1
0

feat(task): show attachment preview for image attachments (#2266)

Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2266
Reviewed-by: konrad <k@knt.li>
This commit is contained in:
konrad 2024-05-25 12:11:03 +00:00
commit 34d69fa588
4 changed files with 176 additions and 75 deletions

View File

@ -42,7 +42,7 @@ function uploadAttachmentAndVerify(taskId: number) {
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose .selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.wait('@uploadAttachment') cy.wait('@uploadAttachment')
cy.get('.attachments .attachments .files a.attachment') cy.get('.attachments .attachments .files button.attachment')
.should('exist') .should('exist')
} }

View File

@ -24,11 +24,15 @@ import {
faCocktail, faCocktail,
faCoffee, faCoffee,
faCog, faCog,
faCopy,
faDownload,
faEllipsisH, faEllipsisH,
faEllipsisV, faEllipsisV,
faExclamation, faExclamation,
faEye, faEye,
faEyeSlash, faEyeSlash,
faFile,
faFileImage,
faFillDrip, faFillDrip,
faFilter, faFilter,
faForward, faForward,
@ -81,7 +85,6 @@ import {
faCheckSquare, faCheckSquare,
faClock, faClock,
faComments, faComments,
faFileImage,
faSave, faSave,
faSquareCheck, faSquareCheck,
faStar, faStar,
@ -102,6 +105,7 @@ library.add(faUnlink)
library.add(faParagraph) library.add(faParagraph)
library.add(faSquareCheck) library.add(faSquareCheck)
library.add(faTable) library.add(faTable)
library.add(faFile)
library.add(faFileImage) library.add(faFileImage)
library.add(faCheckSquare) library.add(faCheckSquare)
library.add(faStrikethrough) library.add(faStrikethrough)
@ -130,6 +134,8 @@ library.add(faCocktail)
library.add(faCoffee) library.add(faCoffee)
library.add(faCog) library.add(faCog)
library.add(faComments) library.add(faComments)
library.add(faCopy)
library.add(faDownload)
library.add(faEllipsisH) library.add(faEllipsisH)
library.add(faEllipsisV) library.add(faEllipsisV)
library.add(faExclamation) library.add(faExclamation)

View File

@ -27,83 +27,87 @@
v-if="attachments.length > 0" v-if="attachments.length > 0"
class="files" class="files"
> >
<!-- FIXME: don't use a for element that wraps other links / buttons <button
Instead: overlay element with button that is inside.
-->
<a
v-for="a in attachments" v-for="a in attachments"
:key="a.id" :key="a.id"
class="attachment" class="attachment"
@click="viewOrDownload(a)" @click="viewOrDownload(a)"
> >
<div class="filename"> <div class="preview-column">
{{ a.file.name }} <FilePreview
<span class="attachment-preview"
v-if="task.coverImageAttachmentId === a.id" :model-value="a"
class="is-task-cover" />
>
{{ $t('task.attachment.usedAsCover') }}
</span>
</div> </div>
<div class="info"> <div class="attachment-info-column">
<p class="attachment-info-meta"> <div class="filename">
<i18n-t {{ a.file.name }}
keypath="task.attachment.createdBy" <span
scope="global" v-if="task.coverImageAttachmentId === a.id"
class="is-task-cover"
> >
<span v-tooltip="formatDateLong(a.created)"> {{ $t('task.attachment.usedAsCover') }}
{{ formatDateSince(a.created) }} </span>
</div>
<div class="info">
<p class="attachment-info-meta">
<i18n-t
keypath="task.attachment.createdBy"
scope="global"
>
<span v-tooltip="formatDateLong(a.created)">
{{ formatDateSince(a.created) }}
</span>
<User
:avatar-size="24"
:user="a.createdBy"
:is-inline="true"
/>
</i18n-t>
<span>
{{ getHumanSize(a.file.size) }}
</span> </span>
<User <span v-if="a.file.mime">
:avatar-size="24" {{ a.file.mime }}
:user="a.createdBy" </span>
:is-inline="true" </p>
/> <p>
</i18n-t> <BaseButton
<span> v-tooltip="$t('task.attachment.downloadTooltip')"
{{ getHumanSize(a.file.size) }} class="attachment-info-meta-button"
</span> @click.prevent.stop="downloadAttachment(a)"
<span v-if="a.file.mime"> >
{{ a.file.mime }} <icon icon="download" />
</span> </BaseButton>
</p> <BaseButton
<p> v-tooltip="$t('task.attachment.copyUrlTooltip')"
<BaseButton class="attachment-info-meta-button"
v-tooltip="$t('task.attachment.downloadTooltip')" @click.stop="copyUrl(a)"
class="attachment-info-meta-button" >
@click.prevent.stop="downloadAttachment(a)" <icon icon="copy" />
> </BaseButton>
{{ $t('misc.download') }} <BaseButton
</BaseButton> v-if="editEnabled"
<BaseButton v-tooltip="$t('task.attachment.deleteTooltip')"
v-tooltip="$t('task.attachment.copyUrlTooltip')" class="attachment-info-meta-button"
class="attachment-info-meta-button" @click.prevent.stop="setAttachmentToDelete(a)"
@click.stop="copyUrl(a)" >
> <icon icon="trash-alt" />
{{ $t('task.attachment.copyUrl') }} </BaseButton>
</BaseButton> <BaseButton
<BaseButton v-if="editEnabled && canPreview(a)"
v-if="editEnabled" v-tooltip="task.coverImageAttachmentId === a.id
v-tooltip="$t('task.attachment.deleteTooltip')"
class="attachment-info-meta-button"
@click.prevent.stop="setAttachmentToDelete(a)"
>
{{ $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.unsetAsCover')
: $t('task.attachment.setAsCover') : $t('task.attachment.setAsCover')"
}} class="attachment-info-meta-button"
</BaseButton> @click.prevent.stop="setCoverImage(task.coverImageAttachmentId === a.id ? null : a)"
</p> >
<icon :icon="task.coverImageAttachmentId === a.id ? 'eye-slash' : 'eye'" />
</BaseButton>
</p>
</div>
</div> </div>
</a> </button>
</div> </div>
<x-button <x-button
@ -188,6 +192,7 @@ import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {error, success} from '@/message' import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks' import {useTaskStore} from '@/stores/tasks'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import FilePreview from '@/components/tasks/partials/file-preview.vue'
const { const {
task, task,
@ -260,13 +265,17 @@ async function deleteAttachment() {
const attachmentImageBlobUrl = ref<string | null>(null) const attachmentImageBlobUrl = ref<string | null>(null)
async function viewOrDownload(attachment: IAttachment) { async function viewOrDownload(attachment: IAttachment) {
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) { if (canPreview(attachment)) {
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment) attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else { } else {
downloadAttachment(attachment) downloadAttachment(attachment)
} }
} }
function canPreview(attachment: IAttachment): boolean {
return SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))
}
const copy = useCopyToClipboard() const copy = useCopyToClipboard()
function copyUrl(attachment: IAttachment) { function copyUrl(attachment: IAttachment) {
@ -298,11 +307,18 @@ async function setCoverImage(attachment: IAttachment | null) {
} }
.attachment { .attachment {
margin-bottom: .5rem; display: grid;
display: block; grid-template-columns: 9rem 1fr;
transition: background-color $transition; align-items: center;
border-radius: $radius; width: 100%;
padding: .5rem; padding: .5rem;
transition: background-color $transition;
background-color: transparent;
border: transparent;
border-radius: $radius;
&:hover { &:hover {
background-color: var(--grey-200); background-color: var(--grey-200);
@ -310,14 +326,18 @@ async function setCoverImage(attachment: IAttachment | null) {
} }
.filename { .filename {
display: flex;
align-items: center;
font-weight: bold; font-weight: bold;
margin-bottom: .25rem; height: 2rem;
color: var(--text); color: var(--text);
} }
.info { .info {
color: var(--grey-500); color: var(--grey-500);
font-size: .9rem; font-size: .9rem;
display: flex;
flex-direction: column;
p { p {
margin-bottom: 0; margin-bottom: 0;
@ -375,6 +395,12 @@ async function setCoverImage(attachment: IAttachment | null) {
} }
} }
.attachment-info-column {
display: flex;
flex-flow: column wrap;
align-self: start;
}
.attachment-info-meta { .attachment-info-meta {
display: flex; display: flex;
align-items: center; align-items: center;
@ -406,6 +432,7 @@ async function setCoverImage(attachment: IAttachment | null) {
.attachment-info-meta-button { .attachment-info-meta-button {
color: var(--link); color: var(--link);
padding: 0 .25rem;
} }
@keyframes bounce { @keyframes bounce {
@ -434,9 +461,19 @@ async function setCoverImage(attachment: IAttachment | null) {
} }
} }
.preview-column {
max-width: 8rem;
height: 5.2rem;
}
.attachment-preview {
height: 100%;
}
.is-task-cover { .is-task-cover {
background: var(--primary); background: var(--primary);
color: var(--white); color: var(--white);
margin-left: .25rem;
padding: .25rem .35rem; padding: .25rem .35rem;
border-radius: 4px; border-radius: 4px;
font-size: .75rem; font-size: .75rem;

View File

@ -0,0 +1,58 @@
<template>
<!-- Preview image -->
<img
v-if="blobUrl"
:src="blobUrl"
alt="Attachment preview"
>
<!-- Fallback -->
<div
v-else
class="icon-wrapper"
>
<icon
size="6x"
icon="file"
/>
</div>
</template>
<script setup lang="ts">
import {type PropType, ref, shallowReactive, watchEffect} from 'vue'
import AttachmentService from '@/services/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
const props = defineProps({
modelValue: {
type: Object as PropType<IAttachment>,
default: undefined,
},
})
const attachmentService = shallowReactive(new AttachmentService())
const blobUrl = ref<string | undefined>(undefined)
watchEffect(async () => {
if (props.modelValue && canPreview(props.modelValue)) {
blobUrl.value = await attachmentService.getBlobUrl(props.modelValue)
}
})
function canPreview(attachment: IAttachment): boolean {
return SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))
}
</script>
<style scoped lang="scss">
img {
width: 100%;
border-radius: $radius;
object-fit: cover;
}
.icon-wrapper {
color: var(--grey-500);
}
</style>