feat: multi-file drop zone with zip support and upload status queue

This commit is contained in:
2026-04-26 00:55:33 +00:00
parent 145ac11023
commit ac60597e65

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import axios from 'axios' import axios from 'axios'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, Eye, RefreshCw, FileText, X } from 'lucide-react' import { Upload, Trash2, Eye, RefreshCw, FileText, FileArchive, X, CheckCircle, AlertCircle, Loader } from 'lucide-react'
const STAGES = ['input', 'parsed', 'generated', 'curated', 'final'] const STAGES = ['input', 'parsed', 'generated', 'curated', 'final']
@@ -11,13 +11,19 @@ function formatBytes(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
} }
function FileIcon({ name }) {
return name.endsWith('.zip')
? <FileArchive size={13} className="text-yellow-500 flex-shrink-0" />
: <FileText size={13} className="text-slate-500 flex-shrink-0" />
}
export default function DocumentManager({ connected }) { export default function DocumentManager({ connected }) {
const [stage, setStage] = useState('input') const [stage, setStage] = useState('input')
const [files, setFiles] = useState([]) const [files, setFiles] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false) const [uploadQueue, setUploadQueue] = useState([])
const [preview, setPreview] = useState(null) const [preview, setPreview] = useState(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const fetchFiles = useCallback(async () => { const fetchFiles = useCallback(async () => {
if (!connected) return if (!connected) return
@@ -36,23 +42,36 @@ export default function DocumentManager({ connected }) {
const onDrop = useCallback(async accepted => { const onDrop = useCallback(async accepted => {
if (!accepted.length || !connected) return if (!accepted.length || !connected) return
setUploading(true) const entries = accepted.map(f => ({ name: f.name, status: 'uploading', error: null }))
setUploadQueue(entries)
const formData = new FormData() const formData = new FormData()
formData.append('file', accepted[0]) accepted.forEach(f => formData.append('files', f))
try { try {
await axios.post('/api/upload', formData) const { data } = await axios.post('/api/upload', formData)
setUploadQueue(data.results.map(r => ({ name: r.file, status: 'done', action: r.action, error: null })))
fetchFiles() fetchFiles()
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || err.message) const msg = err.response?.data?.detail || err.message
} finally { setUploadQueue(entries.map(e => ({ ...e, status: 'error', error: msg })))
setUploading(false) setError(msg)
} }
setTimeout(() => setUploadQueue([]), 4000)
}, [connected, fetchFiles]) }, [connected, fetchFiles])
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
disabled: !connected || stage !== 'input', disabled: !connected || stage !== 'input',
multiple: false, multiple: true,
accept: {
'application/zip': ['.zip'],
'application/x-zip-compressed': ['.zip'],
'text/plain': ['.txt'],
'text/markdown': ['.md'],
'application/pdf': ['.pdf'],
'application/json': ['.json'],
'text/csv': ['.csv'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
},
}) })
const deleteFile = async name => { const deleteFile = async name => {
@@ -66,26 +85,29 @@ export default function DocumentManager({ connected }) {
setPreview({ name, content: data.content }) setPreview({ name, content: data.content })
} }
const statusIcon = item => {
if (item.status === 'uploading') return <Loader size={12} className="animate-spin text-blue-400" />
if (item.status === 'done') return <CheckCircle size={12} className="text-green-400" />
if (item.status === 'error') return <AlertCircle size={12} className="text-red-400" />
return null
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Stage tabs */}
<div className="flex gap-1 bg-[#161b27] rounded-xl p-1 w-fit"> <div className="flex gap-1 bg-[#161b27] rounded-xl p-1 w-fit">
{STAGES.map(s => ( {STAGES.map(s => (
<button <button
key={s} key={s}
onClick={() => setStage(s)} onClick={() => setStage(s)}
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors capitalize className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors capitalize
${stage === s ${stage === s ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-slate-200'}`}
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:text-slate-200'}`}
> >
{s} {s}
</button> </button>
))} ))}
</div> </div>
{/* Upload drop zone (input stage only) */}
{stage === 'input' && ( {stage === 'input' && (
<div <div
{...getRootProps()} {...getRootProps()}
@@ -95,30 +117,38 @@ export default function DocumentManager({ connected }) {
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
<Upload size={24} className="mx-auto mb-2 text-slate-500" /> <Upload size={24} className="mx-auto mb-2 text-slate-500" />
{uploading <p className="text-sm text-slate-400">
? <p className="text-sm text-blue-400">Uploading</p> {isDragActive ? 'Drop files here' : 'Drag & drop files or click — multiple files and .zip archives supported'}
: <p className="text-sm text-slate-400"> </p>
{isDragActive ? 'Drop file here' : 'Drag & drop or click to upload to /input'} <p className="text-xs text-slate-600 mt-1">txt · md · pdf · json · csv · docx · zip</p>
</p> </div>
} )}
{uploadQueue.length > 0 && (
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] overflow-hidden">
<div className="px-4 py-2 border-b border-slate-700/50 text-xs text-slate-400">Uploading</div>
<ul className="divide-y divide-slate-700/20">
{uploadQueue.map((item, i) => (
<li key={i} className="flex items-center gap-2 px-4 py-2 text-xs">
{statusIcon(item)}
<span className="flex-1 text-slate-300 truncate">{item.name}</span>
{item.status === 'done' && <span className="text-slate-500 capitalize">{item.action}</span>}
{item.error && <span className="text-red-400 truncate max-w-xs">{item.error}</span>}
</li>
))}
</ul>
</div> </div>
)} )}
{/* File table */}
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] overflow-hidden"> <div className="rounded-xl border border-slate-700/50 bg-[#161b27] overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50"> <div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50">
<span className="text-xs text-slate-400">{files.length} file(s)</span> <span className="text-xs text-slate-400">{files.length} file(s)</span>
<button <button onClick={fetchFiles} className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 transition-colors">
onClick={fetchFiles}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
<RefreshCw size={12}/> Refresh <RefreshCw size={12}/> Refresh
</button> </button>
</div> </div>
{error && ( {error && <p className="px-4 py-2 text-xs text-red-400">{error}</p>}
<p className="px-4 py-2 text-xs text-red-400">{error}</p>
)}
{loading ? ( {loading ? (
<p className="px-4 py-6 text-xs text-slate-500 text-center">Loading</p> <p className="px-4 py-6 text-xs text-slate-500 text-center">Loading</p>
@@ -138,23 +168,17 @@ export default function DocumentManager({ connected }) {
{files.map(f => ( {files.map(f => (
<tr key={f.name} className="border-b border-slate-700/20 hover:bg-slate-700/10"> <tr key={f.name} className="border-b border-slate-700/20 hover:bg-slate-700/10">
<td className="px-4 py-2 flex items-center gap-2"> <td className="px-4 py-2 flex items-center gap-2">
<FileText size={13} className="text-slate-500 flex-shrink-0"/> <FileIcon name={f.name} />
<span className="truncate max-w-xs text-slate-200">{f.name}</span> <span className="truncate max-w-xs text-slate-200">{f.name}</span>
</td> </td>
<td className="px-4 py-2 text-right text-slate-400">{formatBytes(f.size)}</td> <td className="px-4 py-2 text-right text-slate-400">{formatBytes(f.size)}</td>
<td className="px-4 py-2 text-right text-slate-500">{f.modified}</td> <td className="px-4 py-2 text-right text-slate-500">{f.modified}</td>
<td className="px-4 py-2 text-right"> <td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button onClick={() => previewFile(f.name)} className="p-1 rounded hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 transition-colors">
onClick={() => previewFile(f.name)}
className="p-1 rounded hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 transition-colors"
>
<Eye size={13}/> <Eye size={13}/>
</button> </button>
<button <button onClick={() => deleteFile(f.name)} className="p-1 rounded hover:bg-red-600/20 text-slate-500 hover:text-red-400 transition-colors">
onClick={() => deleteFile(f.name)}
className="p-1 rounded hover:bg-red-600/20 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 size={13}/> <Trash2 size={13}/>
</button> </button>
</div> </div>
@@ -166,22 +190,17 @@ export default function DocumentManager({ connected }) {
)} )}
</div> </div>
{/* Preview modal */}
{preview && ( {preview && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6">
<div className="bg-[#161b27] border border-slate-700 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col"> <div className="bg-[#161b27] border border-slate-700 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700"> <div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<span className="text-sm font-medium text-slate-200">{preview.name}</span> <span className="text-sm font-medium text-slate-200">{preview.name}</span>
<button onClick={() => setPreview(null)} className="text-slate-500 hover:text-slate-200"> <button onClick={() => setPreview(null)} className="text-slate-500 hover:text-slate-200"><X size={16}/></button>
<X size={16}/>
</button>
</div> </div>
<pre className="flex-1 overflow-auto p-4 text-xs text-slate-300 leading-relaxed whitespace-pre-wrap"> <pre className="flex-1 overflow-auto p-4 text-xs text-slate-300 leading-relaxed whitespace-pre-wrap">{preview.content}</pre>
{preview.content}
</pre>
</div> </div>
</div> </div>
)} )}
</div> </div>
) )
} }