// frontend/src/Uploader.tsx import React, { useEffect, useRef, useState } from 'react' import { api, WhoAmI } from './api' import * as tus from 'tus-js-client' type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number } type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string } function sanitizeDesc(s:string){ s = s.trim().replace(/[^A-Za-z0-9 _-]+/g,'_').replace(/\s+/g,'_') if(!s) s = 'upload' return s.slice(0,64) } function extOf(n:string){ const i=n.lastIndexOf('.'); return i>-1 ? n.slice(i+1).toLowerCase() : 'bin' } function composeName(date:string, desc:string, orig:string){ const d = date || new Date().toISOString().slice(0,10) // YYYY-MM-DD const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}` } // Type guard to narrow tus error function isDetailedError(e: unknown): e is tus.DetailedError { return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any)) } export default function Uploader(){ const [me, setMe] = useState() const [cwd, setCwd] = useState('') // relative to user's mapped root const [rows, setRows] = useState([]) // <-- missing before const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}` const [status, setStatus] = useState('') const [globalDate, setGlobalDate] = useState(new Date().toISOString().slice(0,10)) const [uploading, setUploading] = useState(false) const [sel, setSel] = useState([]) const folderInputRef = useRef(null) // to set webkitdirectory // enable directory selection on the folder picker (non-standard attr) useEffect(() => { const el = folderInputRef.current if (el) { el.setAttribute('webkitdirectory','') el.setAttribute('directory','') } }, []) async function refresh(path=''){ try { const m = await api('/api/whoami'); setMe(m) const list = await api('/api/list?path='+encodeURIComponent(path)) setRows(list); setCwd(path) } catch (e: any) { setStatus(`List error: ${e.message || e}`) } } useEffect(()=>{ refresh('') }, []) function handleChoose(files: FileList){ const arr = Array.from(files).map(f=>{ const base = (f as any).webkitRelativePath || f.name const name = base.split('/').pop() || f.name const desc = '' // start empty; user must fill before upload return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 } }) setSel(arr) } useEffect(()=>{ setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)}))) }, [globalDate]) useEffect(()=>{ const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } } window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler) }, [uploading]) async function doUpload(){ if(!me) return if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return } setStatus('Starting upload...') setUploading(true) for(const s of sel){ await new Promise((resolve,reject)=>{ const opts: tus.UploadOptions & { withCredentials?: boolean } = { endpoint: '/tus/', chunkSize: 5*1024*1024, retryDelays: [0, 1000, 3000, 5000, 10000], metadata: { filename: s.file.name, subdir: cwd || "", date: s.date, // per-file YYYY-MM-DD desc: s.desc // server composes final }, onError: (err: Error | tus.DetailedError)=>{ let msg = String(err) if (isDetailedError(err)) { const status = (err.originalRequest as any)?.status const statusText = (err.originalRequest as any)?.statusText if (status) msg = `HTTP ${status} ${statusText || ''}`.trim() } setSel(old => old.map(x => x.file===s.file ? ({...x, err: msg}) : x)) setStatus(`✖ ${s.file.name}: ${msg}`) alert(`Upload failed: ${s.file.name}\n\n${msg}`) reject(err as any) }, onProgress: (sent: number, total: number)=>{ const pct = Math.floor(sent/Math.max(total,1)*100) setSel(old => old.map(x => x.file===s.file ? ({...x, progress: pct}) : x)) setStatus(`⬆ ${s.finalName}: ${pct}%`) }, onSuccess: ()=>{ setSel(old => old.map(x => x.file===s.file ? ({...x, progress: 100, err: undefined}) : x)) setStatus(`✔ ${s.finalName} uploaded`); resolve() }, } // ensure cookie is sent even if your tus typings don’t have this field opts.withCredentials = true const up = new tus.Upload(s.file, opts) up.start() }) } setUploading(false); setSel([]); refresh(cwd); setStatus('All uploads complete') } async function rename(oldp:string){ const name = prompt('New name (YYYY.MM.DD.description.ext):', oldp.split('/').pop()||''); if(!name) return await api('/api/rename', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({from:oldp, to: (oldp.split('/').slice(0,-1).concat(name)).join('/')}) }) refresh(cwd) } async function del(p:string, recursive:boolean){ if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' }) refresh(cwd) } function goUp(){ const up = cwd.split('/').slice(0,-1).join('/') setCwd(up); refresh(up) } return (<>
Signed in: {me?.username} · root: /{me?.root}
Destination: {destPath || '/(unknown)'}
setGlobalDate(e.target.value)} />
e.target.files && handleChoose(e.target.files)} />
{/* set webkitdirectory/directory via ref to satisfy TS */} e.target.files && handleChoose(e.target.files)} />
{sel.length>0 && sel.some(s=>!s.desc.trim()) && (
Add a short description for every file to enable Upload.
)}
{sel.length>0 && (

Ready to upload

{sel.map((s,i)=>(
{s.file.name}
{ const v=e.target.value setSel(old => old.map((x,idx)=> idx===i ? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)}) : x )) }} />
Date { const v = e.target.value; setSel(old => old.map((x,idx)=> idx===i ? ({...x, date:v, finalName: composeName(v, x.desc, x.file.name)}) : x)) }} />
→ {s.finalName}
{typeof s.progress === 'number' && (<> {s.err &&
Error: {s.err}
} )}
))}
)}
{status}

Folder

{rows.length === 0 ? (
No items to show here. You’ll upload into {destPath}.
{ setCwd(p); refresh(p) }} />
) : (
{cwd && ( )} {rows.sort((a,b)=> (Number(b.is_dir)-Number(a.is_dir)) || a.name.localeCompare(b.name)).map(f=> )}
)}
) } function fmt(n:number){ if(n<1024) return n+' B'; const u=['KB','MB','GB','TB']; let i=-1; do{ n/=1024; i++ }while(n>=1024&&ivoid }) { const [name, setName] = React.useState(''); async function submit() { const clean = name.trim().replace(/[\/]+/g,'/').replace(/^\//,'').replace(/[^\w\-\s.]/g,'_'); if (!clean) return; const path = [cwd, clean].filter(Boolean).join('/'); await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) }); onCreate(path); setName(''); } return (
setName(e.target.value)} />
); }