diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 8f771a5..77201f9 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -34,21 +34,26 @@ async function request(path, options = {}) { if (!res.ok) { let msg = `HTTP ${res.status}` - try { msg = (await res.json()).detail || msg } catch {} - throw new Error(msg) + let payload = null + 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() } // ── 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 } = {}) { const params = new URLSearchParams({ page, per_page: perPage }) if (topic) params.set('topic', topic) diff --git a/frontend/src/components/upload/UploadProgress.vue b/frontend/src/components/upload/UploadProgress.vue index 908cf70..0847af8 100644 --- a/frontend/src/components/upload/UploadProgress.vue +++ b/frontend/src/components/upload/UploadProgress.vue @@ -7,14 +7,64 @@ >

{{ item.name }}

-

{{ item.error }}

-

+ + +

{{ item.error }}

+ + +

Done{{ item.topics?.length ? ` — classified as: ${item.topics.join(', ')}` : ' — no topics assigned' }}

-

Uploading…

+ + + + + +
+ +
- + diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index d01133c..adc1f09 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -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, } }) diff --git a/frontend/src/stores/documents.js b/frontend/src/stores/documents.js index 6220e80..a60272d 100644 --- a/frontend/src/stores/documents.js +++ b/frontend/src/stores/documents.js @@ -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(0–100) 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 0–100 into visual range 5–90 + 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 } })