feat: add display of kanban card attachment image
This commit is contained in:
parent
eae7cc5a6b
commit
3d88fdaadd
4 changed files with 91 additions and 56 deletions
|
@ -145,6 +145,7 @@ 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'
|
||||||
|
@ -223,10 +224,8 @@ async function deleteAttachment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
@ -11,62 +11,70 @@
|
||||||
@click.ctrl="() => toggleTaskDone(task)"
|
@click.ctrl="() => toggleTaskDone(task)"
|
||||||
@click.meta="() => toggleTaskDone(task)"
|
@click.meta="() => toggleTaskDone(task)"
|
||||||
>
|
>
|
||||||
<span class="task-id">
|
<img
|
||||||
<Done class="kanban-card__done" :is-done="task.done" variant="small"/>
|
v-if="coverImageBlobUrl"
|
||||||
<template v-if="task.identifier === ''">
|
:src="coverImageBlobUrl"
|
||||||
#{{ task.index }}
|
alt=""
|
||||||
</template>
|
class="cover-image"
|
||||||
<template v-else>
|
/>
|
||||||
{{ task.identifier }}
|
<div class="p-2">
|
||||||
</template>
|
<span class="task-id">
|
||||||
</span>
|
<Done class="kanban-card__done" :is-done="task.done" variant="small"/>
|
||||||
<span
|
<template v-if="task.identifier === ''">
|
||||||
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
|
#{{ task.index }}
|
||||||
class="due-date"
|
</template>
|
||||||
v-if="task.dueDate > 0"
|
<template v-else>
|
||||||
v-tooltip="formatDateLong(task.dueDate)">
|
{{ task.identifier }}
|
||||||
<span class="icon">
|
</template>
|
||||||
<icon :icon="['far', 'calendar-alt']"/>
|
|
||||||
</span>
|
</span>
|
||||||
<time :datetime="formatISO(task.dueDate)">
|
<span
|
||||||
{{ formatDateSince(task.dueDate) }}
|
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
|
||||||
</time>
|
class="due-date"
|
||||||
</span>
|
v-if="task.dueDate > 0"
|
||||||
<h3>{{ task.title }}</h3>
|
v-tooltip="formatDateLong(task.dueDate)">
|
||||||
<progress
|
<span class="icon">
|
||||||
class="progress is-small"
|
<icon :icon="['far', 'calendar-alt']"/>
|
||||||
v-if="task.percentDone > 0"
|
</span>
|
||||||
:value="task.percentDone * 100" max="100">
|
<time :datetime="formatISO(task.dueDate)">
|
||||||
{{ task.percentDone * 100 }}%
|
{{ formatDateSince(task.dueDate) }}
|
||||||
</progress>
|
</time>
|
||||||
<div class="footer">
|
</span>
|
||||||
<labels :labels="task.labels"/>
|
<h3>{{ task.title }}</h3>
|
||||||
<priority-label :priority="task.priority" :done="task.done"/>
|
<progress
|
||||||
<div class="assignees" v-if="task.assignees.length > 0">
|
class="progress is-small"
|
||||||
<user
|
v-if="task.percentDone > 0"
|
||||||
v-for="u in task.assignees"
|
:value="task.percentDone * 100" max="100">
|
||||||
:avatar-size="24"
|
{{ task.percentDone * 100 }}%
|
||||||
:key="task.id + 'assignee' + u.id"
|
</progress>
|
||||||
:show-username="false"
|
<div class="footer">
|
||||||
:user="u"
|
<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>
|
</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>
|
||||||
</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) {
|
||||||
|
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))) {
|
||||||
|
console.log('not an attachment')
|
||||||
|
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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue