<template> <div class="attachments"> <h3> <span class="icon is-grey"> <icon icon="paperclip"/> </span> {{ $t('task.attachment.title') }} </h3> <input v-if="editEnabled" :disabled="attachmentService.loading || undefined" @change="uploadNewAttachment()" id="files" multiple ref="filesRef" type="file" /> <progress v-if="attachmentService.uploadProgress > 0" :value="attachmentService.uploadProgress" class="progress is-primary" max="100" > {{ attachmentService.uploadProgress }}% </progress> <div class="files" v-if="attachments.length > 0"> <!-- FIXME: don't use a for element that wraps other links / buttons Instead: overlay element with button that is inside. --> <a class="attachment" v-for="a in attachments" :key="a.id" @click="viewOrDownload(a)" > <div class="filename">{{ a.file.name }}</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 v-if="a.file.mime"> {{ a.file.mime }} </span> </p> <p> <BaseButton class="attachment-info-meta-button" @click.prevent.stop="downloadAttachment(a)" v-tooltip="$t('task.attachment.downloadTooltip')" > {{ $t('misc.download') }} </BaseButton> <BaseButton class="attachment-info-meta-button" @click.stop="copyUrl(a)" v-tooltip="$t('task.attachment.copyUrlTooltip')" > {{ $t('task.attachment.copyUrl') }} </BaseButton> <BaseButton v-if="editEnabled" class="attachment-info-meta-button" @click.prevent.stop="setAttachmentToDelete(a)" v-tooltip="$t('task.attachment.deleteTooltip')" > {{ $t('misc.delete') }} </BaseButton> </p> </div> </a> </div> <x-button v-if="editEnabled" :disabled="attachmentService.loading" @click="filesRef?.click()" class="mb-4" icon="cloud-upload-alt" variant="secondary" :shadow="false" > {{ $t('task.attachment.upload') }} </x-button> <!-- Dropzone --> <div :class="{ hidden: !isOverDropZone }" class="dropzone" v-if="editEnabled" > <div class="drop-hint"> <div class="icon"> <icon icon="cloud-upload-alt"/> </div> <div class="hint">{{ $t('task.attachment.drop') }}</div> </div> </div> <!-- Delete modal --> <modal v-if="attachmentToDelete !== null" @close="setAttachmentToDelete(null)" @submit="deleteAttachment()" > <template #header> <span>{{ $t('task.attachment.delete') }}</span> </template> <template #text> <p> {{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br/> <strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong> </p> </template> </modal> <!-- Attachment image modal --> <modal v-if="attachmentImageBlobUrl !== null" @close="attachmentImageBlobUrl = null" > <img :src="attachmentImageBlobUrl" alt=""/> </modal> </div> </template> <script setup lang="ts"> import {ref, shallowReactive, computed, type PropType} from 'vue' import {useDropZone} from '@vueuse/core' import User from '@/components/misc/user.vue' import BaseButton from '@/components/base/BaseButton.vue' import AttachmentService from '@/services/attachment' import type AttachmentModel from '@/models/attachment' import type {IAttachment} from '@/modelTypes/IAttachment' import type {ITask} from '@/modelTypes/ITask' import {useAttachmentStore} from '@/stores/attachments' import {formatDateSince, formatDateLong} from '@/helpers/time/formatDate' import {uploadFiles, generateAttachmentUrl} from '@/helpers/attachments' import {getHumanSize} from '@/helpers/getHumanSize' import {useCopyToClipboard} from '@/composables/useCopyToClipboard' import {error, success} from '@/message' const props = defineProps({ taskId: { type: Number as PropType<ITask['id']>, required: true, }, initialAttachments: { type: Array, }, editEnabled: { default: true, }, }) const attachmentService = shallowReactive(new AttachmentService()) const attachmentStore = useAttachmentStore() const attachments = computed(() => attachmentStore.attachments) function onDrop(files: File[] | null) { if (files && files.length !== 0) { uploadFilesToTask(files) } } const { isOverDropZone } = useDropZone(document, onDrop) function downloadAttachment(attachment: IAttachment) { attachmentService.download(attachment) } const filesRef = ref<HTMLInputElement | null>(null) function uploadNewAttachment() { const files = filesRef.value?.files if (!files || files.length === 0) { return } uploadFilesToTask(files) } function uploadFilesToTask(files: File[] | FileList) { uploadFiles(attachmentService, props.taskId, files) } const attachmentToDelete = ref<AttachmentModel | null>(null) function setAttachmentToDelete(attachment: AttachmentModel | null) { attachmentToDelete.value = attachment } async function deleteAttachment() { if (attachmentToDelete.value === null) { return } try { const r = await attachmentService.delete(attachmentToDelete.value) attachmentStore.removeById(this.attachmentToDelete.id) success(r) setAttachmentToDelete(null) } catch(e) { error(e) } } const attachmentImageBlobUrl = ref<string | null>(null) const SUPPORTED_SUFFIX = ['.jpg', '.png', '.bmp', '.gif'] async function viewOrDownload(attachment: AttachmentModel) { if (SUPPORTED_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix)) ) { attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment) } else { downloadAttachment(attachment) } } const copy = useCopyToClipboard() function copyUrl(attachment: IAttachment) { copy(generateAttachmentUrl(props.taskId, attachment.id)) } </script> <style lang="scss" scoped> .attachments { input[type=file] { display: none; } @media screen and (max-width: $tablet) { .button { width: 100%; } } } .files { margin-bottom: 1rem; } .attachment { margin-bottom: .5rem; display: block; transition: background-color $transition; border-radius: $radius; padding: .5rem; &:hover { background-color: var(--grey-200); } } .filename { font-weight: bold; margin-bottom: .25rem; color: var(--text); } .info { color: var(--grey-500); font-size: .9rem; p { margin-bottom: 0; display: flex; > span:not(:last-child):after, > button:not(:last-child):after { content: 'ยท'; padding: 0 .25rem; } } } .dropzone { position: fixed; background: rgba(250, 250, 250, 0.8); top: 0; left: 0; bottom: 0; right: 0; z-index: 100; text-align: center; &.hidden { display: none; } .drop-hint { position: absolute; bottom: 0; left: 0; right: 0; .icon { width: 100%; font-size: 5rem; height: auto; text-shadow: var(--shadow-md); animation: bounce 2s infinite; @media (prefers-reduced-motion: reduce) { animation: none; } } .hint { margin: .5rem auto 2rem; border-radius: 2px; box-shadow: var(--shadow-md); background: var(--primary); padding: 1rem; color: var(--white); width: 100%; max-width: 300px; } } } .attachment-info-meta { display: flex; align-items: center; :deep(.user) { display: flex !important; align-items: center; margin: 0 .5rem; } @media screen and (max-width: $mobile) { flex-direction: column; align-items: flex-start; :deep(.user) { margin: .5rem 0; } > span:not(:last-child):after, > button:not(:last-child):after { display: none; } .user .username { display: none; } } } .attachment-info-meta-button { color: var(--link); } @keyframes bounce { from, 20%, 53%, 80%, to { animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); transform: translate3d(0, 0, 0); } 40%, 43% { animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); transform: translate3d(0, -30px, 0); } 70% { animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); transform: translate3d(0, -15px, 0); } 90% { transform: translate3d(0, -4px, 0); } } @include modal-transition(); </style>