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
+16
View File
@@ -22,6 +22,7 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const loading = ref(false)
const error = ref(null)
const quota = ref({ used_bytes: 0, limit_bytes: 0 })
/**
* 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 {
accessToken,
user,
loading,
error,
quota,
register,
login,
logout,
logoutAll,
refresh,
fetchQuota,
}
})
+88 -5
View File
@@ -1,12 +1,39 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
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', () => {
const documents = ref([])
const total = ref(0)
const loading = ref(false)
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 } = {}) {
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) {
const doc = await api.uploadDocument(file, autoClassify)
documents.value.unshift(doc)
total.value++
return doc
const authStore = useAuthStore()
// 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++
// 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) {
const authStore = useAuthStore()
await api.deleteDocument(id)
documents.value = documents.value.filter(d => d.id !== id)
total.value--
// Refresh quota after document delete (STORE-06, UI-SPEC Data Source)
await authStore.fetchQuota()
}
async function reclassify(id, topics = null) {
@@ -42,5 +125,5 @@ export const useDocumentsStore = defineStore('documents', () => {
return result.topics
}
return { documents, total, loading, error, fetchDocuments, upload, remove, reclassify }
return { documents, total, loading, error, uploadProgress, fetchDocuments, upload, remove, reclassify }
})