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:
commit
34d69fa588
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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;
|
||||||
|
58
frontend/src/components/tasks/partials/file-preview.vue
Normal file
58
frontend/src/components/tasks/partials/file-preview.vue
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user