pegasus/frontend/src/Uploader.tsx

256 lines
11 KiB
TypeScript
Raw Normal View History

2025-09-16 00:05:16 -05:00
// frontend/src/Uploader.tsx
import React, { useEffect, useRef, useState } from 'react'
2025-09-08 00:48:47 -05:00
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 }
2025-09-16 00:05:16 -05:00
type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string }
2025-09-08 00:48:47 -05:00
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)}`
}
2025-09-16 00:05:16 -05:00
// 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))
}
2025-09-08 00:48:47 -05:00
export default function Uploader(){
const [me, setMe] = useState<WhoAmI|undefined>()
2025-09-16 00:05:16 -05:00
const [cwd, setCwd] = useState<string>('') // relative to user's mapped root
const [rows, setRows] = useState<FileRow[]>([]) // <-- missing before
const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}`
2025-09-08 00:48:47 -05:00
const [status, setStatus] = useState<string>('')
2025-09-16 00:05:16 -05:00
const [globalDate, setGlobalDate] = useState<string>(new Date().toISOString().slice(0,10))
const [uploading, setUploading] = useState<boolean>(false)
2025-09-08 00:48:47 -05:00
const [sel, setSel] = useState<Sel[]>([])
2025-09-16 00:05:16 -05:00
const folderInputRef = useRef<HTMLInputElement>(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','')
}
}, [])
2025-09-08 00:48:47 -05:00
2025-09-16 00:05:16 -05:00
async function refresh(path=''){
try {
const m = await api<WhoAmI>('/api/whoami'); setMe(m)
const list = await api<FileRow[]>('/api/list?path='+encodeURIComponent(path))
setRows(list); setCwd(path)
} catch (e: any) {
setStatus(`List error: ${e.message || e}`)
}
2025-09-08 00:48:47 -05:00
}
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
2025-09-16 00:05:16 -05:00
const desc = '' // start empty; user must fill before upload
return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
2025-09-08 00:48:47 -05:00
})
setSel(arr)
}
2025-09-16 00:05:16 -05:00
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])
2025-09-08 00:48:47 -05:00
async function doUpload(){
if(!me) return
if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return }
2025-09-16 00:05:16 -05:00
setStatus('Starting upload...')
setUploading(true)
2025-09-08 00:48:47 -05:00
for(const s of sel){
await new Promise<void>((resolve,reject)=>{
2025-09-16 00:05:16 -05:00
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
2025-09-08 00:48:47 -05:00
endpoint: '/tus/',
chunkSize: 5*1024*1024,
retryDelays: [0, 1000, 3000, 5000, 10000],
metadata: {
filename: s.file.name,
subdir: cwd || "",
2025-09-16 00:05:16 -05:00
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}%`)
2025-09-08 00:48:47 -05:00
},
2025-09-16 00:05:16 -05:00
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 dont have this field
opts.withCredentials = true
const up = new tus.Upload(s.file, opts)
2025-09-08 00:48:47 -05:00
up.start()
})
}
2025-09-16 00:05:16 -05:00
setUploading(false); setSel([]); refresh(cwd); setStatus('All uploads complete')
2025-09-08 00:48:47 -05:00
}
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)
}
2025-09-16 00:05:16 -05:00
function goUp(){
const up = cwd.split('/').slice(0,-1).join('/')
setCwd(up); refresh(up)
}
2025-09-08 00:48:47 -05:00
return (<>
<section className="card">
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', gap:8, flexWrap:'wrap'}}>
<div><b>Signed in:</b> {me?.username} · <span className="meta">root: /{me?.root}</span></div>
2025-09-16 00:05:16 -05:00
<div className="meta">Destination: <b>{destPath || '/(unknown)'}</b></div>
2025-09-08 00:48:47 -05:00
</div>
<div className="grid" style={{alignItems:'end'}}>
<div>
2025-09-16 00:05:16 -05:00
<label className="meta">Default date (applied to new selections)</label>
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
2025-09-08 00:48:47 -05:00
</div>
2025-09-16 00:05:16 -05:00
<div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
<div>
<label className="meta">Gallery/Photos</label>
<input type="file" multiple accept="image/*,video/*" capture="environment"
onChange={e=> e.target.files && handleChoose(e.target.files)} />
</div>
<div className="hide-on-mobile">
<label className="meta">Files/Folders</label>
{/* set webkitdirectory/directory via ref to satisfy TS */}
<input type="file" multiple ref={folderInputRef}
onChange={e=> e.target.files && handleChoose(e.target.files)} />
</div>
2025-09-08 00:48:47 -05:00
</div>
2025-09-16 00:05:16 -05:00
<button className="btn" onClick={doUpload}
disabled={!sel.length || uploading || sel.some(s=>!s.desc.trim())}>
Upload {sel.length? `(${sel.length})` : ''}
</button>
{sel.length>0 && sel.some(s=>!s.desc.trim()) && (
<div className="meta bad">Add a short description for every file to enable Upload.</div>
)}
2025-09-08 00:48:47 -05:00
</div>
{sel.length>0 && (
<div className="card">
<h4>Ready to upload</h4>
<div className="grid">
{sel.map((s,i)=>(
<div key={i} className="item">
<div className="meta">{s.file.name}</div>
2025-09-16 00:05:16 -05:00
<input value={s.desc} placeholder="Short description (required)" onChange={e=> {
const v=e.target.value
setSel(old => old.map((x,idx)=> idx===i
? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)})
: x ))
2025-09-08 00:48:47 -05:00
}} />
2025-09-16 00:05:16 -05:00
<div style={{display:'grid', gridTemplateColumns:'auto 1fr', gap:8, alignItems:'center', marginTop:6}}>
<span className="meta">Date</span>
<input type="date" value={s.date} onChange={e=>{
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))
}} />
</div>
2025-09-08 00:48:47 -05:00
<div className="meta"> {s.finalName}</div>
2025-09-16 00:05:16 -05:00
{typeof s.progress === 'number' && (<>
<progress max={100} value={s.progress}></progress>
{s.err && <div className="meta bad">Error: {s.err}</div>}
</>)}
2025-09-08 00:48:47 -05:00
</div>
))}
</div>
</div>
)}
<div className="meta" style={{marginTop:8}}>{status}</div>
</section>
<section className="card">
<h3>Folder</h3>
2025-09-16 00:05:16 -05:00
{rows.length === 0 ? (
<div className="item">
<div className="meta">
No items to show here. Youll upload into <b>{destPath}</b>.
</div>
<CreateFolder cwd={cwd} onCreate={(p)=>{ setCwd(p); refresh(p) }} />
</div>
) : (
2025-09-08 00:48:47 -05:00
<div className="grid">
{cwd && (
<div className="item">
2025-09-16 00:05:16 -05:00
<a className="name" href="#" onClick={(e)=>{e.preventDefault(); goUp()}}> Up</a>
2025-09-08 00:48:47 -05:00
</div>
)}
{rows.sort((a,b)=> (Number(b.is_dir)-Number(a.is_dir)) || a.name.localeCompare(b.name)).map(f=>
<div key={f.path} className="item">
<div className="name">{f.is_dir?'📁':'🎞️'} {f.name}</div>
<div className="meta">{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}</div>
<div className="meta">
{f.is_dir
2025-09-16 00:05:16 -05:00
? (<><a href="#" onClick={(e)=>{e.preventDefault(); setCwd(f.path); refresh(f.path)}}>Open</a> · <a href="#" onClick={(e)=>{e.preventDefault(); rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); del(f.path,true)}}>Delete</a></>)
: (<><a href="#" onClick={(e)=>{e.preventDefault(); rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); del(f.path,false)}}>Delete</a></>)
2025-09-08 00:48:47 -05:00
}
</div>
</div>
)}
</div>
2025-09-16 00:05:16 -05:00
)}
2025-09-08 00:48:47 -05:00
</section>
</>)
}
2025-09-16 00:05:16 -05:00
2025-09-08 00:48:47 -05:00
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&&i<u.length-1); return n.toFixed(1)+' '+u[i] }
2025-09-16 00:05:16 -05:00
function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void }) {
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 (
<div style={{marginTop:10, display:'flex', gap:8, alignItems:'center'}}>
<input placeholder="New folder name" value={name} onChange={e=>setName(e.target.value)} />
<button className="btn" onClick={submit} disabled={!name.trim()}>Create</button>
</div>
);
}