Spaces:
Sleeping
Sleeping
| 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> | |
| ) | |
| } | |