feat(03-05): 3-step presigned upload + quota state in auth store + progress UI

- api/client.js: extend request() to attach .status and .payload on 413 structured errors; remove legacy uploadDocument multipart function
- stores/auth.js: add quota ref({used_bytes:0, limit_bytes:0}) and fetchQuota() action (silent catch); expose in store return
- stores/documents.js: replace single upload() with uploadToMinIO XHR helper + 3-step async action (getUploadUrl→XHR PUT→confirmUpload); track uploadProgress map keyed by filename+timestamp (T-03-25); call fetchQuota after upload success and document delete
- components/upload/UploadProgress.vue: add aria progressbar per row, percentage label, quota rejection error block (role=alert, red-50/red-200) from item.quotaError; use plain anchor for Manage storage link
This commit is contained in:
curo1305
2026-05-23 20:46:24 +02:00
parent 6bd57629ce
commit eb18428d07
4 changed files with 172 additions and 18 deletions
+14 -9
View File
@@ -34,21 +34,26 @@ async function request(path, options = {}) {
if (!res.ok) { if (!res.ok) {
let msg = `HTTP ${res.status}` let msg = `HTTP ${res.status}`
try { msg = (await res.json()).detail || msg } catch {} let payload = null
throw new Error(msg) try {
const body = await res.json()
if (typeof body.detail === 'object' && body.detail !== null) {
payload = body.detail
msg = body.detail.message || `HTTP ${res.status}`
} else {
msg = body.detail || msg
}
} catch {}
const err = new Error(msg)
err.status = res.status
if (payload) err.payload = payload
throw err
} }
return res.json() return res.json()
} }
// ── Documents ──────────────────────────────────────────────────────────────── // ── Documents ────────────────────────────────────────────────────────────────
export function uploadDocument(file, autoClassify = true) {
const form = new FormData()
form.append('file', file)
form.append('auto_classify', autoClassify ? 'true' : 'false')
return request('/api/documents/upload', { method: 'POST', body: form })
}
export function listDocuments({ topic, page = 1, perPage = 20 } = {}) { export function listDocuments({ topic, page = 1, perPage = 20 } = {}) {
const params = new URLSearchParams({ page, per_page: perPage }) const params = new URLSearchParams({ page, per_page: perPage })
if (topic) params.set('topic', topic) if (topic) params.set('topic', topic)
@@ -7,14 +7,64 @@
> >
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ item.name }}</p> <p class="text-sm font-medium text-gray-800 truncate">{{ item.name }}</p>
<p v-if="item.error" class="text-xs text-red-500 mt-0.5">{{ item.error }}</p>
<p v-else-if="item.done" class="text-xs text-green-600 mt-0.5"> <!-- Error state (non-quota) -->
<p v-if="item.error" class="text-sm text-red-500 mt-0.5">{{ item.error }}</p>
<!-- Done state -->
<p v-else-if="item.done" class="text-sm text-green-600 mt-0.5">
Done{{ item.topics?.length ? ` classified as: ${item.topics.join(', ')}` : ' no topics assigned' }} Done{{ item.topics?.length ? ` classified as: ${item.topics.join(', ')}` : ' no topics assigned' }}
</p> </p>
<p v-else class="text-xs text-gray-400 mt-0.5">Uploading</p>
<!-- Quota rejection error block (413 response T-03-23, UI-SPEC) -->
<div
v-else-if="item.quotaError"
role="alert"
class="mt-1 p-3 rounded-lg bg-red-50 border border-red-200"
>
<p class="text-sm font-semibold text-red-700">Not enough storage</p>
<p class="text-sm text-red-600 mt-1">
This file ({{ (item.quotaError.rejected_bytes / 1048576).toFixed(1) }} MB) would exceed your quota.
</p>
<p class="text-sm text-red-600">
You're using {{ (item.quotaError.used_bytes / 1048576).toFixed(1) }} MB of {{ (item.quotaError.limit_bytes / 1048576).toFixed(1) }} MB.
</p>
<!-- Plain anchor to avoid router-link import dependency in upload component -->
<a href="/settings" class="text-sm text-red-600 underline hover:text-red-700 font-semibold">
Manage storage →
</a>
</div> </div>
<!-- In-progress: progress bar + percentage + status -->
<template v-else>
<!-- Progress bar track + fill (UI-SPEC Upload Progress Bar Contract) -->
<div
v-if="item.progress !== undefined"
class="w-full h-2 bg-gray-100 rounded-full mt-1 overflow-hidden"
>
<div
role="progressbar"
:aria-valuenow="item.progress"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="`Upload progress for ${item.name}`"
class="h-full rounded-full transition-all duration-300 bg-indigo-500"
:style="{ width: `${item.progress}%` }"
></div>
</div>
<!-- Percentage label (shown during upload, hidden after completion) -->
<p
v-if="item.progress !== undefined"
class="text-sm text-gray-400 mt-1 text-right"
>{{ item.progress }}%</p>
<!-- Step status string (UI-SPEC Copywriting Contract) -->
<p class="text-sm text-gray-400 mt-0.5">{{ item.status || 'Uploading' }}</p>
</template>
</div>
<!-- Icon slot: error / done / spinner -->
<div class="shrink-0"> <div class="shrink-0">
<svg v-if="item.error" class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"> <svg v-if="item.error || item.quotaError" class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg> </svg>
<svg v-else-if="item.done" class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20"> <svg v-else-if="item.done" class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
+16
View File
@@ -22,6 +22,7 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref(null) const user = ref(null)
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const quota = ref({ used_bytes: 0, limit_bytes: 0 })
/** /**
* Register a new account. * Register a new account.
@@ -122,15 +123,30 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
/**
* Fetch current user quota from the server and update quota ref.
* Silently ignores errors — QuotaBar handles error display by hiding itself (UI-SPEC).
*/
async function fetchQuota() {
try {
const data = await api.getMyQuota()
quota.value = { used_bytes: data.used_bytes, limit_bytes: data.limit_bytes }
} catch {
// Silently ignore — QuotaBar hides itself on fetch error (UI-SPEC)
}
}
return { return {
accessToken, accessToken,
user, user,
loading, loading,
error, error,
quota,
register, register,
login, login,
logout, logout,
logoutAll, logoutAll,
refresh, refresh,
fetchQuota,
} }
}) })
+87 -4
View File
@@ -1,12 +1,39 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import * as api from '../api/client.js' import * as api from '../api/client.js'
import { useAuthStore } from './auth.js'
/**
* uploadToMinIO — XHR PUT helper for presigned MinIO uploads.
*
* Security: NO Authorization header is set — the presigned URL is self-authenticating (T-03-22).
* Progress events are mapped to onProgress(0100) for the XHR PUT phase.
*/
function uploadToMinIO(url, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100))
})
xhr.addEventListener('load', () => {
if (xhr.status < 400) resolve()
else reject(new Error(`PUT failed: ${xhr.status}`))
})
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
// NOTE: no Authorization header — presigned URL is self-authenticating (T-03-22, CLAUDE.md)
xhr.send(file)
})
}
export const useDocumentsStore = defineStore('documents', () => { export const useDocumentsStore = defineStore('documents', () => {
const documents = ref([]) const documents = ref([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
// uploadProgress: keyed by "${file.name}__${Date.now()}" to prevent collision (T-03-25)
const uploadProgress = ref({})
async function fetchDocuments({ topic, page = 1, perPage = 20 } = {}) { async function fetchDocuments({ topic, page = 1, perPage = 20 } = {}) {
loading.value = true loading.value = true
@@ -22,17 +49,73 @@ export const useDocumentsStore = defineStore('documents', () => {
} }
} }
/**
* Three-step upload:
* Step 1: POST /api/documents/upload-url → {upload_url, document_id} (0% → 5%)
* Step 2: XHR PUT bytes directly to MinIO presigned URL (5% → 90%)
* Step 3: POST /api/documents/{id}/confirm → {id, size_bytes, used_bytes, status} (92% → 100%)
*
* On 413 quota exceeded: err.payload = {used_bytes, limit_bytes, rejected_bytes} from confirm step.
* On success: authStore.fetchQuota() is called to refresh sidebar quota bar (STORE-04).
* Returns { rowKey, doc } on success so callers can clear the progress entry.
*/
async function upload(file, autoClassify = true) { async function upload(file, autoClassify = true) {
const doc = await api.uploadDocument(file, autoClassify) const authStore = useAuthStore()
documents.value.unshift(doc) // Composite key prevents collision when same filename uploaded twice (T-03-25)
const rowKey = `${file.name}__${Date.now()}`
uploadProgress.value[rowKey] = 0
try {
// Step 1: get presigned PUT URL (UI-SPEC: 0% → 5%, status "Preparing upload…")
const { upload_url, document_id } = await api.getUploadUrl(
file.name,
file.type || 'application/octet-stream',
)
uploadProgress.value[rowKey] = 5
// Step 2: XHR PUT to MinIO (UI-SPEC: 5% → 90%, status "Uploading…")
await uploadToMinIO(upload_url, file, (pct) => {
// Map XHR progress 0100 into visual range 590
uploadProgress.value[rowKey] = 5 + Math.round(pct * 0.85)
})
uploadProgress.value[rowKey] = 92
// Step 3: confirm (UI-SPEC: 92% → 100%, status "Processing…" → "Done — classifying…")
const doc = await api.confirmUpload(document_id)
uploadProgress.value[rowKey] = 100
documents.value.unshift({
id: doc.id,
original_name: file.name,
filename: file.name,
mime_type: file.type,
size_bytes: doc.size_bytes,
topics: [],
created_at: new Date().toISOString(),
classified_at: null,
})
total.value++ total.value++
return doc
// Refresh quota after successful upload (STORE-04, UI-SPEC Data Source)
await authStore.fetchQuota()
return { rowKey, doc }
} catch (e) {
error.value = e.message
// Propagate rowKey so the DropZone/parent can target the correct progress row
throw Object.assign(e, { rowKey })
}
// NOTE: uploadProgress[rowKey] is intentionally left in place after throw/return —
// the parent component reads it to render the bar or quota error block; the parent
// is responsible for clearing the entry when the row is dismissed.
} }
async function remove(id) { async function remove(id) {
const authStore = useAuthStore()
await api.deleteDocument(id) await api.deleteDocument(id)
documents.value = documents.value.filter(d => d.id !== id) documents.value = documents.value.filter(d => d.id !== id)
total.value-- total.value--
// Refresh quota after document delete (STORE-06, UI-SPEC Data Source)
await authStore.fetchQuota()
} }
async function reclassify(id, topics = null) { async function reclassify(id, topics = null) {
@@ -42,5 +125,5 @@ export const useDocumentsStore = defineStore('documents', () => {
return result.topics return result.topics
} }
return { documents, total, loading, error, fetchDocuments, upload, remove, reclassify } return { documents, total, loading, error, uploadProgress, fetchDocuments, upload, remove, reclassify }
}) })