pegasus/frontend/src/Uploader.tsx
2025-09-16 23:06:40 -05:00

621 lines
24 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.

// frontend/src/Uploader.tsx
import React 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 }
console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
// ---------- helpers ----------
function sanitizeDesc(s:string){
s = s.trim().replace(/\s+/g,'_').replace(/[^A-Za-z0-9._-]+/g,'_')
if(!s) s = 'upload'
return s.slice(0,64)
}
function sanitizeFolderName(s:string){
// 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-]
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//,'').replace(/\/.*$/,'')
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g,'_').replace(/_+/g,'_')
return s.slice(0,64)
}
function extOf(n:string){ const i=n.lastIndexOf('.'); return i>-1 ? n.slice(i+1).toLowerCase() : '' }
function composeName(date:string, desc:string, orig:string){
const d = date || new Date().toISOString().slice(0,10)
const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}`
}
function clampOneLevel(p:string){ if(!p) return ''; return p.replace(/^\/+|\/+$/g,'').split('/')[0] || '' }
function normalizeRows(listRaw:any[]): FileRow[] {
return (Array.isArray(listRaw) ? listRaw : []).map((r:any)=>({
name: r?.name ?? r?.Name ?? '',
path: r?.path ?? r?.Path ?? '',
is_dir: Boolean(r?.is_dir ?? r?.IsDir ?? r?.isDir ?? false),
size: Number(r?.size ?? r?.Size ?? 0),
mtime: Number(r?.mtime ?? r?.Mtime ?? 0),
}))
}
const videoExt = new Set(['mp4','mkv','mov','avi','m4v','webm','mpg','mpeg','ts','m2ts'])
const imageExt = new Set(['jpg','jpeg','png','gif','heic','heif','webp','bmp','tif','tiff'])
const extLower = (n:string)=> (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '')
const isVideoFile = (f:File)=> f.type.startsWith('video/') || videoExt.has(extLower(f.name))
const isImageFile = (f:File)=> f.type.startsWith('image/') || imageExt.has(extLower(f.name))
function isDetailedError(e: unknown): e is tus.DetailedError {
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any))
}
function isLikelyMobileUA(): boolean {
if (typeof window === 'undefined') return false
const ua = navigator.userAgent || ''
const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
return coarse || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
}
function useIsMobile(): boolean {
const [mobile, setMobile] = React.useState(false)
React.useEffect(()=>{ setMobile(isLikelyMobileUA()) }, [])
return mobile
}
// Disable tus resume completely (v2 API: addUpload/removeUpload/listUploads)
const NoResumeUrlStorage: any = {
addUpload: async (_u: any) => {},
removeUpload: async (_u: any) => {},
listUploads: async () => [],
// compatibility:
findUploadsByFingerprint: async (_fp: string) => []
}
const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}`
// ---------- thumbnail ----------
function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) {
const [url, setUrl] = React.useState<string>()
React.useEffect(()=>{
const u = URL.createObjectURL(file); setUrl(u)
return () => { try { URL.revokeObjectURL(u) } catch {} }
}, [file])
if (!url) return null
const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 }
if (isImageFile(file)) return <img src={url} alt="" style={baseStyle} className="preview-thumb" />
if (isVideoFile(file)) return <video src={url} muted preload="metadata" playsInline style={baseStyle} className="preview-thumb" />
return <div style={{...baseStyle, display:'grid', placeItems:'center'}} className="preview-thumb">📄</div>
}
// ---------- component ----------
export default function Uploader(){
const mobile = useIsMobile()
const [me, setMe] = React.useState<WhoAmI|undefined>()
const [libs, setLibs] = React.useState<string[]>([])
const [lib, setLib] = React.useState<string>('')
const [sub, setSub] = React.useState<string>('')
const [rootDirs, setRootDirs] = React.useState<string[]>([])
const [rows, setRows] = React.useState<FileRow[]>([])
const [status, setStatus] = React.useState<string>('')
const [globalDate, setGlobalDate] = React.useState<string>(new Date().toISOString().slice(0,10))
const [uploading, setUploading] = React.useState<boolean>(false)
const [sel, setSel] = React.useState<Sel[]>([])
const [bulkDesc, setBulkDesc] = React.useState<string>('') // helper: apply to all videos
const folderInputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
const el = folderInputRef.current
if (!el) return
if (mobile) {
el.removeAttribute('webkitdirectory')
el.removeAttribute('directory')
} else {
el.setAttribute('webkitdirectory','')
el.setAttribute('directory','')
}
}, [mobile])
// initial load
React.useEffect(()=>{
(async ()=>{
try{
setStatus('Loading profile…')
const m = await api<WhoAmI>('/api/whoami'); setMe(m as any)
const mm:any = m
const L: string[] =
Array.isArray(mm?.roots) ? mm.roots :
Array.isArray(mm?.libs) ? mm.libs :
(typeof mm?.root === 'string' && mm.root ? [mm.root] : [])
setLibs(L)
const def = L[0] || ''
setLib(def)
setSub('')
if (def) { await refresh(def, '') }
setStatus(def ? `Ready · Destination: /${def}` : 'Choose a library to start')
} catch(e:any) {
const msg = String(e?.message || e || '')
setStatus(`Profile 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('/')
}
}
})()
}, [])
React.useEffect(()=>{
(async ()=>{
if (!lib) return
try {
setStatus(`Loading library “${lib}”…`)
await refresh(lib, sub)
} catch(e:any){
console.error('[Pegasus] refresh error', e)
setStatus(`List error: ${e?.message || e}`)
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lib])
async function refresh(currLib:string, currSub:string){
const one = clampOneLevel(currSub)
const listRoot = normalizeRows(await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path: '' })}`))
const rootDirNames = listRoot.filter(e=>e.is_dir).map(e=>e.name).filter(Boolean)
setRootDirs(rootDirNames.sort((a,b)=>a.localeCompare(b)))
const subOk = one && rootDirNames.includes(one) ? one : ''
if (subOk !== currSub) setSub(subOk)
const path = subOk ? subOk : ''
const list = subOk ? await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path })}`) : listRoot
setRows(normalizeRows(list))
const show = `/${[currLib, path].filter(Boolean).join('/')}`
setStatus(`Ready · Destination: ${show}`)
}
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; required later for videos only
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)
}
// recompute finalName when global date changes
React.useEffect(()=>{
setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)})))
}, [globalDate])
// Warn before closing mid-upload
React.useEffect(()=>{
const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } }
window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler)
}, [uploading])
// Apply description to all videos helper
function applyDescToAllVideos() {
if (!bulkDesc.trim()) return
setSel(old => old.map(x =>
isVideoFile(x.file)
? ({...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name)})
: x
))
}
async function doUpload(){
if(!me) { setStatus('Not signed in'); return }
if(!lib) { alert('Please select a Library to upload into.'); return }
// Require description only for videos:
const missingVideos = sel.filter(s => isVideoFile(s.file) && !s.desc.trim()).length
if (missingVideos > 0) {
alert(`Please add a short description for all videos. ${missingVideos} video(s) missing a description.`)
return
}
setStatus('Starting upload…')
setUploading(true)
try{
for(const s of sel){
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve,reject)=>{
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
endpoint: '/tus/',
chunkSize: 5*1024*1024,
retryDelays: [0, 1000, 3000, 5000, 10000],
metadata: {
filename: s.file.name,
lib: lib,
subdir: sub || "",
date: s.date,
desc: s.desc // server enforces: required for videos only
},
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()
},
}
opts.withCredentials = true
;(opts as any).urlStorage = NoResumeUrlStorage
;(opts as any).fingerprint = NoResumeFingerprint
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, lib, sub })
up.start()
})
}
setStatus('All uploads complete')
setSel([])
await refresh(lib, sub)
} finally {
setUploading(false)
}
}
// -------- one-level subfolder ops --------
async function createSubfolder(nameRaw:string){
const name = sanitizeFolderName(nameRaw)
if (!name) return
try{
await api(`/api/mkdir`, {
method:'POST',
headers:{'content-type':'application/json'},
body: JSON.stringify({ lib: lib, path: name })
})
await refresh(lib, name) // jump into new folder
setSub(name)
} catch(e:any){
console.error('[Pegasus] mkdir error', e)
alert(`Create folder failed:\n${e?.message || e}`)
}
}
async function renameFolder(oldName:string){
const nn = prompt('New folder name:', oldName)
const newName = sanitizeFolderName(nn || '')
if (!newName || newName === oldName) return
try{
await api('/api/rename', {
method:'POST',
headers:{'content-type':'application/json'},
body: JSON.stringify({ lib: lib, from: oldName, to: newName })
})
const newSub = (sub === oldName) ? newName : sub
setSub(newSub)
await refresh(lib, newSub)
} catch(e:any){
console.error('[Pegasus] rename folder error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
async function deleteFolder(name:string){
if(!confirm(`Delete folder “${name}” (and its contents)?`)) return
try{
await api(`/api/file?${new URLSearchParams({ lib: lib, path: name, recursive:'true' })}`, { method:'DELETE' })
const newSub = (sub === name) ? '' : sub
setSub(newSub)
await refresh(lib, newSub)
} catch(e:any){
console.error('[Pegasus] delete folder error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
// destination listing (actions: rename files only at library root)
async function renamePath(oldp:string){
const base = (oldp.split('/').pop()||'')
const name = prompt('New name (YYYY.MM.DD.description.ext):', base); if(!name) return
try{
await api('/api/rename', {
method:'POST',
headers:{'content-type':'application/json'},
body: JSON.stringify({ lib: lib, from: oldp, to: (oldp.split('/').slice(0,-1).concat(sanitizeFolderName(name))).join('/') })
})
await refresh(lib, sub)
} catch(e:any){
console.error('[Pegasus] rename error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
async function deletePath(p:string, recursive:boolean){
if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return
try{
await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive?'true':'false' })}`, { method:'DELETE' })
await refresh(lib, sub)
} catch(e:any){
console.error('[Pegasus] delete error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
// sort rows
const sortedRows = React.useMemo(()=>{
const arr = Array.isArray(rows) ? rows.slice() : []
return arr.sort((a,b)=>{
const dirFirst = (Number(b?.is_dir ? 1:0) - Number(a?.is_dir ? 1:0))
if (dirFirst !== 0) return dirFirst
const an = (a?.name ?? '')
const bn = (b?.name ?? '')
return an.localeCompare(bn)
})
}, [rows])
const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
const videosNeedingDesc = sel.filter(s => isVideoFile(s.file) && !s.desc.trim()).length
const [newFolder, setNewFolder] = React.useState('')
return (
<>
{/* Header context */}
<section>
<hgroup>
<h2>Signed in: {me?.username}</h2>
<p className="meta">Destination: <strong>{destPath || '/(choose a library)'}</strong></p>
</hgroup>
</section>
{/* Add files */}
<section>
<h3>Add files</h3>
<div className="grid-3">
<label>
Default date (applied to new selections)
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
</label>
</div>
<div className="file-picker">
{mobile ? (
<>
<label>
Gallery/Photos
<input type="file" multiple accept="image/*,video/*"
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label>
<label>
Camera (optional)
<input type="file" accept="image/*,video/*" capture="environment"
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label>
</>
) : (
<>
<label>
Select file(s)
<input type="file" multiple accept="image/*,video/*"
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label>
<label>
Select folder(s)
<input type="file" multiple ref={folderInputRef}
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label>
</>
)}
</div>
</section>
{/* Review & upload */}
<section>
<h3>Review & Upload <span className="meta">{destPath || '/(choose a library)'}</span></h3>
{sel.length === 0 ? (
<p className="meta">Select at least one file.</p>
) : (
<>
<article>
<div style={{display:'grid', gap:12}}>
<label>
Description for all videos (optional)
<input
placeholder="Short video description"
value={bulkDesc}
onChange={e=> setBulkDesc(e.target.value)}
/>
</label>
<button type="button" className="stretch" onClick={applyDescToAllVideos} disabled={!bulkDesc.trim()}>
Apply to all videos
</button>
{videosNeedingDesc > 0
? <small className="meta bad">{videosNeedingDesc} video(s) need a description</small>
: <small className="meta">All videos have descriptions</small>
}
</div>
</article>
{sel.map((s,i)=>(
<article key={i} className="video-card">
<div className="thumb-row">
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
</div>
<h4 className="filename ellipsis">{s.file.name}</h4>
<label>
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
<input
required={isVideoFile(s.file)}
aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
value={s.desc}
onChange={e=>{
const v=e.target.value
setSel(old => old.map((x,idx)=> idx===i ? ({...x, desc:v, finalName: composeName(x.date, v, x.file.name)}) : x))
}}
/>
</label>
<label>
Date
<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))
}}
/>
</label>
<small className="meta"> {s.finalName}</small>
{typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
{s.err && <small className="meta bad">Error: {s.err}</small>}
</article>
))}
</>
)}
</section>
{/* Folder (one-level manager) */}
<section>
<h3>Library</h3>
<div className="grid-3">
<label>
Library
<select value={lib} onChange={e=>{ setLib(e.target.value); setSub('') }}>
<option value="" disabled> Select a library </option>
{libs.map(L => <option key={L} value={L}>{L}</option>)}
</select>
{libs.length<=1 && <small className="meta">Auto-selected</small>}
</label>
<label>
Subfolder (optional, 1 level)
<select
value={sub}
onChange={e=>{ const v = clampOneLevel(e.target.value); setSub(v); void refresh(lib, v) }}
disabled={!lib}
>
<option value="">(Library root)</option>
{rootDirs.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</label>
</div>
<div className="grid-3">
<label>
New subfolder name (optional)
<input
placeholder="letters, numbers, underscores, dashes"
value={newFolder}
onChange={e=> setNewFolder(sanitizeFolderName(e.target.value))}
disabled={!lib}
inputMode="text"
pattern="[A-Za-z0-9_.-]+"
title="Use letters, numbers, underscores, dashes or dots"
/>
</label>
{Boolean(sanitizeFolderName(newFolder)) && (
<div className="center-actions">
<button
type="button"
onClick={()=>{ const n = sanitizeFolderName(newFolder); if (n) { void createSubfolder(n); setNewFolder('') } }}
>
Create
</button>
</div>
)}
</div>
<article>
<div style={{display:'flex', gap:8, alignItems:'center', justifyContent:'space-between'}}>
<div>Current: <strong>{sub || '(library root)'}</strong></div>
{sub && (
<div style={{display:'flex', gap:8}}>
<button onClick={()=> void renameFolder(sub)}>Rename</button>
<button onClick={()=> void deleteFolder(sub)} className="bad">Delete</button>
<button onClick={()=>{ setSub(''); void refresh(lib, '') }}>Clear</button>
</div>
)}
</div>
</article>
<details open style={{marginTop:8}}>
<summary>Subfolders</summary>
{rootDirs.length === 0 ? (
<p className="meta">No subfolders yet. Youll upload into <b>/{lib}</b>.</p>
) : (
<div style={{display:'grid', gap:8}}>
{rootDirs.map(d=>(
<article key={d} style={{display:'flex', alignItems:'center', justifyContent:'space-between'}}>
<div>📁 {d}</div>
<div style={{display:'flex', gap:8}}>
<button onClick={()=>{ setSub(d); void refresh(lib, d) }}>Select</button>
<button onClick={()=> void renameFolder(d)}>Rename</button>
<button onClick={()=> void deleteFolder(d)} className="bad">Delete</button>
</div>
</article>
))}
</div>
)}
</details>
<details open style={{marginTop:8}}>
<summary>Contents of {destPath || '/(choose)'}</summary>
{sortedRows.length === 0 ? (
<p className="meta">Empty.</p>
) : (
<div style={{display:'grid', gap:8}}>
{sortedRows.map(f =>
<article key={f.path} style={{display:'flex', alignItems:'center', justifyContent:'space-between'}}>
<div>
<div className="ellipsis">{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}</div>
<small className="meta">{f.is_dir?'folder':fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}</small>
</div>
<div style={{display:'flex', gap:8}}>
{f.is_dir ? (
<>
<button onClick={()=>{ setSub(f.name); void refresh(lib, f.name) }}>Open</button>
<button onClick={()=> void renameFolder(f.name)}>Rename</button>
<button onClick={()=> void deleteFolder(f.name)} className="bad">Delete</button>
</>
) : (
<>
{sub === '' && <button onClick={()=> void renamePath(f.path)}>Rename</button>}
<button onClick={()=> void deletePath(f.path, false)} className="bad">Delete</button>
</>
)}
</div>
</article>
)}
</div>
)}
</details>
</section>
<footer style={{display:'flex', gap:12, alignItems:'center', justifyContent:'space-between', marginTop:8}}>
<small className="meta" aria-live="polite">{status}</small>
<button
type="button"
onClick={()=>{ const disabled = !lib || !sel.length || uploading || videosNeedingDesc>0; if (!disabled) void doUpload() }}
disabled={!lib || !sel.length || uploading || videosNeedingDesc>0}
>
Upload {sel.length? `(${sel.length})` : ''}
</button>
</footer>
</>
)
}
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] }