226 lines
6.7 KiB
Vue
226 lines
6.7 KiB
Vue
<script setup lang="ts">
|
|
import { ref, useTemplateRef, watch } from 'vue'
|
|
import {
|
|
attachmentCreate,
|
|
attachmentDelete,
|
|
attachmentsList,
|
|
downloadUrl,
|
|
fileUpload,
|
|
type IssueAttachment,
|
|
isPreviewable,
|
|
} from '@/api/file.api.ts'
|
|
|
|
interface Props {
|
|
issueId: number
|
|
}
|
|
|
|
interface PendingUpload {
|
|
key: number
|
|
name: string
|
|
error: string | null
|
|
}
|
|
|
|
const { issueId } = defineProps<Props>()
|
|
|
|
const attachments = ref<IssueAttachment[]>([])
|
|
const pending = ref<PendingUpload[]>([])
|
|
const loading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const dragOver = ref(false)
|
|
const input = useTemplateRef<HTMLInputElement>('input')
|
|
|
|
let pendingKey = 0
|
|
|
|
const sizeFormat = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 1 })
|
|
|
|
function formatSize(bytes: number) {
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${sizeFormat.format(bytes / 1024)} KB`
|
|
return `${sizeFormat.format(bytes / 1024 / 1024)} MB`
|
|
}
|
|
|
|
function fileIcon(mimeType: string) {
|
|
if (mimeType.startsWith('image/')) return 'mdi-file-image-outline'
|
|
if (mimeType === 'application/pdf') return 'mdi-file-pdf-box'
|
|
if (mimeType.startsWith('video/')) return 'mdi-file-video-outline'
|
|
if (mimeType.startsWith('audio/')) return 'mdi-file-music-outline'
|
|
if (mimeType.includes('zip') || mimeType.includes('compressed')) return 'mdi-folder-zip-outline'
|
|
return 'mdi-file-outline'
|
|
}
|
|
|
|
function errorMessage(e: unknown, fallback: string) {
|
|
return e instanceof Error ? e.message : fallback
|
|
}
|
|
|
|
watch(
|
|
() => issueId,
|
|
async (id) => {
|
|
attachments.value = []
|
|
pending.value = []
|
|
error.value = null
|
|
loading.value = true
|
|
try {
|
|
const list = await attachmentsList(id)
|
|
// The issue may have changed while the request was in flight
|
|
if (issueId === id) attachments.value = list
|
|
} catch (e) {
|
|
if (issueId === id) error.value = errorMessage(e, 'error get list of attachments')
|
|
} finally {
|
|
if (issueId === id) loading.value = false
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
async function uploadOne(file: File) {
|
|
const id = issueId
|
|
const item: PendingUpload = { key: ++pendingKey, name: file.name, error: null }
|
|
pending.value.push(item)
|
|
try {
|
|
// Two steps: upload the file, then attach it to the issue by id
|
|
const stored = await fileUpload(file)
|
|
const attachment = await attachmentCreate(id, stored.id)
|
|
if (issueId !== id) return
|
|
// Re-attaching is idempotent on the server, so replace instead of duplicating
|
|
attachments.value = [...attachments.value.filter((a) => a.file.id !== attachment.file.id), attachment]
|
|
pending.value = pending.value.filter((p) => p.key !== item.key)
|
|
} catch (e) {
|
|
const target = pending.value.find((p) => p.key === item.key)
|
|
if (target) target.error = errorMessage(e, 'upload failed')
|
|
}
|
|
}
|
|
|
|
async function upload(files: FileList | File[] | null | undefined) {
|
|
if (!files?.length) return
|
|
await Promise.all(Array.from(files).map(uploadOne))
|
|
}
|
|
|
|
function openPicker() {
|
|
input.value?.click()
|
|
}
|
|
|
|
async function onInputChange(event: Event) {
|
|
const target = event.target as HTMLInputElement
|
|
await upload(target.files)
|
|
// Allow picking the same file again
|
|
target.value = ''
|
|
}
|
|
|
|
async function onDrop(event: DragEvent) {
|
|
dragOver.value = false
|
|
await upload(event.dataTransfer?.files)
|
|
}
|
|
|
|
function dismiss(key: number) {
|
|
pending.value = pending.value.filter((p) => p.key !== key)
|
|
}
|
|
|
|
async function remove(attachment: IssueAttachment) {
|
|
try {
|
|
await attachmentDelete(attachment.issueId, attachment.file.id)
|
|
attachments.value = attachments.value.filter((a) => a.file.id !== attachment.file.id)
|
|
} catch (e) {
|
|
error.value = errorMessage(e, 'error delete attachment')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="d-flex flex-column ga-2">
|
|
<div
|
|
class="dropzone d-flex align-center justify-center ga-2 pa-3 text-medium-emphasis cursor-pointer"
|
|
:class="{ 'dropzone--active': dragOver }"
|
|
@click="openPicker"
|
|
@dragenter.prevent="dragOver = true"
|
|
@dragover.prevent="dragOver = true"
|
|
@dragleave.prevent="dragOver = false"
|
|
@drop.prevent="onDrop"
|
|
>
|
|
<v-icon icon="mdi-paperclip" />
|
|
<span>Drop files here or click to upload</span>
|
|
</div>
|
|
<input ref="input" type="file" multiple hidden @change="onInputChange" />
|
|
|
|
<v-alert v-if="error" type="error" density="compact" variant="tonal" closable @click:close="error = null">
|
|
{{ error }}
|
|
</v-alert>
|
|
|
|
<v-progress-linear v-if="loading" indeterminate />
|
|
|
|
<v-list v-if="attachments.length || pending.length" density="compact" class="pa-0">
|
|
<v-list-item
|
|
v-for="attachment in attachments"
|
|
:key="attachment.file.id"
|
|
:href="downloadUrl(attachment.file.id)"
|
|
target="_blank"
|
|
rounded
|
|
>
|
|
<template #prepend>
|
|
<v-avatar v-if="isPreviewable(attachment.file)" rounded="sm" size="32" class="mr-3">
|
|
<v-img :src="downloadUrl(attachment.file.id)" cover />
|
|
</v-avatar>
|
|
<v-icon v-else :icon="fileIcon(attachment.file.mimeType)" />
|
|
</template>
|
|
|
|
<v-list-item-title>{{ attachment.file.fileName }}</v-list-item-title>
|
|
<v-list-item-subtitle>{{ formatSize(attachment.file.size) }}</v-list-item-subtitle>
|
|
|
|
<template #append>
|
|
<v-btn
|
|
icon="mdi-close"
|
|
size="small"
|
|
variant="text"
|
|
density="comfortable"
|
|
title="Remove"
|
|
@click.prevent.stop="remove(attachment)"
|
|
/>
|
|
</template>
|
|
</v-list-item>
|
|
|
|
<v-list-item v-for="item in pending" :key="item.key" rounded>
|
|
<template #prepend>
|
|
<v-icon v-if="item.error" icon="mdi-alert-circle-outline" color="error" />
|
|
<v-progress-circular v-else indeterminate size="20" width="2" class="mr-8" />
|
|
</template>
|
|
|
|
<v-list-item-title>{{ item.name }}</v-list-item-title>
|
|
<v-list-item-subtitle :class="{ 'text-error': item.error }">
|
|
{{ item.error ?? 'Uploading…' }}
|
|
</v-list-item-subtitle>
|
|
|
|
<template v-if="item.error" #append>
|
|
<v-btn
|
|
icon="mdi-close"
|
|
size="small"
|
|
variant="text"
|
|
density="comfortable"
|
|
title="Dismiss"
|
|
@click="dismiss(item.key)"
|
|
/>
|
|
</template>
|
|
</v-list-item>
|
|
</v-list>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.dropzone {
|
|
border: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
|
|
border-radius: 4px;
|
|
transition:
|
|
border-color 0.15s,
|
|
background-color 0.15s;
|
|
|
|
&:hover,
|
|
&--active {
|
|
border-color: rgb(var(--v-theme-primary));
|
|
background-color: rgba(var(--v-theme-primary), 0.04);
|
|
}
|
|
|
|
// Otherwise dragleave fires when the cursor moves over the icon or the text
|
|
> * {
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
</style>
|