// frontend/src/Uploader.tsx import React, { useEffect, useMemo, 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 } const [mobile, setMobile] = useState(false) useEffect(()=>{ setMobile(isLikelyMobile()) }, []) // Simple bundle heartbeat so you can confirm the loaded JS is current console.log('[Pegasus] FE bundle activated at', new Date().toISOString()) function isLikelyMobile(): boolean { if (typeof window === 'undefined') return false const ua = navigator.userAgent || '' const touch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches // catch Android/iOS/iPadOS return touch || /Mobi|Android|iPhone|iPad|iPod/i.test(ua) } 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)}` } // 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)) } // Normalize API list rows (works with old CamelCase or new snake_case) function normalizeRows(raw: any): FileRow[] { if (!Array.isArray(raw)) return [] return raw.map((r: any) => ({ name: r?.name ?? r?.Name ?? '', path: r?.path ?? r?.Path ?? '', is_dir: typeof r?.is_dir === 'boolean' ? r.is_dir : typeof r?.IsDir === 'boolean' ? r.IsDir : false, size: typeof r?.size === 'number' ? r.size : (typeof r?.Size === 'number' ? r.Size : 0), mtime: typeof r?.mtime === 'number' ? r.mtime : (typeof r?.Mtime === 'number' ? r.Mtime : 0), })).filter(r => r && typeof r.name === 'string' && typeof r.path === 'string') } // simple mobile detection (no CSS change needed) function useIsMobile() { const [isMobile, setIsMobile] = useState(false) useEffect(() => { const mq = window.matchMedia('(max-width: 640px)') const handler = (e: MediaQueryListEvent | MediaQueryList) => setIsMobile(('matches' in e ? e.matches : (e as MediaQueryList).matches)) handler(mq) mq.addEventListener('change', handler as any) return () => mq.removeEventListener('change', handler as any) }, []) return isMobile } export default function Uploader(){ const [me, setMe] = useState() const [libraries, setLibraries] = useState([]) // from whoami.roots or [whoami.root] const [selectedLib, setSelectedLib] = useState('') // must be chosen to upload const [subdir, setSubdir] = useState('') // one-level subfolder name (no '/') const [rows, setRows] = useState([]) const destPath = `/${[selectedLib, subdir].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 const isMobile = useIsMobile() // Enable directory selection on the folder picker (non-standard attr) useEffect(() => { const el = folderInputRef.current if (!el) return if (mobile) { // ensure we don't accidentally force folder mode on mobile el.removeAttribute('webkitdirectory') el.removeAttribute('directory') } else { el.setAttribute('webkitdirectory','') el.setAttribute('directory','') } }, [mobile]) // Fetch whoami + list async function refresh(path=''){ try { const m = await api('/api/whoami') setMe(m as WhoAmI) const libs: string[] = Array.isArray(m?.roots) && m.roots.length ? m.roots.slice() : m?.root ? [m.root] : [] setLibraries(libs) // Auto-select if exactly one library setSelectedLib(prev => prev || (libs.length === 1 ? libs[0] : '')) const listRaw = await api('/api/list?path='+encodeURIComponent(path)) const list = normalizeRows(listRaw) setRows(list); setSubdir(path) setStatus(`Ready · Destination: /${[libs[0] ?? '', 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 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(!selectedLib){ alert('Please select a Library to upload to.'); 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], // Avoid resuming old HTTP URLs: don't store fingerprints, and use a random fingerprint so nothing matches storeFingerprintForResuming: false, removeFingerprintOnSuccess: true, fingerprint: (async () => `${Date.now()}-${Math.random().toString(36).slice(2)}-${s.file.name}`) as any, metadata: { filename: s.file.name, // Server treats this as a subdirectory under the user's mapped root. // When backend supports multiple libraries, it will also need the selected library. subdir: subdir || "", 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, subdir }) up.start() }) } setStatus('All uploads complete') setSel([]) await refresh(subdir) } finally { setUploading(false) } } async function rename(oldp:string){ const name = prompt('New name (YYYY.MM.DD.description.ext for files, or folder name):', 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(subdir) } 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(subdir) } catch(e:any){ console.error('[Pegasus] delete error', e) alert(`Delete failed:\n${e?.message || e}`) } } function goUp(){ setSubdir(''); void refresh('') } // Precompute a safely sorted copy for rendering (no in-place mutation) const rowsSorted = useMemo(() => { const copy = Array.isArray(rows) ? [...rows] : [] return copy .filter(r => r && typeof r.name === 'string') .sort((a, b) => (Number(b.is_dir) - Number(a.is_dir)) || a.name.localeCompare(b.name)) }, [rows]) // Restrict to one-level: only allow "Open" when we're at library root (subdir === '') const canOpenDeeper = subdir === '' return (<>
Signed in: {me?.username}
Destination: {destPath || '/(select a library)'}
{/* Destination (Library + Subfolder) */}
{libraries.length <= 1 ? (
{libraries[0] || '(none)'}
) : ( )}
setGlobalDate(e.target.value)} />
{/* Pickers: mobile vs desktop */}
{mobile ? ( // ---- Mobile: show a Gallery picker (no capture) + optional Camera quick-capture <>
browsers tend to open Photo/Media picker instead of camera or filesystem accept="image/*,video/*" onChange={e => e.target.files && handleChoose(e.target.files)} />
e.target.files && handleChoose(e.target.files)} />
) : ( // ---- Desktop: show both Files and Folder(s) pickers <>
e.target.files && handleChoose(e.target.files)} />
{/* set webkitdirectory/directory via ref (done in useEffect) */} e.target.files && handleChoose(e.target.files)} />
)}
{(() => { const uploadDisabled = !selectedLib || !sel.length || uploading || sel.some(s=>!s.desc.trim()) let disabledReason = '' if (!selectedLib) disabledReason = 'Pick a library.' else 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}
} ) })()}
{/* Per-file editor */} {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}
{/* Destination details and subfolder management */}

Destination

{/* Current subfolder header & actions (one level only) */}
Library: {selectedLib || '(none)'} {' · '} Subfolder: {subdir ? subdir : '(root)'}
{/* Create subfolder (root only) */} {!subdir && ( { setSubdir(p); void refresh(p) }} /> )} {/* Listing */} {rowsSorted.length === 0 ? (
No items to show here. You’ll upload into {destPath || '/(select a library)'}.
) : (
{/* Only show "Open" when at library root to enforce one-level depth */} {rowsSorted.map(f =>
{f.is_dir?'📁':'🎞️'} {f.name}
{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}
)}
)}
) } 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() { // single-level only let clean = name.trim().replace(/[\/]+/g,'').replace(/[^\w\-\s.]/g,'_') clean = clean.replace(/\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)} />
); }