Frontend: Proof of concept for image preview #2266

Merged
konrad merged 20 commits from Elscrux/vikunja:feature/image-preview into main 2024-05-25 12:11:05 +00:00
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
cy.wait('@uploadAttachment')
cy.get('.attachments .attachments .files a.attachment')
cy.get('.attachments .attachments .files button.attachment')

This needs to be changed again to make the test pass.

This needs to be changed again to make the test pass.
.should('exist')
}

View File

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

View File

@ -27,15 +27,19 @@
v-if="attachments.length > 0"
class="files"
>
<!-- FIXME: don't use a for element that wraps other links / buttons
Instead: overlay element with button that is inside.
-->
<a
<button

Please do not use a table for multi-column layout.

Please do not use a table for multi-column layout.

I used a grid instead now

I used a grid instead now
v-for="a in attachments"
:key="a.id"
class="attachment"

grid-item sounds like a child of a grid. Why not call it attachment as it was before?

`grid-item` sounds like a child of a grid. Why not call it `attachment` as it was before?
@click="viewOrDownload(a)"
>
<div class="preview-column">
<FilePreview
class="attachment-preview"
:model-value="a"
/>
</div>
<div class="attachment-info-column">
<div class="filename">
{{ a.file.name }}
<span
@ -73,14 +77,14 @@
class="attachment-info-meta-button"
@click.prevent.stop="downloadAttachment(a)"
>
{{ $t('misc.download') }}
<icon icon="download" />
</BaseButton>
<BaseButton
v-tooltip="$t('task.attachment.copyUrlTooltip')"
class="attachment-info-meta-button"
@click.stop="copyUrl(a)"
>
{{ $t('task.attachment.copyUrl') }}
<icon icon="copy" />
</BaseButton>
<BaseButton
v-if="editEnabled"
@ -88,22 +92,22 @@
class="attachment-info-meta-button"
@click.prevent.stop="setAttachmentToDelete(a)"
>
{{ $t('misc.delete') }}
<icon icon="trash-alt" />
</BaseButton>
<BaseButton
Review

This needs a tooltip

This needs a tooltip
v-if="editEnabled"
v-if="editEnabled && canPreview(a)"
v-tooltip="task.coverImageAttachmentId === a.id
? $t('task.attachment.unsetAsCover')
: $t('task.attachment.setAsCover')"
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')
}}
<icon :icon="task.coverImageAttachmentId === a.id ? 'eye-slash' : 'eye'" />
</BaseButton>
</p>
</div>
</a>
</div>
</button>
</div>
<x-button
@ -188,6 +192,7 @@ import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useI18n} from 'vue-i18n'
import FilePreview from '@/components/tasks/partials/file-preview.vue'
const {
task,
@ -260,13 +265,17 @@ async function deleteAttachment() {
const attachmentImageBlobUrl = ref<string | null>(null)
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)
} else {
downloadAttachment(attachment)
}
}
function canPreview(attachment: IAttachment): boolean {
return SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))
}
const copy = useCopyToClipboard()
function copyUrl(attachment: IAttachment) {
@ -298,26 +307,37 @@ async function setCoverImage(attachment: IAttachment | null) {
}
.attachment {
Review

Please make this width: 100%.

Please make this `width: 100%`.
Review

done!

done!
margin-bottom: .5rem;
display: block;
transition: background-color $transition;
border-radius: $radius;
display: grid;
grid-template-columns: 9rem 1fr;
align-items: center;
width: 100%;
padding: .5rem;
transition: background-color $transition;
background-color: transparent;
border: transparent;
border-radius: $radius;
&:hover {
background-color: var(--grey-200);
}
}
.filename {
display: flex;
align-items: center;
font-weight: bold;
margin-bottom: .25rem;
height: 2rem;
color: var(--text);
}
.info {
color: var(--grey-500);
font-size: .9rem;
display: flex;
flex-direction: column;
p {
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 {
display: flex;
align-items: center;
@ -406,6 +432,7 @@ async function setCoverImage(attachment: IAttachment | null) {
.attachment-info-meta-button {
color: var(--link);
padding: 0 .25rem;
}
@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 {
background: var(--primary);
color: var(--white);
margin-left: .25rem;
padding: .25rem .35rem;
border-radius: 4px;
font-size: .75rem;

View File

@ -0,0 +1,58 @@
<template>
<!-- Preview image -->

Can you make this always the same size? Use object-fit: cover; to let the image fill the container nicely.

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.

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

Did you set a fixed with and height for the image?

Did you set a fixed with and height for the image?

Yes, it's at 4rem now

Yes, it's at 4rem now
<img
v-if="blobUrl"
:src="blobUrl"

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.

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

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.

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

Please use $radius here so that the radius is consistent with other elements.

Please use `$radius` here so that the radius is consistent with other elements.

Done!

Done!

Adding a width: 100% here will make it look like what I meant (will share a screenshot later)

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>