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 } // Simple bundle heartbeat so you can confirm the loaded JS is current console.log('[Pegasus] FE bundle activated at', new Date().toISOString()) 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([]) 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) setStatus(`Ready · Destination: /${[m.root, path].filter(Boolean).join('/')}`) console.log('[Pegasus] list ok', { path, count: list.length }) } catch (e:any) { const msg = String(e?.message || e || '') console.error('[Pegasus] list error', e) setStatus(`List error: ${msg}`) if (msg.toLowerCase().includes('no mapping')) { alert('Your account is not linked to any upload library yet. Please contact the admin to be granted access.') try { await api('/api/logout', { method:'POST' }) } catch {} location.replace('/') // back to login } } } useEffect(()=>{ setStatus('Loading profile & folder list…'); 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 } }) console.log('[Pegasus] selected files', arr.map(a=>a.file.name)) setSel(arr) } // When the global date changes, recompute per-file finalName (leave per-file date in place) useEffect(()=>{ setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)}))) }, [globalDate]) // Warn before closing mid-upload 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) { setStatus('Not signed in'); return } if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return } setStatus('Starting upload…') setUploading(true) try{ for(const s of sel){ // eslint-disable-next-line no-await-in-loop await new Promise((resolve,reject)=>{ const opts: tus.UploadOptions & { withCredentials?: boolean } = { endpoint: '/tus/', chunkSize: 5*1024*1024, retryDelays: [0, 1000, 3000, 5000, 10000], resume: false, // ignore any old http:// resume URLs removeFingerprintOnSuccess: true, // keep storage clean going forward 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() } console.error('[Pegasus] tus error', s.file.name, err) 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`) console.log('[Pegasus] tus success', s.file.name) resolve() }, } // Ensure cookie is sent even if the tus typings don’t list this field opts.withCredentials = true const up = new tus.Upload(s.file, opts) console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, cwd }) up.start() }) } setStatus('All uploads complete') setSel([]) await refresh(cwd) } finally { setUploading(false) } } async function rename(oldp:string){ const name = prompt('New name (YYYY.MM.DD.description.ext):', oldp.split('/').pop()||''); if(!name) return try{ 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('/')}) }) await refresh(cwd) } catch(e:any){ console.error('[Pegasus] rename error', e) alert(`Rename failed:\n${e?.message || e}`) } } async function del(p:string, recursive:boolean){ if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return try{ await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' }) await refresh(cwd) } catch(e:any){ console.error('[Pegasus] delete error', e) alert(`Delete failed:\n${e?.message || e}`) } } function goUp(){ const up = cwd.split('/').slice(0,-1).join('/') setCwd(up); void 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)} />
{(() => { const uploadDisabled = !sel.length || uploading || sel.some(s=>!s.desc.trim()) let disabledReason = '' if (!sel.length) disabledReason = 'Select at least one file.' else if (uploading) disabledReason = 'Upload in progress…' else if (sel.some(s=>!s.desc.trim())) disabledReason = 'Add a short description for every file.' return ( <> {disabledReason &&
{disabledReason}
} ) })()}
{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); void 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('/'); console.log('[Pegasus] mkdir click', { path }) try { await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) }); onCreate(path); setName(''); } catch (e:any) { console.error('[Pegasus] mkdir error', e) alert(`Create folder failed:\n${e?.message || e}`) } } return (
setName(e.target.value)} />
); }