This commit is contained in:
1 parent
5b2fbb46ef
commit
0d310c2732
5 files changed
+289
-2
No files matched your search
@@ -6,6 +6,8 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
# Matches the backend FILE_MAX_SIZE_MB (default 1m would reject uploads)
|
||||||
|
client_max_body_size 25m;
|
||||||
proxy_pass http://${BACKEND_UPSTREAM}/api/;
|
proxy_pass http://${BACKEND_UPSTREAM}/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -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 HistoryList from '@/components/HistoryList.vue'
|
||||||
import SplitHandle from '@/components/SplitHandle.vue'
|
import SplitHandle from '@/components/SplitHandle.vue'
|
||||||
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
||||||
|
import FileUpload from '@/components/FileUpload.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ckey: string
|
ckey: string
|
||||||
@@ -132,6 +133,10 @@ defineExpose({
|
|||||||
<expansion-panel title="Details" class="details__content">
|
<expansion-panel title="Details" class="details__content">
|
||||||
<issue-content :text="issue.content" @changed="onContentChanged" />
|
<issue-content :text="issue.content" @changed="onContentChanged" />
|
||||||
</expansion-panel>
|
</expansion-panel>
|
||||||
|
|
||||||
|
<expansion-panel title="Attachments">
|
||||||
|
<file-upload :issue-id="issue.id" />
|
||||||
|
</expansion-panel>
|
||||||
</div>
|
</div>
|
||||||
<split-handle v-bind="handleProps" />
|
<split-handle v-bind="handleProps" />
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ interface ApiEnvelope<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<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, {
|
const res = await fetch(path, {
|
||||||
...init,
|
...init,
|
||||||
credentials: 'same-origin',
|
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
|
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) {
|
if (!res.ok) {
|
||||||
const message = Array.isArray(body.message) ? body.message.join(', ') : body.message
|
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),
|
get: <T>(path: string) => request<T>(path),
|
||||||
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
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) }),
|
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' }),
|
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user