feat: feat-attachments-script-setup (#2358)

Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/2358
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
Dominik Pschenitschni 2022-09-22 15:31:13 +00:00 committed by konrad
parent 13bc25ff5d
commit 4dfcd8e70f
7 changed files with 249 additions and 283 deletions

View file

@ -8,19 +8,19 @@
</h3> </h3>
<input <input
v-if="editEnabled"
:disabled="attachmentService.loading || undefined" :disabled="attachmentService.loading || undefined"
@change="uploadNewAttachment()" @change="uploadNewAttachment()"
id="files" id="files"
multiple multiple
ref="files" ref="filesRef"
type="file" type="file"
v-if="editEnabled"
/> />
<progress <progress
v-if="attachmentService.uploadProgress > 0"
:value="attachmentService.uploadProgress" :value="attachmentService.uploadProgress"
class="progress is-primary" class="progress is-primary"
max="100" max="100"
v-if="attachmentService.uploadProgress > 0"
> >
{{ attachmentService.uploadProgress }}% {{ attachmentService.uploadProgress }}%
</progress> </progress>
@ -42,7 +42,7 @@
<span v-tooltip="formatDateLong(a.created)"> <span v-tooltip="formatDateLong(a.created)">
{{ formatDateSince(a.created) }} {{ formatDateSince(a.created) }}
</span> </span>
<user <User
:avatar-size="24" :avatar-size="24"
:user="a.createdBy" :user="a.createdBy"
:is-inline="true" :is-inline="true"
@ -73,7 +73,7 @@
<BaseButton <BaseButton
v-if="editEnabled" v-if="editEnabled"
class="attachment-info-meta-button" class="attachment-info-meta-button"
@click.prevent.stop="() => {attachmentToDelete = a; showDeleteModal = true}" @click.prevent.stop="setAttachmentToDelete(a)"
v-tooltip="$t('task.attachment.deleteTooltip')" v-tooltip="$t('task.attachment.deleteTooltip')"
> >
{{ $t('misc.delete') }} {{ $t('misc.delete') }}
@ -86,7 +86,7 @@
<x-button <x-button
v-if="editEnabled" v-if="editEnabled"
:disabled="attachmentService.loading" :disabled="attachmentService.loading"
@click="$refs.files.click()" @click="filesRef?.click()"
class="mb-4" class="mb-4"
icon="cloud-upload-alt" icon="cloud-upload-alt"
variant="secondary" variant="secondary"
@ -97,7 +97,7 @@
<!-- Dropzone --> <!-- Dropzone -->
<div <div
:class="{ hidden: !showDropzone }" :class="{ hidden: !isOverDropZone }"
class="dropzone" class="dropzone"
v-if="editEnabled" v-if="editEnabled"
> >
@ -110,13 +110,14 @@
</div> </div>
<!-- Delete modal --> <!-- Delete modal -->
<transition name="modal">
<modal <modal
@close="showDeleteModal = false" v-if="attachmentToDelete !== null"
v-if="showDeleteModal" @close="setAttachmentToDelete(null)"
@submit="deleteAttachment()" @submit="deleteAttachment()"
> >
<template #header><span>{{ $t('task.attachment.delete') }}</span></template> <template #header>
<span>{{ $t('task.attachment.delete') }}</span>
</template>
<template #text> <template #text>
<p> <p>
@ -125,63 +126,39 @@
</p> </p>
</template> </template>
</modal> </modal>
</transition>
<transition name="modal"> <!-- Attachment image modal -->
<modal <modal
@close=" v-if="attachmentImageBlobUrl !== null"
() => { @close="attachmentImageBlobUrl = null"
showImageModal = false
attachmentImageBlobUrl = null
}
"
v-if="showImageModal"
> >
<img :src="attachmentImageBlobUrl" alt=""/> <img :src="attachmentImageBlobUrl" alt=""/>
</modal> </modal>
</transition>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import {defineComponent} from 'vue' import {ref, shallowReactive, computed} from 'vue'
import {useDropZone} from '@vueuse/core'
import {useStore} from '@/store'
import AttachmentService from '../../../services/attachment'
import AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import User from '@/components/misc/user.vue' import User from '@/components/misc/user.vue'
import {mapState} from 'vuex'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { uploadFiles, generateAttachmentUrl } from '@/helpers/attachments'
import {formatDate, formatDateSince, formatDateLong} from '@/helpers/time/formatDate'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import type { IFile } from '@/modelTypes/IFile'
import AttachmentService from '@/services/attachment'
import type AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import {formatDateSince, formatDateLong} from '@/helpers/time/formatDate'
import {uploadFiles, generateAttachmentUrl} from '@/helpers/attachments'
import {getHumanSize} from '@/helpers/getHumanSize' import {getHumanSize} from '@/helpers/getHumanSize'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {error, success} from '@/message'
export default defineComponent({ const props = defineProps({
name: 'attachments',
components: {
BaseButton,
User,
},
data() {
return {
attachmentService: new AttachmentService(),
showDropzone: false,
showDeleteModal: false,
attachmentToDelete: AttachmentModel,
showImageModal: false,
attachmentImageBlobUrl: null,
}
},
props: {
taskId: { taskId: {
required: true,
type: Number, type: Number,
required: true,
}, },
initialAttachments: { initialAttachments: {
type: Array, type: Array,
@ -189,95 +166,79 @@ export default defineComponent({
editEnabled: { editEnabled: {
default: true, default: true,
}, },
}, })
setup(props) { const attachmentService = shallowReactive(new AttachmentService())
const copy = useCopyToClipboard()
function copyUrl(attachment: IAttachment) { const store = useStore()
copy(generateAttachmentUrl(props.taskId, attachment.id)) const attachments = computed(() => store.state.attachments.attachments)
function onDrop(files: File[] | null) {
if (files && files.length !== 0) {
uploadFilesToTask(files)
}
} }
return { copyUrl } const { isOverDropZone } = useDropZone(document, onDrop)
},
computed: mapState({ function downloadAttachment(attachment: IAttachment) {
attachments: (state) => state.attachments.attachments, attachmentService.download(attachment)
}), }
mounted() {
document.addEventListener('dragenter', (e) => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
})
window.addEventListener('dragleave', (e) => { const filesRef = ref<HTMLInputElement | null>(null)
e.stopPropagation() function uploadNewAttachment() {
e.preventDefault() const files = filesRef.value?.files
this.showDropzone = false
})
document.addEventListener('dragover', (e) => { if (!files || files.length === 0) {
e.stopPropagation() return
e.preventDefault() }
this.showDropzone = true
}) uploadFilesToTask(files)
}
document.addEventListener('drop', (e) => {
e.stopPropagation() function uploadFilesToTask(files: File[] | FileList) {
e.preventDefault() uploadFiles(attachmentService, props.taskId, files)
}
let files = e.dataTransfer.files
this.uploadFiles(files) const attachmentToDelete = ref<AttachmentModel | null>(null)
this.showDropzone = false
}) function setAttachmentToDelete(attachment: AttachmentModel | null) {
}, attachmentToDelete.value = attachment
methods: { }
getHumanSize,
formatDate, async function deleteAttachment() {
formatDateSince, if (attachmentToDelete.value === null) {
formatDateLong,
downloadAttachment(attachment: IAttachment) {
this.attachmentService.download(attachment)
},
uploadNewAttachment() {
if (this.$refs.files.files.length === 0) {
return return
} }
this.uploadFiles(this.$refs.files.files)
},
uploadFiles(files: IFile[]) {
uploadFiles(this.attachmentService, this.taskId, files)
},
async deleteAttachment() {
try { try {
const r = await this.attachmentService.delete(this.attachmentToDelete) const r = await attachmentService.delete(attachmentToDelete.value)
this.$store.commit( store.commit(
'attachments/removeById', 'attachments/removeById',
this.attachmentToDelete.id, attachmentToDelete.value.id,
) )
this.$message.success(r) success(r)
} finally{ setAttachmentToDelete(null)
this.showDeleteModal = false } catch(e) {
error(e)
} }
}, }
async viewOrDownload(attachment) {
if ( const attachmentImageBlobUrl = ref<string | null>(null)
attachment.file.name.endsWith('.jpg') || const SUPPORTED_SUFFIX = ['.jpg', '.png', '.bmp', '.gif']
attachment.file.name.endsWith('.png') ||
attachment.file.name.endsWith('.bmp') || async function viewOrDownload(attachment: AttachmentModel) {
attachment.file.name.endsWith('.gif') if (SUPPORTED_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix)) ) {
) { attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
this.showImageModal = true
this.attachmentImageBlobUrl = await this.attachmentService.getBlobUrl(attachment)
} else { } else {
this.downloadAttachment(attachment) downloadAttachment(attachment)
}
}
const copy = useCopyToClipboard()
function copyUrl(attachment: AttachmentModel) {
copy(generateAttachmentUrl(props.taskId, attachment.id))
} }
},
},
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -286,8 +247,16 @@ export default defineComponent({
display: none; display: none;
} }
@media screen and (max-width: $tablet) {
.button {
width: 100%;
}
}
}
.files { .files {
margin-bottom: 1rem; margin-bottom: 1rem;
}
.attachment { .attachment {
margin-bottom: .5rem; margin-bottom: .5rem;
@ -299,6 +268,7 @@ export default defineComponent({
&:hover { &:hover {
background-color: var(--grey-200); background-color: var(--grey-200);
} }
}
.filename { .filename {
font-weight: bold; font-weight: bold;
@ -321,14 +291,6 @@ export default defineComponent({
} }
} }
} }
}
}
@media screen and (max-width: $tablet) {
.button {
width: 100%;
}
}
.dropzone { .dropzone {
position: fixed; position: fixed;
@ -374,7 +336,6 @@ export default defineComponent({
} }
} }
} }
}
.attachment-info-meta { .attachment-info-meta {
display: flex; display: flex;

View file

@ -212,8 +212,13 @@ const actions = computed(() => {
]))) ])))
}) })
function attachmentUpload(...args) { function attachmentUpload(
return uploadFile(props.taskId, ...args) file: File,
onSuccess: (url: string) => void,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onError: (error: string) => void,
) {
return uploadFile(props.taskId, file, onSuccess)
} }
const taskCommentService = shallowReactive(new TaskCommentService()) const taskCommentService = shallowReactive(new TaskCommentService())

