143 lines
6.4 KiB
TypeScript
143 lines
6.4 KiB
TypeScript
|
|
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 (auto‑applied)</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] }
|