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