production-rag / ui /src /components /UploadPanel.jsx
vgsampathkumar's picture
fix: background upload to avoid HF proxy timeout on large OCR PDFs
d2eaf3e
Raw
History Blame Contribute Delete
16.3 kB
import { useState, useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@clerk/clerk-react'
import { API_BASE } from '../config.js'
function DocumentRow({ doc, index, onDelete }) {
const [deleting, setDeleting] = useState(false)
const handleDelete = async (e) => {
e.stopPropagation()
if (!confirm(`Delete "${doc.name}" and all its indexed chunks?`)) return
setDeleting(true)
await onDelete(doc.name)
setDeleting(false)
}
return (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.04 }}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-white/5 transition-colors group"
>
<div className="flex items-center gap-2 min-w-0">
<svg className="flex-shrink-0 text-accent/60" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span className="text-xs text-body/80 truncate font-mono">{doc.name}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
<span className="text-xs text-muted bg-surface-2 px-2 py-0.5 rounded-full">
{doc.chunks} chunk{doc.chunks !== 1 ? 's' : ''}
</span>
<motion.button
onClick={handleDelete}
disabled={deleting}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="opacity-40 hover:opacity-100 transition-opacity text-muted hover:text-red-400 disabled:opacity-20"
title="Delete document"
>
{deleting ? (
<motion.svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
animate={{ rotate: 360 }} transition={{ duration: 0.8, repeat: Infinity, ease: 'linear' }}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</motion.svg>
) : (
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
)}
</motion.button>
</div>
</motion.div>
)
}
function UploadResultBadge({ result }) {
const styles = {
ok: 'bg-emerald-500/15 border-emerald-500/30 text-emerald-400',
queued: 'bg-accent/10 border-accent/25 text-accent/80',
warning: 'bg-yellow-500/15 border-yellow-500/30 text-yellow-400',
error: 'bg-red-500/15 border-red-500/30 text-red-400',
skipped: 'bg-white/5 border-white/10 text-muted',
}
const icons = {
ok: <path d="M20 6 9 17l-5-5" />,
queued: <path d="M21 12a9 9 0 1 1-6.219-8.56" />,
warning: <><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></>,
error: <><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></>,
skipped: <><circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></>,
}
const style = styles[result.status] || styles.skipped
return (
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className={`flex items-start gap-2 p-2.5 rounded-lg border text-xs ${style}`}>
<svg className="flex-shrink-0 mt-0.5" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">{icons[result.status]}</svg>
<div className="min-w-0">
<span className="font-mono truncate block">{result.name}</span>
{result.status === 'ok' && <span className="opacity-80">{result.chunks} chunk{result.chunks !== 1 ? 's' : ''} from {result.pages} page{result.pages !== 1 ? 's' : ''}</span>}
{result.message && <span className="opacity-80">{result.message}</span>}
</div>
</motion.div>
)
}
export default function UploadPanel({ onUploaded }) {
const { getToken } = useAuth()
const [isOpen, setIsOpen] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
const [uploadStatus, setUploadStatus] = useState('idle')
const [uploadResults, setUploadResults] = useState([])
const [documents, setDocuments] = useState([])
const [totalChunks, setTotalChunks] = useState(0)
const [loadingDocs, setLoadingDocs] = useState(false)
const fileInputRef = useRef(null)
const authHeaders = useCallback(async () => {
const token = await getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}, [getToken])
const fetchDocuments = useCallback(async () => {
setLoadingDocs(true)
try {
const res = await fetch(`${API_BASE}/documents`, { headers: await authHeaders() })
const data = await res.json()
setDocuments(data.documents || [])
setTotalChunks(data.total_chunks || 0)
} catch {
// backend not running — silently ignore
} finally {
setLoadingDocs(false)
}
}, [authHeaders])
useEffect(() => {
if (isOpen) fetchDocuments()
}, [isOpen, fetchDocuments])
const addFiles = useCallback((fileList) => {
const pdfs = Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.pdf'))
if (!pdfs.length) return
setPendingFiles(prev => {
const existing = new Set(prev.map(f => f.name))
return [...prev, ...pdfs.filter(f => !existing.has(f.name))]
})
}, [])
const handleDragOver = (e) => { e.preventDefault(); setIsDragOver(true) }
const handleDragLeave = (e) => { if (!e.currentTarget.contains(e.relatedTarget)) setIsDragOver(false) }
const handleDrop = (e) => { e.preventDefault(); setIsDragOver(false); addFiles(e.dataTransfer.files) }
const handleFileInput = (e) => { addFiles(e.target.files); e.target.value = '' }
const removeFile = (name) => setPendingFiles(prev => prev.filter(f => f.name !== name))
const handleUpload = async () => {
if (!pendingFiles.length || uploadStatus === 'uploading') return
setUploadStatus('uploading')
setUploadResults([])
const formData = new FormData()
pendingFiles.forEach(f => formData.append('files', f))
try {
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: await authHeaders(),
body: formData,
})
if (!res.ok) {
let msg = `Server error ${res.status}`
try { const e = await res.json(); if (e.detail) msg = `${res.status}: ${e.detail}` } catch {}
throw new Error(msg)
}
const data = await res.json()
const results = data.results || []
setUploadResults(results)
setTotalChunks(data.total_chunks || totalChunks)
setPendingFiles([])
setUploadStatus('done')
fetchDocuments()
onUploaded?.()
// If any files are queued for background indexing, poll until they appear
if (results.some(r => r.status === 'queued')) {
let attempts = 0
const poll = setInterval(async () => {
attempts++
await fetchDocuments()
if (attempts >= 12) clearInterval(poll) // stop after 2 min
}, 10000) // every 10 s
}
} catch (err) {
setUploadResults([{ name: 'Upload failed', status: 'error', message: err.message }])
setUploadStatus('error')
}
}
const handleDelete = async (name) => {
try {
const res = await fetch(`${API_BASE}/document/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: await authHeaders(),
})
if (!res.ok) throw new Error(`Server error ${res.status}`)
await fetchDocuments()
onUploaded?.()
} catch (err) {
alert(`Delete failed: ${err.message}`)
}
}
const successCount = uploadResults.filter(r => r.status === 'ok').length
return (
<div className="w-full">
<button onClick={() => setIsOpen(v => !v)} className="w-full flex items-center justify-between px-4 py-2.5 bg-surface rounded-xl border border-white/5 hover:border-accent/30 transition-colors group">
<div className="flex items-center gap-2.5">
<svg className="text-accent/70" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span className="text-sm font-medium text-body/80 group-hover:text-body transition-colors">Manage Documents</span>
{totalChunks > 0 && <span className="text-xs text-muted bg-accent/10 border border-accent/20 px-2 py-0.5 rounded-full">{totalChunks} chunks</span>}
</div>
<motion.svg animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.2 }} className="text-muted" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<polyline points="6 9 12 15 18 9" />
</motion.svg>
</button>
<AnimatePresence>
{isOpen && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.25, ease: 'easeInOut' }} className="overflow-hidden">
<div className="pt-3 flex flex-col gap-4">
<motion.div
animate={{ boxShadow: isDragOver ? '0 0 0 2px #6366f1, 0 0 30px rgba(99,102,241,0.3)' : '0 0 0 1px rgba(255,255,255,0.07)', backgroundColor: isDragOver ? 'rgba(99,102,241,0.08)' : 'transparent' }}
transition={{ duration: 0.15 }}
onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className="relative rounded-xl p-6 flex flex-col items-center gap-3 cursor-pointer select-none"
style={{ border: '1.5px dashed rgba(99,102,241,0.3)' }}
>
<input ref={fileInputRef} type="file" accept=".pdf" multiple onChange={handleFileInput} className="hidden" />
<motion.div animate={isDragOver ? { scale: 1.15, rotate: -5 } : { scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 300 }} className="w-10 h-10 rounded-xl bg-accent/10 border border-accent/30 flex items-center justify-center">
<svg width="18" height="18" fill="none" stroke="#6366f1" strokeWidth="2" viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
</motion.div>
<div className="text-center">
<p className="text-sm text-body/70">{isDragOver ? 'Drop PDFs here' : 'Drag & drop PDFs here'}</p>
<p className="text-xs text-muted mt-0.5">or <span className="text-accent/80 underline underline-offset-2">click to browse</span> · PDF only</p>
</div>
</motion.div>
<AnimatePresence mode="popLayout">
{pendingFiles.length > 0 && (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="flex flex-col gap-2">
<p className="text-xs text-muted uppercase tracking-wider px-1">Ready to upload ({pendingFiles.length})</p>
{pendingFiles.map(file => (
<motion.div key={file.name} layout initial={{ opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 8 }} className="flex items-center justify-between px-3 py-2 bg-surface rounded-lg border border-white/5">
<div className="flex items-center gap-2 min-w-0">
<svg className="flex-shrink-0 text-accent/60" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /></svg>
<span className="text-xs text-body/80 font-mono truncate">{file.name}</span>
<span className="flex-shrink-0 text-xs text-muted">{(file.size / 1024).toFixed(0)} KB</span>
</div>
<button onClick={(e) => { e.stopPropagation(); removeFile(file.name) }} className="flex-shrink-0 ml-2 text-muted hover:text-red-400 transition-colors">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</motion.div>
))}
<motion.button onClick={handleUpload} disabled={uploadStatus === 'uploading'} whileHover={{ scale: 1.01 }} whileTap={{ scale: 0.98 }} className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-accent text-white text-sm font-semibold disabled:opacity-60 transition-opacity mt-1">
{uploadStatus === 'uploading' ? (<><motion.svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" animate={{ rotate: 360 }} transition={{ duration: 0.8, repeat: Infinity, ease: 'linear' }}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></motion.svg>Uploading & indexing…</>) : (<><svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>Upload {pendingFiles.length} file{pendingFiles.length !== 1 ? 's' : ''}</>)}
</motion.button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{uploadResults.length > 0 && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col gap-2">
{successCount > 0 && <p className="text-xs text-emerald-400 px-1">{successCount} file{successCount !== 1 ? 's' : ''} indexed successfully</p>}
{uploadResults.map((r, i) => <UploadResultBadge key={i} result={r} />)}
</motion.div>
)}
</AnimatePresence>
<div>
<div className="flex items-center justify-between mb-2 px-1">
<p className="text-xs text-muted uppercase tracking-wider">Indexed Library</p>
<button onClick={fetchDocuments} className="text-xs text-accent/60 hover:text-accent transition-colors flex items-center gap-1">
<motion.svg animate={loadingDocs ? { rotate: 360 } : { rotate: 0 }} transition={loadingDocs ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}} width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</motion.svg>
Refresh
</button>
</div>
{documents.length === 0 && !loadingDocs && <p className="text-xs text-muted text-center py-4">No documents indexed yet</p>}
<div className="flex flex-col divide-y divide-white/5">
{documents.map((doc, i) => <DocumentRow key={doc.name} doc={doc} index={i} onDelete={handleDelete} />)}
</div>
{documents.length > 0 && <p className="text-xs text-muted text-right mt-2 px-1">{documents.length} documents · {totalChunks} total chunks</p>}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}