Frontend: Proof of concept for image preview #2266
|
@ -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
|
||||||
konrad
commented
Please do not use a table for multi-column layout. Please do not use a table for multi-column layout.
Elscrux
commented
I used a grid instead now I used a grid instead now
|
|||||||
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"
|
||||||
konrad
commented
`grid-item` sounds like a child of a grid. Why not call it `attachment` as it was before?
|
|||||||
@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">
|
||||||
konrad
commented
I would extract this out into its own component, which takes the attachment, fetches the image and shows it next to the attachment. We could extend this further to show some kind of icon for pdfs or other file types later on. I would extract this out into its own component, which takes the attachment, fetches the image and shows it next to the attachment. We could extend this further to show some kind of icon for pdfs or other file types later on.
Elscrux
commented
Good point, I did that Good point, I did that
|
|||||||
: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
|
||||||
konrad
commented
This needs a tooltip This needs a tooltip
|
|||||||
<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 {
|
||||||
konrad
commented
Please make this Please make this `width: 100%`.
Elscrux
commented
done! done!
|
|||||||
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
|
@ -0,0 +1,58 @@
|
||||||
|
<template>
|
||||||
|
<!-- Preview image -->
|
||||||
konrad
commented
Can you make this always the same size? Use Can you make this always the same size? Use [`object-fit: cover;`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) to let the image fill the container nicely.
Elscrux
commented
I included it, but it didn't seem like it made a difference I included it, but it didn't seem like it made a difference
konrad
commented
Did you set a fixed with and height for the image? Did you set a fixed with and height for the image?
Elscrux
commented
Yes, it's at 4rem now Yes, it's at 4rem now
|
|||||||
|
<img
|
||||||
|
v-if="blobUrl"
|
||||||
|
:src="blobUrl"
|
||||||
konrad
commented
Please do not use inline styles. This image should have a rectangular fixed size, then the Please do not use inline styles.
This image should have a rectangular fixed size, then the `object-fit` property will make sure it looks good.
|
|||||||
|
alt="Attachment preview"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Fallback -->
|
||||||
|
<div
|
||||||
konrad
commented
Can you use a simple file icon here? An image icon suggests the file is an image, which it is not. Can you use a simple [file icon](https://fontawesome.com/icons/file?f=classic&s=regular) here? An image icon suggests the file is an image, which it is not.
konrad
commented
Also please make the file icon in a dark gray, similar to the attachment title. Also please make the file icon in a dark gray, similar to the attachment title.
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
konrad marked this conversation as resolved
Outdated
konrad
commented
Please use Please use `$radius` here so that the radius is consistent with other elements.
Elscrux
commented
Done! Done!
|
|||||||
|
|
||||||
konrad
commented
Adding a Adding a `width: 100%` here will make it look like what I meant (will share a screenshot later)
|
|||||||
|
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>
|
This needs to be changed again to make the test pass.