2025-09-16 07:37:10 -05:00
|
|
|
|
// frontend/src/Uploader.tsx
|
2025-09-16 23:06:40 -05:00
|
|
|
|
import React 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
|
|
|
|
|
2025-09-16 04:32:16 -05:00
|
|
|
|
console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
|
|
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
// ---------- helpers ----------
|
2025-09-08 00:48:47 -05:00
|
|
|
|
function sanitizeDesc(s:string){
|
2025-09-16 23:06:40 -05:00
|
|
|
|
s = s.trim().replace(/\s+/g,'_').replace(/[^A-Za-z0-9._-]+/g,'_')
|
2025-09-08 00:48:47 -05:00
|
|
|
|
if(!s) s = 'upload'
|
|
|
|
|
|
return s.slice(0,64)
|
|
|
|
|
|
}
|
2025-09-16 23:06:40 -05:00
|
|
|
|
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() : '' }
|
2025-09-08 00:48:47 -05:00
|
|
|
|
function composeName(date:string, desc:string, orig:string){
|
2025-09-16 23:06:40 -05:00
|
|
|
|
const d = date || new Date().toISOString().slice(0,10)
|
2025-09-08 00:48:47 -05:00
|
|
|
|
const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}`
|
|
|
|
|
|
}
|
2025-09-16 23:06:40 -05:00
|
|
|
|
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))
|
2025-09-08 00:48:47 -05:00
|
|
|
|
|
2025-09-16 00:05:16 -05:00
|
|
|
|
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-16 23:06:40 -05:00
|
|
|
|
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
|
2025-09-16 07:37:10 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
// 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>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
// ---------- component ----------
|
2025-09-08 00:48:47 -05:00
|
|
|
|
export default function Uploader(){
|
2025-09-16 23:06:40 -05:00
|
|
|
|
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(() => {
|
2025-09-16 00:05:16 -05:00
|
|
|
|
const el = folderInputRef.current
|
2025-09-16 07:37:10 -05:00
|
|
|
|
if (!el) return
|
|
|
|
|
|
if (mobile) {
|
|
|
|
|
|
el.removeAttribute('webkitdirectory')
|
|
|
|
|
|
el.removeAttribute('directory')
|
|
|
|
|
|
} else {
|
2025-09-16 00:05:16 -05:00
|
|
|
|
el.setAttribute('webkitdirectory','')
|
|
|
|
|
|
el.setAttribute('directory','')
|
|
|
|
|
|
}
|
2025-09-16 07:37:10 -05:00
|
|
|
|
}, [mobile])
|
2025-09-08 00:48:47 -05:00
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
// 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('/')
|
|
|
|
|
|
}
|
2025-09-16 04:32:16 -05:00
|
|
|
|
}
|
2025-09-16 23:06:40 -05:00
|
|
|
|
})()
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
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}`)
|
2025-09-08 00:48:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 23:06:40 -05:00
|
|
|
|
const desc = '' // start empty; required later for videos only
|
2025-09-16 00:05:16 -05:00
|
|
|
|
return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
|
2025-09-08 00:48:47 -05:00
|
|
|
|
})
|
2025-09-16 04:32:16 -05:00
|
|
|
|
console.log('[Pegasus] selected files', arr.map(a=>a.file.name))
|
2025-09-08 00:48:47 -05:00
|
|
|
|
setSel(arr)
|
|
|
|
|
|
}
|
2025-09-16 00:05:16 -05:00
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
// recompute finalName when global date changes
|
|
|
|
|
|
React.useEffect(()=>{
|
2025-09-16 00:05:16 -05:00
|
|
|
|
setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)})))
|
|
|
|
|
|
}, [globalDate])
|
|
|
|
|
|
|
2025-09-16 04:32:16 -05:00
|
|
|
|
// Warn before closing mid-upload
|
2025-09-16 23:06:40 -05:00
|
|
|
|
React.useEffect(()=>{
|
2025-09-16 00:05:16 -05:00
|
|
|
|
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
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
// 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
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-08 00:48:47 -05:00
|
|
|
|
async function doUpload(){
|
2025-09-16 04:32:16 -05:00
|
|
|
|
if(!me) { setStatus('Not signed in'); return }
|
2025-09-16 23:06:40 -05:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2025-09-16 07:37:10 -05:00
|
|
|
|
|
2025-09-16 04:32:16 -05:00
|
|
|
|
setStatus('Starting upload…')
|
2025-09-16 00:05:16 -05:00
|
|
|
|
setUploading(true)
|
2025-09-16 04:32:16 -05:00
|
|
|
|
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,
|
2025-09-16 23:06:40 -05:00
|
|
|
|
lib: lib,
|
|
|
|
|
|
subdir: sub || "",
|
|
|
|
|
|
date: s.date,
|
|
|
|
|
|
desc: s.desc // server enforces: required for videos only
|
2025-09-16 04:32:16 -05:00
|
|
|
|
},
|
|
|
|
|
|
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
|
2025-09-16 23:06:40 -05:00
|
|
|
|
;(opts as any).urlStorage = NoResumeUrlStorage
|
|
|
|
|
|
;(opts as any).fingerprint = NoResumeFingerprint
|
2025-09-16 04:32:16 -05:00
|
|
|
|
|
|
|
|
|
|
const up = new tus.Upload(s.file, opts)
|
2025-09-16 23:06:40 -05:00
|
|
|
|
console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, lib, sub })
|
2025-09-16 04:32:16 -05:00
|
|
|
|
up.start()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
setStatus('All uploads complete')
|
|
|
|
|
|
setSel([])
|
2025-09-16 23:06:40 -05:00
|
|
|
|
await refresh(lib, sub)
|
2025-09-16 04:32:16 -05:00
|
|
|
|
} finally {
|
|
|
|
|
|
setUploading(false)
|
2025-09-08 00:48:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
// -------- 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
|
2025-09-16 04:32:16 -05:00
|
|
|
|
try{
|
|
|
|
|
|
await api('/api/rename', {
|
|
|
|
|
|
method:'POST',
|
|
|
|
|
|
headers:{'content-type':'application/json'},
|
2025-09-16 23:06:40 -05:00
|
|
|
|
body: JSON.stringify({ lib: lib, from: oldp, to: (oldp.split('/').slice(0,-1).concat(sanitizeFolderName(name))).join('/') })
|
2025-09-16 04:32:16 -05:00
|
|
|
|
})
|
2025-09-16 23:06:40 -05:00
|
|
|
|
await refresh(lib, sub)
|
2025-09-16 04:32:16 -05:00
|
|
|
|
} catch(e:any){
|
|
|
|
|
|
console.error('[Pegasus] rename error', e)
|
|
|
|
|
|
alert(`Rename failed:\n${e?.message || e}`)
|
|
|
|
|
|
}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
}
|
2025-09-16 23:06:40 -05:00
|
|
|
|
async function deletePath(p:string, recursive:boolean){
|
2025-09-08 00:48:47 -05:00
|
|
|
|
if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return
|
2025-09-16 04:32:16 -05:00
|
|
|
|
try{
|
2025-09-16 23:06:40 -05:00
|
|
|
|
await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive?'true':'false' })}`, { method:'DELETE' })
|
|
|
|
|
|
await refresh(lib, sub)
|
2025-09-16 04:32:16 -05:00
|
|
|
|
} catch(e:any){
|
|
|
|
|
|
console.error('[Pegasus] delete error', e)
|
|
|
|
|
|
alert(`Delete failed:\n${e?.message || e}`)
|
|
|
|
|
|
}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
}
|
2025-09-16 04:32:16 -05:00
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
2025-09-16 07:37:10 -05:00
|
|
|
|
}, [rows])
|
|
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
|
|
|
|
|
|
const videosNeedingDesc = sel.filter(s => isVideoFile(s.file) && !s.desc.trim()).length
|
2025-09-16 07:37:10 -05:00
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
const [newFolder, setNewFolder] = React.useState('')
|
2025-09-16 04:32:16 -05:00
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
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">
|
2025-09-16 07:37:10 -05:00
|
|
|
|
{mobile ? (
|
|
|
|
|
|
<>
|
2025-09-16 23:06:40 -05:00
|
|
|
|
<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>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2025-09-16 23:06:40 -05:00
|
|
|
|
<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>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
</div>
|
2025-09-16 23:06:40 -05:00
|
|
|
|
</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>
|
2025-09-16 04:32:16 -05:00
|
|
|
|
|
2025-09-08 00:48:47 -05:00
|
|
|
|
{sel.map((s,i)=>(
|
2025-09-16 23:06:40 -05:00
|
|
|
|
<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
|
2025-09-16 04:32:16 -05:00
|
|
|
|
<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))
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2025-09-16 23:06:40 -05:00
|
|
|
|
</label>
|
2025-09-08 00:48:47 -05:00
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
<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>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
|
2025-09-16 23:06:40 -05:00
|
|
|
|
<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>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
</div>
|
2025-09-16 23:06:40 -05:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
)}
|
2025-09-16 00:05:16 -05:00
|
|
|
|
</div>
|
2025-09-16 23:06:40 -05:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
</div>
|
2025-09-16 23:06:40 -05:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<details open style={{marginTop:8}}>
|
|
|
|
|
|
<summary>Subfolders</summary>
|
|
|
|
|
|
{rootDirs.length === 0 ? (
|
|
|
|
|
|
<p className="meta">No subfolders yet. You’ll 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>
|
|
|
|
|
|
))}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
</div>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
)}
|
2025-09-16 23:06:40 -05:00
|
|
|
|
</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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)
|
2025-09-08 00:48:47 -05:00
|
|
|
|
}
|
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] }
|