2025-09-16 07:37:10 -05:00
|
|
|
|
// frontend/src/Uploader.tsx
|
|
|
|
|
|
import React, { useEffect, useMemo, 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-16 07:37:10 -05:00
|
|
|
|
const [mobile, setMobile] = useState<boolean>(false)
|
|
|
|
|
|
useEffect(()=>{ setMobile(isLikelyMobile()) }, [])
|
2025-09-08 00:48:47 -05:00
|
|
|
|
|
2025-09-16 04:32:16 -05:00
|
|
|
|
// Simple bundle heartbeat so you can confirm the loaded JS is current
|
|
|
|
|
|
console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
|
|
|
|
|
|
|
2025-09-16 07:37:10 -05:00
|
|
|
|
function isLikelyMobile(): boolean {
|
|
|
|
|
|
if (typeof window === 'undefined') return false
|
|
|
|
|
|
const ua = navigator.userAgent || ''
|
|
|
|
|
|
const touch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
|
|
|
|
|
|
// catch Android/iOS/iPadOS
|
|
|
|
|
|
return touch || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 07:37:10 -05:00
|
|
|
|
// Narrow tus error
|
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 07:37:10 -05:00
|
|
|
|
// Normalize API list rows (works with old CamelCase or new snake_case)
|
|
|
|
|
|
function normalizeRows(raw: any): FileRow[] {
|
|
|
|
|
|
if (!Array.isArray(raw)) return []
|
|
|
|
|
|
return raw.map((r: any) => ({
|
|
|
|
|
|
name: r?.name ?? r?.Name ?? '',
|
|
|
|
|
|
path: r?.path ?? r?.Path ?? '',
|
|
|
|
|
|
is_dir: typeof r?.is_dir === 'boolean' ? r.is_dir
|
|
|
|
|
|
: typeof r?.IsDir === 'boolean' ? r.IsDir
|
|
|
|
|
|
: false,
|
|
|
|
|
|
size: typeof r?.size === 'number' ? r.size : (typeof r?.Size === 'number' ? r.Size : 0),
|
|
|
|
|
|
mtime: typeof r?.mtime === 'number' ? r.mtime : (typeof r?.Mtime === 'number' ? r.Mtime : 0),
|
|
|
|
|
|
})).filter(r => r && typeof r.name === 'string' && typeof r.path === 'string')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// simple mobile detection (no CSS change needed)
|
|
|
|
|
|
function useIsMobile() {
|
|
|
|
|
|
const [isMobile, setIsMobile] = useState(false)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const mq = window.matchMedia('(max-width: 640px)')
|
|
|
|
|
|
const handler = (e: MediaQueryListEvent | MediaQueryList) =>
|
|
|
|
|
|
setIsMobile(('matches' in e ? e.matches : (e as MediaQueryList).matches))
|
|
|
|
|
|
handler(mq)
|
|
|
|
|
|
mq.addEventListener('change', handler as any)
|
|
|
|
|
|
return () => mq.removeEventListener('change', handler as any)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
return isMobile
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-08 00:48:47 -05:00
|
|
|
|
export default function Uploader(){
|
|
|
|
|
|
const [me, setMe] = useState<WhoAmI|undefined>()
|
2025-09-16 07:37:10 -05:00
|
|
|
|
const [libraries, setLibraries] = useState<string[]>([]) // from whoami.roots or [whoami.root]
|
|
|
|
|
|
const [selectedLib, setSelectedLib] = useState<string>('') // must be chosen to upload
|
|
|
|
|
|
|
|
|
|
|
|
const [subdir, setSubdir] = useState<string>('') // one-level subfolder name (no '/')
|
2025-09-16 04:32:16 -05:00
|
|
|
|
const [rows, setRows] = useState<FileRow[]>([])
|
2025-09-16 07:37:10 -05:00
|
|
|
|
const destPath = `/${[selectedLib, subdir].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
|
2025-09-16 07:37:10 -05:00
|
|
|
|
const isMobile = useIsMobile()
|
2025-09-16 00:05:16 -05:00
|
|
|
|
|
2025-09-16 04:32:16 -05:00
|
|
|
|
// Enable directory selection on the folder picker (non-standard attr)
|
2025-09-16 00:05:16 -05:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const el = folderInputRef.current
|
2025-09-16 07:37:10 -05:00
|
|
|
|
if (!el) return
|
|
|
|
|
|
if (mobile) {
|
|
|
|
|
|
// ensure we don't accidentally force folder mode on 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 07:37:10 -05:00
|
|
|
|
// Fetch whoami + list
|
2025-09-16 00:05:16 -05:00
|
|
|
|
async function refresh(path=''){
|
|
|
|
|
|
try {
|
2025-09-16 07:37:10 -05:00
|
|
|
|
const m = await api<any>('/api/whoami')
|
|
|
|
|
|
setMe(m as WhoAmI)
|
|
|
|
|
|
|
|
|
|
|
|
const libs: string[] =
|
|
|
|
|
|
Array.isArray(m?.roots) && m.roots.length ? m.roots.slice()
|
|
|
|
|
|
: m?.root ? [m.root] : []
|
|
|
|
|
|
|
|
|
|
|
|
setLibraries(libs)
|
|
|
|
|
|
// Auto-select if exactly one library
|
|
|
|
|
|
setSelectedLib(prev => prev || (libs.length === 1 ? libs[0] : ''))
|
|
|
|
|
|
|
|
|
|
|
|
const listRaw = await api<any>('/api/list?path='+encodeURIComponent(path))
|
|
|
|
|
|
const list = normalizeRows(listRaw)
|
|
|
|
|
|
setRows(list); setSubdir(path)
|
|
|
|
|
|
setStatus(`Ready · Destination: /${[libs[0] ?? '', path].filter(Boolean).join('/')}`)
|
2025-09-16 04:32:16 -05:00
|
|
|
|
console.log('[Pegasus] list ok', { path, count: list.length })
|
|
|
|
|
|
} catch (e:any) {
|
|
|
|
|
|
const msg = String(e?.message || e || '')
|
|
|
|
|
|
console.error('[Pegasus] list error', e)
|
|
|
|
|
|
setStatus(`List 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('/') // back to login
|
|
|
|
|
|
}
|
2025-09-16 00:05:16 -05:00
|
|
|
|
}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
}
|
2025-09-16 04:32:16 -05:00
|
|
|
|
useEffect(()=>{ setStatus('Loading profile & folder list…'); refresh('') }, [])
|
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 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
|
|
|
|
})
|
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 07:37:10 -05:00
|
|
|
|
// When the global date changes, recompute per-file finalName
|
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])
|
|
|
|
|
|
|
2025-09-16 04:32:16 -05:00
|
|
|
|
// Warn before closing mid-upload
|
2025-09-16 00:05:16 -05:00
|
|
|
|
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(){
|
2025-09-16 04:32:16 -05:00
|
|
|
|
if(!me) { setStatus('Not signed in'); return }
|
2025-09-16 07:37:10 -05:00
|
|
|
|
if(!selectedLib){ alert('Please select a Library to upload to.'); return }
|
2025-09-08 00:48:47 -05:00
|
|
|
|
if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); 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 07:37:10 -05:00
|
|
|
|
|
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],
|
2025-09-16 07:37:10 -05:00
|
|
|
|
// Avoid resuming old HTTP URLs: don't store fingerprints, and use a random fingerprint so nothing matches
|
|
|
|
|
|
storeFingerprintForResuming: false,
|
|
|
|
|
|
removeFingerprintOnSuccess: true,
|
|
|
|
|
|
fingerprint: (async () => `${Date.now()}-${Math.random().toString(36).slice(2)}-${s.file.name}`) as any,
|
|
|
|
|
|
|
2025-09-16 04:32:16 -05:00
|
|
|
|
metadata: {
|
|
|
|
|
|
filename: s.file.name,
|
2025-09-16 07:37:10 -05:00
|
|
|
|
// Server treats this as a subdirectory under the user's mapped root.
|
|
|
|
|
|
// When backend supports multiple libraries, it will also need the selected library.
|
|
|
|
|
|
subdir: subdir || "",
|
2025-09-16 04:32: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()
|
|
|
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
// Ensure cookie is sent even if the tus typings don’t list this field
|
|
|
|
|
|
opts.withCredentials = true
|
|
|
|
|
|
|
|
|
|
|
|
const up = new tus.Upload(s.file, opts)
|
2025-09-16 07:37:10 -05:00
|
|
|
|
console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, subdir })
|
2025-09-16 04:32:16 -05:00
|
|
|
|
up.start()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
setStatus('All uploads complete')
|
|
|
|
|
|
setSel([])
|
2025-09-16 07:37:10 -05:00
|
|
|
|
await refresh(subdir)
|
2025-09-16 04:32:16 -05:00
|
|
|
|
} finally {
|
|
|
|
|
|
setUploading(false)
|
2025-09-08 00:48:47 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function rename(oldp:string){
|
2025-09-16 07:37:10 -05:00
|
|
|
|
const name = prompt('New name (YYYY.MM.DD.description.ext for files, or folder name):', oldp.split('/').pop()||''); if(!name) return
|
2025-09-16 04:32:16 -05:00
|
|
|
|
try{
|
|
|
|
|
|
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('/')})
|
|
|
|
|
|
})
|
2025-09-16 07:37:10 -05:00
|
|
|
|
await refresh(subdir)
|
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 04:32:16 -05:00
|
|
|
|
|
2025-09-08 00:48:47 -05:00
|
|
|
|
async function del(p:string, recursive:boolean){
|
|
|
|
|
|
if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return
|
2025-09-16 04:32:16 -05:00
|
|
|
|
try{
|
|
|
|
|
|
await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' })
|
2025-09-16 07:37:10 -05:00
|
|
|
|
await refresh(subdir)
|
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 00:05:16 -05:00
|
|
|
|
function goUp(){
|
2025-09-16 07:37:10 -05:00
|
|
|
|
setSubdir(''); void refresh('')
|
2025-09-16 00:05:16 -05:00
|
|
|
|
}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
|
2025-09-16 07:37:10 -05:00
|
|
|
|
// Precompute a safely sorted copy for rendering (no in-place mutation)
|
|
|
|
|
|
const rowsSorted = useMemo(() => {
|
|
|
|
|
|
const copy = Array.isArray(rows) ? [...rows] : []
|
|
|
|
|
|
return copy
|
|
|
|
|
|
.filter(r => r && typeof r.name === 'string')
|
|
|
|
|
|
.sort((a, b) => (Number(b.is_dir) - Number(a.is_dir)) || a.name.localeCompare(b.name))
|
|
|
|
|
|
}, [rows])
|
|
|
|
|
|
|
|
|
|
|
|
// Restrict to one-level: only allow "Open" when we're at library root (subdir === '')
|
|
|
|
|
|
const canOpenDeeper = subdir === ''
|
|
|
|
|
|
|
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'}}>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
<div><b>Signed in:</b> {me?.username}</div>
|
|
|
|
|
|
<div className="meta">Destination: <b>{destPath || '/(select a library)'}</b></div>
|
2025-09-08 00:48:47 -05:00
|
|
|
|
</div>
|
2025-09-16 04:32:16 -05:00
|
|
|
|
|
2025-09-16 07:37:10 -05:00
|
|
|
|
{/* Destination (Library + Subfolder) */}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
<div className="grid" style={{alignItems:'end'}}>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="meta">Library</label>
|
|
|
|
|
|
{libraries.length <= 1 ? (
|
|
|
|
|
|
<div className="item" style={{padding:10}}>
|
|
|
|
|
|
<span>{libraries[0] || '(none)'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={selectedLib}
|
|
|
|
|
|
onChange={e=> setSelectedLib(e.target.value)}
|
|
|
|
|
|
style={{border:'1px solid #2a2f45', background:'#1c2138', color:'var(--fg)', borderRadius:8, padding:10, width:'100%'}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="" disabled>Choose a library…</option>
|
|
|
|
|
|
{libraries.map(lib => <option key={lib} value={lib}>{lib}</option>)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-09-08 00:48:47 -05:00
|
|
|
|
<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 04:32:16 -05:00
|
|
|
|
|
2025-09-16 07:37:10 -05:00
|
|
|
|
{/* Pickers: mobile vs desktop */}
|
2025-09-16 00:05:16 -05:00
|
|
|
|
<div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
{mobile ? (
|
|
|
|
|
|
// ---- Mobile: show a Gallery picker (no capture) + optional Camera quick-capture
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="meta">Gallery/Photos</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
// no "capture" -> browsers tend to open Photo/Media picker instead of camera or filesystem
|
|
|
|
|
|
accept="image/*,video/*"
|
|
|
|
|
|
onChange={e => e.target.files && handleChoose(e.target.files)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="meta">Camera (optional)</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
// keeping this separate provides a clear way to open the camera immediately if desired
|
|
|
|
|
|
accept="image/*,video/*"
|
|
|
|
|
|
capture="environment"
|
|
|
|
|
|
onChange={e => e.target.files && handleChoose(e.target.files)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
// ---- Desktop: show both Files and Folder(s) pickers
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="meta">Select file(s)</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
accept="image/*,video/*"
|
|
|
|
|
|
onChange={e => e.target.files && handleChoose(e.target.files)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="meta">Select folder(s)</label>
|
|
|
|
|
|
{/* set webkitdirectory/directory via ref (done in useEffect) */}
|
|
|
|
|
|
<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 04:32:16 -05:00
|
|
|
|
|
|
|
|
|
|
{(() => {
|
2025-09-16 07:37:10 -05:00
|
|
|
|
const uploadDisabled =
|
|
|
|
|
|
!selectedLib || !sel.length || uploading || sel.some(s=>!s.desc.trim())
|
2025-09-16 04:32:16 -05:00
|
|
|
|
let disabledReason = ''
|
2025-09-16 07:37:10 -05:00
|
|
|
|
if (!selectedLib) disabledReason = 'Pick a library.'
|
|
|
|
|
|
else if (!sel.length) disabledReason = 'Select at least one file.'
|
2025-09-16 04:32:16 -05:00
|
|
|
|
else if (uploading) disabledReason = 'Upload in progress…'
|
|
|
|
|
|
else if (sel.some(s=>!s.desc.trim())) disabledReason = 'Add a short description for every file.'
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn"
|
|
|
|
|
|
onClick={()=>{
|
|
|
|
|
|
console.log('[Pegasus] Upload click', { files: sel.length, uploading, disabled: uploadDisabled })
|
|
|
|
|
|
setStatus('Upload button clicked')
|
|
|
|
|
|
if (!uploadDisabled) { void doUpload() }
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={uploadDisabled}
|
|
|
|
|
|
aria-disabled={uploadDisabled}
|
|
|
|
|
|
>
|
|
|
|
|
|
Upload {sel.length? `(${sel.length})` : ''}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{disabledReason && <div className="meta bad">{disabledReason}</div>}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)
|
|
|
|
|
|
})()}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
</div>
|
2025-09-16 04:32:16 -05:00
|
|
|
|
|
2025-09-16 07:37:10 -05:00
|
|
|
|
{/* Per-file editor */}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
{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 04:32: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-16 00:05:16 -05:00
|
|
|
|
<div style={{display:'grid', gridTemplateColumns:'auto 1fr', gap:8, alignItems:'center', marginTop:6}}>
|
|
|
|
|
|
<span className="meta">Date</span>
|
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 00:05:16 -05:00
|
|
|
|
</div>
|
2025-09-08 00:48:47 -05:00
|
|
|
|
<div className="meta">→ {s.finalName}</div>
|
2025-09-16 04:32: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>
|
|
|
|
|
|
)}
|
2025-09-16 04:32:16 -05:00
|
|
|
|
|
2025-09-08 00:48:47 -05:00
|
|
|
|
<div className="meta" style={{marginTop:8}}>{status}</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2025-09-16 07:37:10 -05:00
|
|
|
|
{/* Destination details and subfolder management */}
|
2025-09-08 00:48:47 -05:00
|
|
|
|
<section className="card">
|
2025-09-16 07:37:10 -05:00
|
|
|
|
<h3>Destination</h3>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Current subfolder header & actions (one level only) */}
|
|
|
|
|
|
<div className="item" style={{display:'flex', gap:12, alignItems:'center', justifyContent:'space-between', flexWrap:'wrap'}}>
|
|
|
|
|
|
<div className="meta">
|
|
|
|
|
|
Library: <b>{selectedLib || '(none)'}</b>
|
|
|
|
|
|
{' · '}
|
|
|
|
|
|
Subfolder: <b>{subdir ? subdir : '(root)'}</b>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meta">
|
|
|
|
|
|
{subdir && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<a href="#" onClick={(e)=>{e.preventDefault(); void rename(subdir)}}>Rename subfolder</a>
|
|
|
|
|
|
{' · '}
|
|
|
|
|
|
<a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(subdir, true)}}>Delete subfolder</a>
|
|
|
|
|
|
{' · '}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{subdir && <a href="#" onClick={(e)=>{e.preventDefault(); goUp()}}>⬆ Up to library root</a>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Create subfolder (root only) */}
|
|
|
|
|
|
{!subdir && (
|
|
|
|
|
|
<CreateFolder
|
|
|
|
|
|
cwd={''}
|
|
|
|
|
|
onCreate={(p)=>{ setSubdir(p); void refresh(p) }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Listing */}
|
|
|
|
|
|
{rowsSorted.length === 0 ? (
|
2025-09-16 00:05:16 -05:00
|
|
|
|
<div className="item">
|
|
|
|
|
|
<div className="meta">
|
2025-09-16 07:37:10 -05:00
|
|
|
|
No items to show here. You’ll upload into <b>{destPath || '/(select a library)'}</b>.
|
2025-09-16 00:05:16 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2025-09-16 07:37:10 -05:00
|
|
|
|
<div className="grid">
|
|
|
|
|
|
{/* Only show "Open" when at library root to enforce one-level depth */}
|
|
|
|
|
|
{rowsSorted.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 ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{canOpenDeeper
|
|
|
|
|
|
? <a href="#" onClick={(e)=>{e.preventDefault(); setSubdir(f.path); void refresh(f.path)}}>Open</a>
|
|
|
|
|
|
: <span className="meta">Open (disabled)</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
{' · '}<a href="#" onClick={(e)=>{e.preventDefault(); void rename(f.path)}}>Rename</a>
|
|
|
|
|
|
{' · '}<a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(f.path,true)}}>Delete</a>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<a href="#" onClick={(e)=>{e.preventDefault(); void rename(f.path)}}>Rename</a>
|
|
|
|
|
|
{' · '}<a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(f.path,false)}}>Delete</a>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-09-08 00:48:47 -05:00
|
|
|
|
</div>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
)}
|
|
|
|
|
|
</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() {
|
2025-09-16 07:37:10 -05:00
|
|
|
|
// single-level only
|
|
|
|
|
|
let clean = name.trim().replace(/[\/]+/g,'').replace(/[^\w\-\s.]/g,'_')
|
|
|
|
|
|
clean = clean.replace(/\s+/g,'_')
|
2025-09-16 00:05:16 -05:00
|
|
|
|
if (!clean) return;
|
|
|
|
|
|
const path = [cwd, clean].filter(Boolean).join('/');
|
2025-09-16 04:32:16 -05:00
|
|
|
|
console.log('[Pegasus] mkdir click', { path })
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) });
|
|
|
|
|
|
onCreate(path);
|
|
|
|
|
|
setName('');
|
|
|
|
|
|
} catch (e:any) {
|
|
|
|
|
|
console.error('[Pegasus] mkdir error', e)
|
|
|
|
|
|
alert(`Create folder failed:\n${e?.message || e}`)
|
|
|
|
|
|
}
|
2025-09-16 00:05:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{marginTop:10, display:'flex', gap:8, alignItems:'center'}}>
|
2025-09-16 07:37:10 -05:00
|
|
|
|
<input placeholder="New subfolder name (one-level)" value={name} onChange={e=>setName(e.target.value)} />
|
2025-09-16 04:32:16 -05:00
|
|
|
|
<button type="button" className="btn" onClick={submit} disabled={!name.trim()}>Create</button>
|
2025-09-16 00:05:16 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|