feat(task): cover image for tasks (#2460)
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/2460 Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
commit
31e39aa6c8
7 changed files with 169 additions and 81 deletions
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
<input
|
<input
|
||||||
v-if="editEnabled"
|
v-if="editEnabled"
|
||||||
:disabled="attachmentService.loading || undefined"
|
:disabled="loading || undefined"
|
||||||
@change="uploadNewAttachment()"
|
@change="uploadNewAttachment()"
|
||||||
id="files"
|
id="files"
|
||||||
multiple
|
multiple
|
||||||
|
@ -35,7 +35,15 @@
|
||||||
:key="a.id"
|
:key="a.id"
|
||||||
@click="viewOrDownload(a)"
|
@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">
|
<div class="info">
|
||||||
<p class="attachment-info-meta">
|
<p class="attachment-info-meta">
|
||||||
<i18n-t keypath="task.attachment.createdBy" scope="global">
|
<i18n-t keypath="task.attachment.createdBy" scope="global">
|
||||||
|
@ -78,6 +86,17 @@
|
||||||
>
|
>
|
||||||
{{ $t('misc.delete') }}
|
{{ $t('misc.delete') }}
|
||||||
</BaseButton>
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
@ -85,7 +104,7 @@
|
||||||
|
|
||||||
<x-button
|
<x-button
|
||||||
v-if="editEnabled"
|
v-if="editEnabled"
|
||||||
:disabled="attachmentService.loading"
|
:disabled="loading"
|
||||||
@click="filesRef?.click()"
|
@click="filesRef?.click()"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
icon="cloud-upload-alt"
|
icon="cloud-upload-alt"
|
||||||
|
@ -138,13 +157,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, shallowReactive, computed, type PropType} from 'vue'
|
import {ref, shallowReactive, computed} from 'vue'
|
||||||
import {useDropZone} from '@vueuse/core'
|
import {useDropZone} from '@vueuse/core'
|
||||||
|
|
||||||
import User from '@/components/misc/user.vue'
|
import User from '@/components/misc/user.vue'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
import AttachmentService from '@/services/attachment'
|
import AttachmentService from '@/services/attachment'
|
||||||
|
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
|
||||||
import type AttachmentModel from '@/models/attachment'
|
import type AttachmentModel from '@/models/attachment'
|
||||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
@ -155,38 +175,44 @@ import {uploadFiles, generateAttachmentUrl} from '@/helpers/attachments'
|
||||||
import {getHumanSize} from '@/helpers/getHumanSize'
|
import {getHumanSize} from '@/helpers/getHumanSize'
|
||||||
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
||||||
import {error, success} from '@/message'
|
import {error, success} from '@/message'
|
||||||
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
const props = defineProps({
|
const taskStore = useTaskStore()
|
||||||
taskId: {
|
const {t} = useI18n({useScope: 'global'})
|
||||||
type: Number as PropType<ITask['id']>,
|
|
||||||
required: true,
|
const props = withDefaults(defineProps<{
|
||||||
},
|
task: ITask,
|
||||||
initialAttachments: {
|
initialAttachments?: IAttachment[],
|
||||||
type: Array,
|
editEnabled: boolean,
|
||||||
},
|
}>(), {
|
||||||
editEnabled: {
|
editEnabled: true,
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// FIXME: this should go through the store
|
||||||
|
const emit = defineEmits(['task-changed'])
|
||||||
|
|
||||||
const attachmentService = shallowReactive(new AttachmentService())
|
const attachmentService = shallowReactive(new AttachmentService())
|
||||||
|
|
||||||
const attachmentStore = useAttachmentStore()
|
const attachmentStore = useAttachmentStore()
|
||||||
const attachments = computed(() => attachmentStore.attachments)
|
const attachments = computed(() => attachmentStore.attachments)
|
||||||
|
|
||||||
|
const loading = computed(() => attachmentService.loading || taskStore.isLoading)
|
||||||
|
|
||||||
function onDrop(files: File[] | null) {
|
function onDrop(files: File[] | null) {
|
||||||
if (files && files.length !== 0) {
|
if (files && files.length !== 0) {
|
||||||
uploadFilesToTask(files)
|
uploadFilesToTask(files)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isOverDropZone } = useDropZone(document, onDrop)
|
const {isOverDropZone} = useDropZone(document, onDrop)
|
||||||
|
|
||||||
function downloadAttachment(attachment: IAttachment) {
|
function downloadAttachment(attachment: IAttachment) {
|
||||||
attachmentService.download(attachment)
|
attachmentService.download(attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesRef = ref<HTMLInputElement | null>(null)
|
const filesRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
function uploadNewAttachment() {
|
function uploadNewAttachment() {
|
||||||
const files = filesRef.value?.files
|
const files = filesRef.value?.files
|
||||||
|
|
||||||
|
@ -198,7 +224,7 @@ function uploadNewAttachment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadFilesToTask(files: File[] | FileList) {
|
function uploadFilesToTask(files: File[] | FileList) {
|
||||||
uploadFiles(attachmentService, props.taskId, files)
|
uploadFiles(attachmentService, props.task.id, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentToDelete = ref<AttachmentModel | null>(null)
|
const attachmentToDelete = ref<AttachmentModel | null>(null)
|
||||||
|
@ -217,16 +243,15 @@ async function deleteAttachment() {
|
||||||
attachmentStore.removeById(attachmentToDelete.value.id)
|
attachmentStore.removeById(attachmentToDelete.value.id)
|
||||||
success(r)
|
success(r)
|
||||||
setAttachmentToDelete(null)
|
setAttachmentToDelete(null)
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
error(e)
|
error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentImageBlobUrl = ref<string | null>(null)
|
const attachmentImageBlobUrl = ref<string | null>(null)
|
||||||
const SUPPORTED_SUFFIX = ['.jpg', '.png', '.bmp', '.gif']
|
|
||||||
|
|
||||||
async function viewOrDownload(attachment: AttachmentModel) {
|
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)
|
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
|
||||||
} else {
|
} else {
|
||||||
downloadAttachment(attachment)
|
downloadAttachment(attachment)
|
||||||
|
@ -234,8 +259,15 @@ async function viewOrDownload(attachment: AttachmentModel) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const copy = useCopyToClipboard()
|
const copy = useCopyToClipboard()
|
||||||
|
|
||||||
function copyUrl(attachment: IAttachment) {
|
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>
|
</script>
|
||||||
|
|
||||||
|
@ -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();
|
@include modal-transition();
|
||||||
</style>
|
</style>
|
|
@ -11,6 +11,13 @@
|
||||||
@click.ctrl="() => toggleTaskDone(task)"
|
@click.ctrl="() => toggleTaskDone(task)"
|
||||||
@click.meta="() => toggleTaskDone(task)"
|
@click.meta="() => toggleTaskDone(task)"
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
v-if="coverImageBlobUrl"
|
||||||
|
:src="coverImageBlobUrl"
|
||||||
|
alt=""
|
||||||
|
class="cover-image"
|
||||||
|
/>
|
||||||
|
<div class="p-2">
|
||||||
<span class="task-id">
|
<span class="task-id">
|
||||||
<Done class="kanban-card__done" :is-done="task.done" variant="small"/>
|
<Done class="kanban-card__done" :is-done="task.done" variant="small"/>
|
||||||
<template v-if="task.identifier === ''">
|
<template v-if="task.identifier === ''">
|
||||||
|
@ -63,10 +70,11 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {ref, computed} from 'vue'
|
import {ref, computed, watch} from 'vue'
|
||||||
import {useRouter} from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
|
|
||||||
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
|
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 {TASK_DEFAULT_COLOR, getHexColor} from '@/models/task'
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
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 {formatDateLong, formatISO, formatDateSince} from '@/helpers/time/formatDate'
|
||||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||||
|
@ -114,6 +124,29 @@ function openTaskDetail() {
|
||||||
state: {backdropView: router.currentRoute.value.fullPath},
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -125,12 +158,11 @@ $task-background: var(--white);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
display: block;
|
display: block;
|
||||||
border: 3px solid transparent;
|
|
||||||
|
|
||||||
font-size: .9rem;
|
font-size: .9rem;
|
||||||
padding: .4rem;
|
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
background: $task-background;
|
background: $task-background;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&.loader-container.is-loading::after {
|
&.loader-container.is-loading::after {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
|
|
|
@ -693,7 +693,11 @@
|
||||||
"deleteTooltip": "Delete this attachment",
|
"deleteTooltip": "Delete this attachment",
|
||||||
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
|
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
|
||||||
"copyUrl": "Copy URL",
|
"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": {
|
"comment": {
|
||||||
"title": "Comments",
|
"title": "Comments",
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Priority } from '@/constants/priorities'
|
import type {Priority} from '@/constants/priorities'
|
||||||
|
|
||||||
import type {IAbstract} from './IAbstract'
|
import type {IAbstract} from './IAbstract'
|
||||||
import type {IUser} from './IUser'
|
import type {IUser} from './IUser'
|
||||||
|
@ -11,6 +11,7 @@ import type {IBucket} from './IBucket'
|
||||||
import type {IRelationKind} from '@/types/IRelationKind'
|
import type {IRelationKind} from '@/types/IRelationKind'
|
||||||
import type {IRepeatAfter} from '@/types/IRepeatAfter'
|
import type {IRepeatAfter} from '@/types/IRepeatAfter'
|
||||||
import type {IRepeatMode} from '@/types/IRepeatMode'
|
import type {IRepeatMode} from '@/types/IRepeatMode'
|
||||||
|
|
||||||
export interface ITask extends IAbstract {
|
export interface ITask extends IAbstract {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
|
@ -31,8 +32,9 @@ export interface ITask extends IAbstract {
|
||||||
parentTaskId: ITask['id']
|
parentTaskId: ITask['id']
|
||||||
hexColor: string
|
hexColor: string
|
||||||
percentDone: number
|
percentDone: number
|
||||||
relatedTasks: Partial<Record<IRelationKind, ITask[]>>,
|
relatedTasks: Partial<Record<IRelationKind, ITask[]>>
|
||||||
attachments: IAttachment[]
|
attachments: IAttachment[]
|
||||||
|
coverImageAttachmentId: IAttachment['id']
|
||||||
identifier: string
|
identifier: string
|
||||||
index: number
|
index: number
|
||||||
isFavorite: boolean
|
isFavorite: boolean
|
||||||
|
|
|
@ -5,6 +5,8 @@ import type { IUser } from '@/modelTypes/IUser'
|
||||||
import type { IFile } from '@/modelTypes/IFile'
|
import type { IFile } from '@/modelTypes/IFile'
|
||||||
import type { IAttachment } from '@/modelTypes/IAttachment'
|
import type { IAttachment } from '@/modelTypes/IAttachment'
|
||||||
|
|
||||||
|
export const SUPPORTED_IMAGE_SUFFIX = ['.jpg', '.png', '.bmp', '.gif']
|
||||||
|
|
||||||
export default class AttachmentModel extends AbstractModel<IAttachment> implements IAttachment {
|
export default class AttachmentModel extends AbstractModel<IAttachment> implements IAttachment {
|
||||||
id = 0
|
id = 0
|
||||||
taskId = 0
|
taskId = 0
|
||||||
|
|
|
@ -409,6 +409,13 @@ export const useTaskStore = defineStore('task', {
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async setCoverImage(task: ITask, attachment: IAttachment | null) {
|
||||||
|
return this.update({
|
||||||
|
...task,
|
||||||
|
coverImageAttachmentId: attachment ? attachment.id : 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -218,7 +218,8 @@
|
||||||
<div class="content attachments" v-if="activeFields.attachments || hasAttachments">
|
<div class="content attachments" v-if="activeFields.attachments || hasAttachments">
|
||||||
<attachments
|
<attachments
|
||||||
:edit-enabled="canWrite"
|
:edit-enabled="canWrite"
|
||||||
:task-id="taskId"
|
:task="task"
|
||||||
|
@task-changed="({coverImageAttachmentId}) => task.coverImageAttachmentId = coverImageAttachmentId"
|
||||||
ref="attachments"
|
ref="attachments"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -500,7 +501,7 @@ const attachmentStore = useAttachmentStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const kanbanStore = useKanbanStore()
|
const kanbanStore = useKanbanStore()
|
||||||
|
|
||||||
const task = reactive(new TaskModel())
|
const task = reactive<ITask>(new TaskModel())
|
||||||
useTitle(toRef(task, 'title'))
|
useTitle(toRef(task, 'title'))
|
||||||
|
|
||||||
// We doubled the task color property here because verte does not have a real change property, leading
|
// We doubled the task color property here because verte does not have a real change property, leading
|
||||||
|
|
Loading…
Reference in a new issue