This commit is contained in:
1 parent
5b2fbb46ef
commit
0d310c2732
5 files changed
+289
-2
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import { apiClient } from '@/lib/api-client.ts'
|
||||
|
||||
export interface StoredFile {
|
||||
id: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
/** null — the uploader deleted their account */
|
||||
uploaderId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface IssueAttachment {
|
||||
issueId: number
|
||||
file: StoredFile
|
||||
/** When the file was attached to the issue (may differ from file.createdAt) */
|
||||
attachedAt: string
|
||||
}
|
||||
|
||||
/** The server serves only these types inline, everything else as a download */
|
||||
const INLINE_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
|
||||
|
||||
export function isPreviewable(file: StoredFile) {
|
||||
return INLINE_IMAGE_TYPES.includes(file.mimeType)
|
||||
}
|
||||
|
||||
export function downloadUrl(fileId: string) {
|
||||
return `/api/files/${fileId}/download`
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
/** Uploads a file without an owner; it must be attached afterwards or GC removes it */
|
||||
export async function fileUpload(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return apiClient.upload<StoredFile>('/api/files', form)
|
||||
}
|
||||
|
||||
export async function attachmentsList(issueId: number) {
|
||||
return apiClient.get<IssueAttachment[]>(`/api/issues/${issueId}/attachments`)
|
||||
}
|
||||
|
||||
export async function attachmentCreate(issueId: number, fileId: string) {
|
||||
return apiClient.post<IssueAttachment>(`/api/issues/${issueId}/attachments`, { fileId })
|
||||
}
|
||||
|
||||
export async function attachmentDelete(issueId: number, fileId: string) {
|
||||
await apiClient.delete(`/api/issues/${issueId}/attachments/${fileId}`)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<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>
|
||||
@@ -13,6 +13,7 @@ import CommentsList from '@/components/CommentsList.vue'
|
||||
import HistoryList from '@/components/HistoryList.vue'
|
||||
import SplitHandle from '@/components/SplitHandle.vue'
|
||||
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
||||
import FileUpload from '@/components/FileUpload.vue'
|
||||
|
||||
interface Props {
|
||||
ckey: string
|
||||
@@ -132,6 +133,10 @@ defineExpose({
|
||||
<expansion-panel title="Details" class="details__content">
|
||||
<issue-content :text="issue.content" @changed="onContentChanged" />
|
||||
</expansion-panel>
|
||||
|
||||
<expansion-panel title="Attachments">
|
||||
<file-upload :issue-id="issue.id" />
|
||||
</expansion-panel>
|
||||
</div>
|
||||
<split-handle v-bind="handleProps" />
|
||||
<div class="d-flex flex-column">
|
||||
|
||||
@@ -5,15 +5,18 @@ interface ApiEnvelope<T> {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
// FormData needs the browser to set multipart Content-Type with its boundary
|
||||
const isForm = init?.body instanceof FormData
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||
headers: { ...(isForm ? {} : { 'Content-Type': 'application/json' }), ...(init?.headers ?? {}) },
|
||||
})
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const body = (await res.json()) as ApiEnvelope<T>
|
||||
// A proxy (e.g. nginx 413) may answer with a non-JSON body
|
||||
const body = (await res.json().catch(() => ({}))) as Partial<ApiEnvelope<T>>
|
||||
|
||||
if (!res.ok) {
|
||||
const message = Array.isArray(body.message) ? body.message.join(', ') : body.message
|
||||
@@ -36,5 +39,6 @@ export const apiClient = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
upload: <T>(path: string, form: FormData) => request<T>(path, { method: 'POST', body: form }),
|
||||
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
|
||||
}
|
||||
Reference in new issue
Block a user