View file

@ -1,11 +1,10 @@
import AttachmentModel from '@/models/attachment' import AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment' import type {IAttachment} from '@/modelTypes/IAttachment'
import type {IFile} from '@/modelTypes/IFile'
import AttachmentService from '@/services/attachment' import AttachmentService from '@/services/attachment'
import { store } from '@/store' import { store } from '@/store'
export function uploadFile(taskId: number, file: IFile, onSuccess: () => Function) { export function uploadFile(taskId: number, file: File, onSuccess: (url: string) => void) {
const attachmentService = new AttachmentService() const attachmentService = new AttachmentService()
const files = [file] const files = [file]
@ -15,7 +14,7 @@ export function uploadFile(taskId: number, file: IFile, onSuccess: () => Functio
export async function uploadFiles( export async function uploadFiles(
attachmentService: AttachmentService, attachmentService: AttachmentService,
taskId: number, taskId: number,
files: IFile[], files: File[] | FileList,
onSuccess: Function = () => {}, onSuccess: Function = () => {},
) { ) {
const attachmentModel = new AttachmentModel({taskId}) const attachmentModel = new AttachmentModel({taskId})

View file

@ -58,7 +58,7 @@ app.directive('shortcut', shortcut)
app.directive('cy', cypress) app.directive('cy', cypress)
// global components // global components
import FontAwesomeIcon from './icons' import FontAwesomeIcon from '@/components/misc/Icon'
import Button from '@/components/input/button.vue' import Button from '@/components/input/button.vue'
import Modal from '@/components/misc/modal.vue' import Modal from '@/components/misc/modal.vue'
import Card from '@/components/misc/card.vue' import Card from '@/components/misc/card.vue'

View file

@ -14,7 +14,7 @@ export default class TaskCommentModel extends AbstractModel<ITaskComment> implem
created: Date = null created: Date = null
updated: Date = null updated: Date = null
constructor(data: Partial<ITaskComment>) { constructor(data: Partial<ITaskComment> = {}) {
super() super()
this.assignData(data) this.assignData(data)

View file

@ -4,7 +4,6 @@ import AbstractService from './abstractService'
import AttachmentModel from '../models/attachment' import AttachmentModel from '../models/attachment'
import type { IAttachment } from '@/modelTypes/IAttachment' import type { IAttachment } from '@/modelTypes/IAttachment'
import type { IFile } from '@/modelTypes/IFile'
import {downloadBlob} from '@/helpers/downloadBlob' import {downloadBlob} from '@/helpers/downloadBlob'
@ -18,8 +17,10 @@ export default class AttachmentService extends AbstractService<AttachmentModel>
} }
processModel(model: IAttachment) { processModel(model: IAttachment) {
model.created = formatISO(new Date(model.created)) return {
return model ...model,
created: formatISO(new Date(model.created)),
}
} }
useCreateInterceptor() { useCreateInterceptor() {
@ -52,7 +53,7 @@ export default class AttachmentService extends AbstractService<AttachmentModel>
* @param files * @param files
* @returns {Promise<any|never>} * @returns {Promise<any|never>}
*/ */
create(model: IAttachment, files: IFile[]) { create(model: IAttachment, files: File[] | FileList) {
const data = new FormData() const data = new FormData()
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
// TODO: Validation of file size // TODO: Validation of file size