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,270 +110,231 @@
</div> </div>
<!-- Delete modal --> <!-- Delete modal -->
<transition name="modal"> <modal
<modal v-if="attachmentToDelete !== null"
@close="showDeleteModal = false" @close="setAttachmentToDelete(null)"
v-if="showDeleteModal" @submit="deleteAttachment()"
@submit="deleteAttachment()" >
> <template #header>
<template #header><span>{{ $t('task.attachment.delete') }}</span></template> <span>{{ $t('task.attachment.delete') }}</span>
</template>
<template #text> <template #text>
<p> <p>
{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br/> {{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br/>
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong> <strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</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 <img :src="attachmentImageBlobUrl" alt=""/>
} </modal>
"
v-if="showImageModal"
>
<img :src="attachmentImageBlobUrl" alt=""/>
</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 { getHumanSize } from '@/helpers/getHumanSize'
export default defineComponent({ import AttachmentService from '@/services/attachment'
name: 'attachments', import type AttachmentModel from '@/models/attachment'
components: { import type {IAttachment} from '@/modelTypes/IAttachment'
BaseButton,
User, 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,
required: true,
}, },
data() { initialAttachments: {
return { type: Array,
attachmentService: new AttachmentService(),
showDropzone: false,
showDeleteModal: false,
attachmentToDelete: AttachmentModel,
showImageModal: false,
attachmentImageBlobUrl: null,
}
}, },
props: { editEnabled: {
taskId: { default: true,
required: true,
type: Number,
},
initialAttachments: {
type: Array,
},
editEnabled: {
default: true,
},
},
setup(props) {
const copy = useCopyToClipboard()
function copyUrl(attachment: IAttachment) {
copy(generateAttachmentUrl(props.taskId, attachment.id))
}
return { copyUrl }
},
computed: mapState({
attachments: (state) => state.attachments.attachments,
}),
mounted() {
document.addEventListener('dragenter', (e) => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
})
window.addEventListener('dragleave', (e) => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = false
})
document.addEventListener('dragover', (e) => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
})
document.addEventListener('drop', (e) => {
e.stopPropagation()
e.preventDefault()
let files = e.dataTransfer.files
this.uploadFiles(files)
this.showDropzone = false
})
},
methods: {
getHumanSize,
formatDate,
formatDateSince,
formatDateLong,
downloadAttachment(attachment: IAttachment) {
this.attachmentService.download(attachment)
},
uploadNewAttachment() {
if (this.$refs.files.files.length === 0) {
return
}
this.uploadFiles(this.$refs.files.files)
},
uploadFiles(files: IFile[]) {
uploadFiles(this.attachmentService, this.taskId, files)
},
async deleteAttachment() {
try {
const r = await this.attachmentService.delete(this.attachmentToDelete)
this.$store.commit(
'attachments/removeById',
this.attachmentToDelete.id,
)
this.$message.success(r)
} finally{
this.showDeleteModal = false
}
},
async viewOrDownload(attachment) {
if (
attachment.file.name.endsWith('.jpg') ||
attachment.file.name.endsWith('.png') ||
attachment.file.name.endsWith('.bmp') ||
attachment.file.name.endsWith('.gif')
) {
this.showImageModal = true
this.attachmentImageBlobUrl = await this.attachmentService.getBlobUrl(attachment)
} else {
this.downloadAttachment(attachment)
}
},
}, },
}) })
const attachmentService = shallowReactive(new AttachmentService())
const store = useStore()
const attachments = computed(() => store.state.attachments.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)
store.commit(
'attachments/removeById',
attachmentToDelete.value.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: AttachmentModel) {
copy(generateAttachmentUrl(props.taskId, attachment.id))
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.attachments { .attachments {
input[type=file] { input[type=file] {
display: none; display: none;
} }
.files { @media screen and (max-width: $tablet) {
margin-bottom: 1rem; .button {
width: 100%;
.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;
}
}
}
}
}
@media screen and (max-width: $tablet) {
.button {
width: 100%;
}
}
.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 { .files {
margin: .5rem auto 2rem; margin-bottom: 1rem;
border-radius: 2px; }
box-shadow: var(--shadow-md);
background: var(--primary); .attachment {
padding: 1rem; margin-bottom: .5rem;
color: var(--white); display: block;
width: 100%; transition: background-color $transition;
max-width: 300px; 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 { .attachment-info-meta {
@ -410,29 +371,29 @@ export default defineComponent({
} }
@keyframes bounce { @keyframes bounce {
from, from,
20%, 20%,
53%, 53%,
80%, 80%,
to { to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
} }
40%, 40%,
43% { 43% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -30px, 0); transform: translate3d(0, -30px, 0);
} }
70% { 70% {
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
transform: translate3d(0, -15px, 0); transform: translate3d(0, -15px, 0);
} }
90% { 90% {
transform: translate3d(0, -4px, 0); transform: translate3d(0, -4px, 0);
} }
} }
@include modal-transition(); @include modal-transition();

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