| 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> |
| ) |
| } |
|
|