MedRAG / frontend /src /components /chat /UploadModal.jsx
hetsheta's picture
Initial commit
ea50fb7
Raw
History Blame Contribute Delete
7.85 kB
import { useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Upload, FileText, CheckCircle, AlertCircle, Loader } from 'lucide-react'
import { docsApi } from '../../api/client'
import toast from 'react-hot-toast'
const ALLOWED_TYPES = [
'application/pdf',
'text/plain',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
export default function UploadModal({ onClose, conversationId }) {
const [files, setFiles] = useState([])
const [title, setTitle] = useState('')
const [source, setSource] = useState('')
const [status, setStatus] = useState('idle')
const [errorMsg, setErrorMsg] = useState('')
const fileRef = useRef()
const isSingleFile = files.length === 1
const selectFiles = (fileList) => {
const picked = Array.from(fileList || []).filter((file) => {
const ok = ALLOWED_TYPES.includes(file.type) || /\.(pdf|txt|docx)$/i.test(file.name)
if (!ok) toast.error(`Unsupported file skipped: ${file.name}`)
return ok
})
if (!picked.length) return
setFiles(picked)
if (picked.length === 1) {
setTitle((prev) => prev || picked[0].name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' '))
} else {
setTitle('')
}
}
const handleDrop = (e) => {
e.preventDefault()
selectFiles(e.dataTransfer.files)
}
const submit = async () => {
if (!conversationId) {
setErrorMsg('Create a conversation before uploading documents')
setStatus('error')
return
}
if (!files.length || (isSingleFile && !title.trim())) return
setStatus('uploading')
setErrorMsg('')
try {
const fd = new FormData()
files.forEach((file) => fd.append('files', file))
fd.append('conversation_id', conversationId)
if (isSingleFile) fd.append('title', title.trim())
if (source.trim()) fd.append('source', source.trim())
const result = await docsApi.upload(fd)
setStatus('success')
toast.success(result.message || 'Documents queued for ingestion')
setTimeout(onClose, 1800)
} catch (err) {
setErrorMsg(err.response?.data?.detail || 'Upload failed')
setStatus('error')
}
}
const totalSizeMb = (files.reduce((sum, file) => sum + file.size, 0) / 1024 / 1024).toFixed(2)
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 16 }}
transition={{ duration: 0.2 }}
className="bg-surface-2 border border-white/8 rounded-2xl w-full max-w-md p-6 shadow-2xl"
>
<div className="flex items-center justify-between mb-5">
<div>
<h2 className="text-white font-semibold">Upload Medical Documents</h2>
<p className="text-xs text-gray-500 mt-0.5">PDF, DOCX, or TXT. One or many files.</p>
</div>
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded-lg hover:bg-white/5">
<X size={18} />
</button>
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 mb-4 ${
files.length ? 'border-med-teal/40 bg-med-teal/5' : 'border-white/10 hover:border-accent/40 hover:bg-accent/5'
}`}
>
<input
ref={fileRef}
type="file"
multiple
accept=".pdf,.docx,.txt"
className="hidden"
onChange={(e) => selectFiles(e.target.files)}
/>
{files.length ? (
<div className="space-y-3">
<div className="flex items-center gap-3 justify-center">
<FileText className="text-med-teal" size={22} />
<div className="text-left">
<p className="text-white text-sm font-medium">
{isSingleFile ? files[0].name : `${files.length} files selected`}
</p>
<p className="text-gray-500 text-xs">{totalSizeMb} MB total</p>
</div>
</div>
{files.length > 1 && (
<div className="max-h-28 overflow-y-auto text-left text-xs text-gray-400 space-y-1">
{files.map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name}
</div>
))}
</div>
)}
</div>
) : (
<div>
<Upload size={24} className="mx-auto text-gray-500 mb-2" />
<p className="text-gray-400 text-sm">Drop files here or <span className="text-accent">browse</span></p>
</div>
)}
</div>
<div className="space-y-3 mb-5">
{isSingleFile && (
<div>
<label className="block text-xs text-gray-400 mb-1.5 font-medium">Document Title *</label>
<input
className="input-field"
placeholder="e.g. Harrison's Principles of Internal Medicine"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
)}
{files.length > 1 && (
<div className="text-xs text-gray-500">
Titles will be generated automatically from the filenames.
</div>
)}
<div>
<label className="block text-xs text-gray-400 mb-1.5 font-medium">Source / Reference <span className="text-gray-600">(optional)</span></label>
<input
className="input-field"
placeholder="e.g. PubMed, WHO Guidelines 2024"
value={source}
onChange={(e) => setSource(e.target.value)}
/>
</div>
</div>
{status === 'error' && (
<div className="flex items-start gap-2 bg-red-500/10 border border-red-500/20 rounded-lg p-3 mb-4 text-xs text-red-400">
<AlertCircle size={14} className="shrink-0 mt-0.5" />
{errorMsg}
</div>
)}
{status === 'success' && (
<div className="flex items-center gap-2 bg-med-teal/10 border border-med-teal/20 rounded-lg p-3 mb-4 text-xs text-med-teal">
<CheckCircle size={14} />
Upload accepted and queued for background ingestion. Closing...
</div>
)}
<div className="flex gap-3">
<button onClick={onClose} className="btn-ghost flex-1 text-sm">Cancel</button>
<button
onClick={submit}
disabled={!files.length || (isSingleFile && !title.trim()) || status === 'uploading' || status === 'success'}
className="btn-primary flex-1 text-sm flex items-center justify-center gap-2"
>
{status === 'uploading'
? <><Loader size={14} className="animate-spin" /> Queueing...</>
: <><Upload size={14} /> Upload</>
}
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}