add file upload for issue
Gitea Actions Demo / Deploy (push) Successful in 36s

This commit is contained in:
vadim committed 2026-10-08 18:38:25 +03:00
1 parent 5b2fbb46ef
commit 0d310c2732
5 files changed
+289 -2

No files matched your search

+51
View File
@@ -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
}
+225
View File
@@ -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>
+5
View File
@@ -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">
+6 -2
View File
@@ -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' }),
}