pegasus/frontend/src/Uploader.tsx
2025-09-08 00:48:47 -05:00

143 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, 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; finalName: 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)}`
}
export default function Uploader(){
const [me, setMe] = useState<WhoAmI|undefined>()
const [cwd, setCwd] = useState<string>('')
const [rows, setRows] = useState<FileRow[]>([])
const [status, setStatus] = useState<string>('')
const [date, setDate] = useState<string>(new Date().toISOString().slice(0,10))
const [sel, setSel] = useState<Sel[]>([])
async function refresh(path=''){ const m = await api<WhoAmI>('/api/whoami'); setMe(m)
const list = await api<FileRow[]>('/api/list?path='+encodeURIComponent(path)); setRows(list); setCwd(path)
}
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 guess = name.replace(/\.[^/.]+$/,'').replace(/[_-]+/g,' ')
const desc = sanitizeDesc(guess)
return { file:f, desc, finalName: composeName(date, desc, name) }
})
setSel(arr)
}
useEffect(()=>{ setSel(old => old.map(x=> ({...x, finalName: composeName(date, x.desc, x.file.name)}))) }, [date])
async function doUpload(){
if(!me) return
if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return }
setStatus('')
for(const s of sel){
await new Promise<void>((resolve,reject)=>{
const up = new tus.Upload(s.file, {
endpoint: '/tus/',
chunkSize: 5*1024*1024,
retryDelays: [0, 1000, 3000, 5000, 10000],
withCredentials: true,
metadata: {
filename: s.file.name,
subdir: cwd || "",
date, // YYYY-MM-DD
desc: s.desc // server composes YYYY.MM.DD.desc.ext
},
onError: (err)=>{ setStatus(`${s.file.name}: ${err}`); reject(err) },
onProgress: (sent, total)=>{ const pct = Math.floor(sent/total*100); setStatus(`${s.finalName}: ${pct}%`) },
onSuccess: ()=>{ setStatus(`${s.finalName} uploaded`); resolve() },
})
up.start()
})
}
setSel([]); refresh(cwd)
}
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)
}
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>
<div className="meta">{cwd ? `/${cwd}` : 'Choose a folder below'}</div>
</div>
<div className="grid" style={{alignItems:'end'}}>
<div>
<label className="meta">Date (autoapplied)</label>
<input type="date" value={date} onChange={e=> setDate(e.target.value)} />
</div>
<div>
<label className="meta">Pick files or folders</label>
<input type="file" multiple webkitdirectory="true" onChange={e=> e.target.files && handleChoose(e.target.files)} />
</div>
<button className="btn" onClick={doUpload} disabled={!sel.length}>Upload {sel.length? `(${sel.length})` : ''}</button>
</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>
<input value={s.desc} onChange={e=> {
const v=e.target.value; setSel(old => old.map((x,idx)=> idx===i ? ({...x, desc:v, finalName: composeName(date, v, x.file.name)}) : x ))
}} />
<div className="meta"> {s.finalName}</div>
</div>
))}
</div>
</div>
)}
<div className="meta" style={{marginTop:8}}>{status}</div>
</section>
<section className="card">
<h3>Folder</h3>
<div className="grid">
{cwd && (
<div className="item">
<a className="name" href="#" onClick={()=> setCwd(cwd.split('/').slice(0,-1).join('/')) || refresh(cwd.split('/').slice(0,-1).join('/'))}> Up</a>
</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
? (<><a href="#" onClick={()=>{ setCwd(f.path); refresh(f.path) }}>Open</a> · <a href="#" onClick={()=>rename(f.path)}>Rename</a> · <a className="bad" href="#" onClick={()=>del(f.path,true)}>Delete</a></>)
: (<><a href="#" onClick={()=>rename(f.path)}>Rename</a> · <a className="bad" href="#" onClick={()=>del(f.path,false)}>Delete</a></>)
}
</div>
</div>
)}
</div>
</section>
</>)
}
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] }