Workshop / src /components /workshop /FilePartsArea.tsx
00000tt's picture
Overhaul Workshop: LoRA pipeline configurator dashboard + publish engine
8edd759
Raw
History Blame Contribute Delete
6.28 kB
import { FileArchive, Loader2, UploadCloud, X } from 'lucide-react'
import { useRef, useState } from 'react'
interface StagedFile {
id: string
name: string
size: number
status: 'idle' | 'uploading' | 'done' | 'error'
error: string
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function FilePartsArea({ onLog, onUploaded, vaultReady }: {
onLog: (msg: string) => void
onUploaded: () => void
vaultReady: boolean
}) {
const [files, setFiles] = useState<StagedFile[]>([])
const [dragOver, setDragOver] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const storedRef = useRef<{ id: string; file: File }[]>([])
const capture = (list: FileList | null) => {
if (!list) return
const refs = Array.from(list).map(f => ({
id: `${f.name}-${f.size}-${Date.now()}-${Math.random()}`,
file: f,
}))
storedRef.current.push(...refs)
setFiles(prev => [...prev, ...refs.map(r => ({ id: r.id, name: r.file.name, size: r.file.size, status: 'idle' as const, error: '' }))])
}
const removeFile = (id: string) => {
storedRef.current = storedRef.current.filter(f => f.id !== id)
setFiles(prev => prev.filter(f => f.id !== id))
}
const uploadFile = async (id: string) => {
const ref = storedRef.current.find(r => r.id === id)
if (!ref) return
const fd = new FormData()
fd.append('file', ref.file)
setFiles(prev => prev.map(f => (f.id === id ? { ...f, status: 'uploading', error: '' } : f)))
try {
const res = await fetch('/api/warehouse/upload', { method: 'POST', body: fd })
const data = await res.json()
if (res.ok) {
setFiles(prev => prev.map(f => (f.id === id ? { ...f, status: 'done' } : f)))
onLog(`Uploaded ${ref.file.name} to warehouse.`)
onUploaded()
} else {
setFiles(prev => prev.map(f => (f.id === id ? { ...f, status: 'error', error: data.detail ?? 'upload failed' } : f)))
onLog(`Upload failed: ${data.detail ?? 'unknown'}`)
}
} catch (e) {
setFiles(prev => prev.map(f => (f.id === id ? { ...f, status: 'error', error: String(e) } : f)))
onLog(`Upload error: ${e}`)
}
}
const uploadAll = async () => {
if (!vaultReady) { onLog('Unlock the vault before uploading.'); return }
for (const f of files) {
if (f.status !== 'done') await uploadFile(f.id)
}
}
const pendingCount = files.filter(f => f.status !== 'done').length
return (
<div className="bg-[#191919] border border-[#2E2E2E] rounded-xl p-4 flex flex-col">
<div className="flex items-center gap-2 mb-3">
<UploadCloud size={16} className="text-[#FF9D00]" />
<span className="text-sm font-semibold text-white">Reference library parts</span>
{!vaultReady && <span className="ml-auto text-[10px] text-[#FBBF24]">vault locked</span>}
</div>
<button
onClick={() => inputRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={e => {
e.preventDefault()
setDragOver(false)
capture(e.dataTransfer.files)
}}
className={`flex flex-col items-center justify-center gap-2 border-2 border-dashed rounded-lg py-6 px-3 text-center transition-colors ${
dragOver ? 'border-[#FF9D00] bg-[#FF9D00]/5' : 'border-[#2E2E2E] hover:border-[#4A4A4A]'
}`}
>
<UploadCloud size={22} className="text-[#8B8B8B]" />
<span className="text-xs text-[#8B8B8B]">Drop LoRA .safetensors here / click to browse</span>
<input
ref={inputRef}
type="file"
multiple
accept=".safetensors"
className="hidden"
onChange={e => { capture(e.target.files); e.target.value = '' }}
/>
</button>
<button
onClick={uploadAll}
disabled={!vaultReady || pendingCount === 0}
className={`mt-3 w-full flex items-center justify-center gap-2 rounded-lg py-2.5 text-xs font-semibold transition-colors ${
!vaultReady || pendingCount === 0
? 'bg-[#242424] text-[#5A5A5A] cursor-not-allowed'
: 'bg-[#FF9D00] text-black hover:bg-[#FFB93D]'
}`}
>
<UploadCloud size={14} />
Upload {pendingCount > 0 ? `(${pendingCount})` : ''} to warehouse
</button>
<div className="mt-3 space-y-2 max-h-[180px] overflow-y-auto">
{files.length === 0 ? (
<p className="text-xs text-[#5A5A5A] text-center py-4 border border-dashed border-[#2E2E2E] rounded-lg">No parts staged yet</p>
) : (
files.map(f => (
<div key={f.id} className="flex items-center justify-between gap-2 bg-[#0F0F0F] border border-[#2E2E2E] rounded-lg px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
{f.status === 'uploading' ? (
<Loader2 size={14} className="text-[#FF9D00] animate-spin flex-shrink-0" />
) : f.status === 'done' ? (
<span className="text-[#4ADE80] text-xs flex-shrink-0"></span>
) : f.status === 'error' ? (
<span className="text-[#F87171] text-xs flex-shrink-0"></span>
) : (
<FileArchive size={14} className="text-[#FF9D00] flex-shrink-0" />
)}
<span className="text-xs text-white truncate">{f.name}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-[#6B6B6B]">{formatSize(f.size)}</span>
{f.error && <span className="text-[10px] text-[#F87171] max-w-32 truncate" title={f.error}>{f.error}</span>}
{f.status !== 'uploading' && (
<button onClick={() => removeFile(f.id)} className="text-[#6B6B6B] hover:text-white">
<X size={13} />
</button>
)}
</div>
</div>
))
)}
</div>
</div>
)
}
export default FilePartsArea