427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
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<React.SetStateAction<WhoAmI | undefined>>
|
|
libs: string[]
|
|
lib: string
|
|
sub: string
|
|
rootDirs: string[]
|
|
rows: FileRow[]
|
|
status: string
|
|
globalDate: string
|
|
uploading: boolean
|
|
sel: Sel[]
|
|
bulkDesc: string
|
|
folderInputRef: React.RefObject<HTMLInputElement>
|
|
newFolderRaw: string
|
|
setGlobalDate: React.Dispatch<React.SetStateAction<string>>
|
|
setLib: React.Dispatch<React.SetStateAction<string>>
|
|
setSub: React.Dispatch<React.SetStateAction<string>>
|
|
setNewFolderRaw: React.Dispatch<React.SetStateAction<string>>
|
|
setBulkDesc: React.Dispatch<React.SetStateAction<string>>
|
|
setSel: React.Dispatch<React.SetStateAction<Sel[]>>
|
|
handleChoose: (files: FileList) => void
|
|
applyDescToAllVideos: () => void
|
|
doUpload: () => Promise<void>
|
|
createSubfolder: (nameRaw: string) => Promise<void>
|
|
renameFolder: (oldName: string) => Promise<void>
|
|
deleteFolder: (name: string) => Promise<void>
|
|
renamePath: (oldp: string) => Promise<void>
|
|
deletePath: (p: string, recursive: boolean) => Promise<void>
|
|
refresh: (currLib: string, currSub: string) => Promise<void>
|
|
sortedRows: FileRow[]
|
|
existingNames: Set<string>
|
|
duplicateNamesInSelection: Set<string>
|
|
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<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>('')
|
|
const folderInputRef = React.useRef<HTMLInputElement>(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<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)
|
|
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.
|
|
}
|
|
try {
|
|
location.replace('/')
|
|
} catch {
|
|
// JSDOM can block navigation APIs; browsers still redirect normally.
|
|
}
|
|
}
|
|
}
|
|
})()
|
|
}, [])
|
|
|
|
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<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 = ''
|
|
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<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,
|
|
},
|
|
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<string, number>()
|
|
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,
|
|
}
|
|
}
|