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:
@@ -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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</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">
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(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', () => {
|
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)
|
||||||
total.value++
|
const rowKey = `${file.name}__${Date.now()}`
|
||||||
return doc
|
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) {
|
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 }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user