import React from 'react' import * as tus from 'tus-js-client' import { api, WhoAmI } from './api' import uploaderUtils from './uploader-utils' const { clampOneLevel, composeName, createNoResumeFingerprint, createNoResumeStorage, isDetailedError, isVideoFile, normalizeRows, sanitizeDesc, sanitizeFolderName, } = uploaderUtils 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 } type ControllerState = { mobile: boolean me: WhoAmI | undefined setMe: React.Dispatch> libs: string[] lib: string sub: string rootDirs: string[] rows: FileRow[] status: string globalDate: string uploading: boolean sel: Sel[] bulkDesc: string folderInputRef: React.RefObject newFolderRaw: string setGlobalDate: React.Dispatch> setLib: React.Dispatch> setSub: React.Dispatch> setNewFolderRaw: React.Dispatch> setBulkDesc: React.Dispatch> setSel: React.Dispatch> handleChoose: (files: FileList) => void applyDescToAllVideos: () => void doUpload: () => Promise createSubfolder: (nameRaw: string) => Promise renameFolder: (oldName: string) => Promise deleteFolder: (name: string) => Promise renamePath: (oldp: string) => Promise deletePath: (p: string, recursive: boolean) => Promise refresh: (currLib: string, currSub: string) => Promise sortedRows: FileRow[] existingNames: Set duplicateNamesInSelection: Set hasNameIssues: boolean destPath: string videosNeedingDesc: number } const NoResumeUrlStorage = createNoResumeStorage() function useIsMobile(): boolean { const [mobile, setMobile] = React.useState(false) React.useEffect(() => { setMobile(uploaderUtils.isLikelyMobileUA()) }, []) return mobile } export default function useUploaderController(): ControllerState { const mobile = useIsMobile() const [me, setMe] = React.useState() const [libs, setLibs] = React.useState([]) const [lib, setLib] = React.useState('') const [sub, setSub] = React.useState('') const [rootDirs, setRootDirs] = React.useState([]) const [rows, setRows] = React.useState([]) const [status, setStatus] = React.useState('') const [globalDate, setGlobalDate] = React.useState(new Date().toISOString().slice(0, 10)) const [uploading, setUploading] = React.useState(false) const [sel, setSel] = React.useState([]) const [bulkDesc, setBulkDesc] = React.useState('') const folderInputRef = React.useRef(null) const [newFolderRaw, setNewFolderRaw] = React.useState('') 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]) React.useEffect(() => { ;(async () => { try { setStatus('Loading profile…') const m = await api('/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) setLib('') setSub('') setStatus('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 { // Best-effort logout cleanup only. } location.replace('/') } } })() }, []) React.useEffect(() => { ;(async () => { if (!lib) { setRootDirs([]) setRows([]) setSub('') 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]) React.useEffect(() => { const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault() e.returnValue = '' } } window.addEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler) }, [uploading]) async function refresh(currLib: string, currSub: string) { if (!currLib) return const one = clampOneLevel(currSub) const listRoot = normalizeRows(await api(`/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(`/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 = '' 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) } React.useEffect(() => { setSel((old) => old.map((x) => ({ ...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name) }))) }, [globalDate]) 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 } 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((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, }, 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 = createNoResumeFingerprint 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) } } 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) 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}`) } } async function renamePath(oldp: string) { const base = oldp.split('/').pop() || '' const name = prompt('New name (YYYY.MM.DD.Description.OrigStem.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}`) } } 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 existingNames = React.useMemo(() => new Set(sortedRows.filter((r) => !r.is_dir).map((r) => r.name)), [sortedRows]) const duplicateNamesInSelection = React.useMemo(() => { const counts = new Map() sel.forEach((s) => counts.set(s.finalName, (counts.get(s.finalName) || 0) + 1)) return new Set(Array.from(counts.entries()).filter(([, c]) => c > 1).map(([n]) => n)) }, [sel]) const hasNameIssues = React.useMemo( () => sel.some((s) => duplicateNamesInSelection.has(s.finalName) || existingNames.has(s.finalName)), [sel, duplicateNamesInSelection, existingNames] ) const destPath = `/${[lib, sub].filter(Boolean).join('/')}` const videosNeedingDesc = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length return { mobile, me, setMe, libs, lib, sub, rootDirs, rows, status, globalDate, uploading, sel, bulkDesc, folderInputRef, newFolderRaw, setGlobalDate, setLib, setSub, setNewFolderRaw, setBulkDesc, setSel, handleChoose, applyDescToAllVideos, doUpload, createSubfolder, renameFolder, deleteFolder, renamePath, deletePath, refresh, sortedRows, existingNames, duplicateNamesInSelection, hasNameIssues, destPath, videosNeedingDesc, } }