pegasus/frontend/src/Uploader.tsx

759 lines
28 KiB
TypeScript
Raw Normal View History

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-17 07:44:29 -05:00
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)
2025-09-08 00:48:47 -05:00
}
2025-09-17 07:44:29 -05:00
function sanitizeFolderName(s: string) {
2025-09-16 23:06:40 -05:00
// 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-]
2025-09-17 07:44:29 -05:00
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '')
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_')
return s.slice(0, 64)
2025-09-16 23:06:40 -05:00
}
2025-09-17 07:44:29 -05:00
function extOf(n: string) {
const i = n.lastIndexOf('.')
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
2025-09-08 00:48:47 -05:00
}
2025-09-17 07:44:29 -05:00
function stemOf(n: string) {
const i = n.lastIndexOf('.')
const stem = i > -1 ? n.slice(0, i) : n
// keep safe charset and avoid extra dots within segments
return sanitizeDesc(stem.replace(/\./g, '_')) || 'file'
}
function composeName(date: string, desc: string, orig: string) {
const d = date || new Date().toISOString().slice(0, 10)
const [Y, M, D] = d.split('-')
const sDesc = sanitizeDesc(desc)
const sStem = stemOf(orig)
const ext = extOf(orig) || 'bin'
// New pattern: YYYY.MM.DD.Description.OriginalStem.ext
return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}`
}
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 ?? '',
2025-09-16 23:06:40 -05:00
is_dir: Boolean(r?.is_dir ?? r?.IsDir ?? r?.isDir ?? false),
2025-09-17 07:44:29 -05:00
size: Number(r?.size ?? r?.Size ?? 0),
2025-09-16 23:06:40 -05:00
mtime: Number(r?.mtime ?? r?.Mtime ?? 0),
}))
}
2025-09-17 07:44:29 -05:00
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)
2025-09-17 07:44:29 -05:00
React.useEffect(() => {
setMobile(isLikelyMobileUA())
}, [])
2025-09-16 23:06:40 -05:00
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 () => [],
2025-09-17 07:44:29 -05:00
findUploadsByFingerprint: async (_fp: string) => [],
2025-09-16 23:06:40 -05:00
}
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>()
2025-09-17 07:44:29 -05:00
React.useEffect(() => {
const u = URL.createObjectURL(file)
setUrl(u)
return () => {
try {
URL.revokeObjectURL(u)
} catch {}
}
2025-09-16 23:06:40 -05:00
}, [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" />
2025-09-17 07:44:29 -05:00
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-17 07:44:29 -05:00
export default function Uploader() {
2025-09-16 23:06:40 -05:00
const mobile = useIsMobile()
2025-09-17 07:44:29 -05:00
const [me, setMe] = React.useState<WhoAmI | undefined>()
2025-09-16 23:06:40 -05:00
const [libs, setLibs] = React.useState<string[]>([])
2025-09-17 07:44:29 -05:00
const [lib, setLib] = React.useState<string>('') // required to upload
2025-09-16 23:06:40 -05:00
const [sub, setSub] = React.useState<string>('')
const [rootDirs, setRootDirs] = React.useState<string[]>([])
const [rows, setRows] = React.useState<FileRow[]>([])
const [status, setStatus] = React.useState<string>('')
2025-09-17 07:44:29 -05:00
const [globalDate, setGlobalDate] = React.useState<string>(new Date().toISOString().slice(0, 10))
2025-09-16 23:06:40 -05:00
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)
2025-09-17 07:44:29 -05:00
// keep raw input for folder name (let user type anything; sanitize on create)
const [newFolderRaw, setNewFolderRaw] = React.useState('')
2025-09-16 23:06:40 -05:00
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-17 07:44:29 -05:00
el.setAttribute('webkitdirectory', '')
el.setAttribute('directory', '')
2025-09-16 00:05:16 -05:00
}
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
2025-09-17 07:44:29 -05:00
React.useEffect(() => {
;(async () => {
try {
2025-09-16 23:06:40 -05:00
setStatus('Loading profile…')
2025-09-17 07:44:29 -05:00
const m = await api<WhoAmI>('/api/whoami')
setMe(m as any)
const mm: any = m
2025-09-16 23:06:40 -05:00
const L: string[] =
2025-09-17 07:44:29 -05:00
Array.isArray(mm?.roots) ? mm.roots : Array.isArray(mm?.libs) ? mm.libs : typeof mm?.root === 'string' && mm.root ? [mm.root] : []
2025-09-16 23:06:40 -05:00
setLibs(L)
2025-09-17 07:44:29 -05:00
setLib('') // do NOT auto-pick; user will choose
2025-09-16 23:06:40 -05:00
setSub('')
2025-09-17 07:44:29 -05:00
setStatus('Choose a library to start')
} catch (e: any) {
2025-09-16 23:06:40 -05:00
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.')
2025-09-17 07:44:29 -05:00
try {
await api('/api/logout', { method: 'POST' })
} catch {}
2025-09-16 23:06:40 -05:00
location.replace('/')
}
2025-09-16 04:32:16 -05:00
}
2025-09-16 23:06:40 -05:00
})()
}, [])
2025-09-17 07:44:29 -05:00
React.useEffect(() => {
;(async () => {
if (!lib) {
setRootDirs([])
setRows([])
setSub('')
return
}
2025-09-16 23:06:40 -05:00
try {
setStatus(`Loading library “${lib}”…`)
await refresh(lib, sub)
2025-09-17 07:44:29 -05:00
} catch (e: any) {
2025-09-16 23:06:40 -05:00
console.error('[Pegasus] refresh error', e)
setStatus(`List error: ${e?.message || e}`)
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lib])
2025-09-17 07:44:29 -05:00
async function refresh(currLib: string, currSub: string) {
if (!currLib) return
2025-09-16 23:06:40 -05:00
const one = clampOneLevel(currSub)
const listRoot = normalizeRows(await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path: '' })}`))
2025-09-17 07:44:29 -05:00
const rootDirNames = listRoot
.filter((e) => e.is_dir)
.map((e) => e.name)
.filter(Boolean)
setRootDirs(rootDirNames.sort((a, b) => a.localeCompare(b)))
2025-09-16 23:06:40 -05:00
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
}
2025-09-17 07:44:29 -05:00
function handleChoose(files: FileList) {
const arr = Array.from(files).map((f) => {
2025-09-08 00:48:47 -05:00
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-17 07:44:29 -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-17 07:44:29 -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
2025-09-17 07:44:29 -05:00
React.useEffect(() => {
setSel((old) => old.map((x) => ({ ...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name) })))
2025-09-16 00:05:16 -05:00
}, [globalDate])
2025-09-16 04:32:16 -05:00
// Warn before closing mid-upload
2025-09-17 07:44:29 -05:00
React.useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (uploading) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
2025-09-16 00:05:16 -05:00
}, [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
2025-09-17 07:44:29 -05:00
setSel((old) =>
old.map((x) => (isVideoFile(x.file) ? { ...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name) } : x))
)
2025-09-16 23:06:40 -05:00
}
2025-09-17 07:44:29 -05:00
async function doUpload() {
if (!me) {
setStatus('Not signed in')
return
}
if (!lib) {
alert('Please select a Library to upload into.')
return
}
2025-09-16 23:06:40 -05:00
// Require description only for videos:
2025-09-17 07:44:29 -05:00
const missingVideos = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
2025-09-16 23:06:40 -05:00
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-17 07:44:29 -05:00
try {
for (const s of sel) {
2025-09-16 04:32:16 -05:00
// eslint-disable-next-line no-await-in-loop
2025-09-17 07:44:29 -05:00
await new Promise<void>((resolve, reject) => {
2025-09-16 04:32:16 -05:00
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
endpoint: '/tus/',
2025-09-17 07:44:29 -05:00
chunkSize: 5 * 1024 * 1024,
2025-09-16 04:32:16 -05:00
retryDelays: [0, 1000, 3000, 5000, 10000],
metadata: {
filename: s.file.name,
2025-09-16 23:06:40 -05:00
lib: lib,
2025-09-17 07:44:29 -05:00
subdir: sub || '',
2025-09-16 23:06:40 -05:00
date: s.date,
2025-09-17 07:44:29 -05:00
desc: s.desc, // server enforces: required for videos only
2025-09-16 04:32:16 -05:00
},
2025-09-17 07:44:29 -05:00
onError: (err: Error | tus.DetailedError) => {
2025-09-16 04:32:16 -05:00
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)
2025-09-17 07:44:29 -05:00
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, err: msg } : x)))
2025-09-16 04:32:16 -05:00
setStatus(`${s.file.name}: ${msg}`)
alert(`Upload failed: ${s.file.name}\n\n${msg}`)
reject(err as any)
},
2025-09-17 07:44:29 -05:00
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)))
2025-09-16 04:32:16 -05:00
setStatus(`${s.finalName}: ${pct}%`)
},
2025-09-17 07:44:29 -05:00
onSuccess: () => {
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: 100, err: undefined } : x)))
2025-09-16 04:32:16 -05:00
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 --------
2025-09-17 07:44:29 -05:00
async function createSubfolder(nameRaw: string) {
2025-09-16 23:06:40 -05:00
const name = sanitizeFolderName(nameRaw)
if (!name) return
2025-09-17 07:44:29 -05:00
try {
2025-09-16 23:06:40 -05:00
await api(`/api/mkdir`, {
2025-09-17 07:44:29 -05:00
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, path: name }),
2025-09-16 23:06:40 -05:00
})
await refresh(lib, name) // jump into new folder
setSub(name)
2025-09-17 07:44:29 -05:00
} catch (e: any) {
2025-09-16 23:06:40 -05:00
console.error('[Pegasus] mkdir error', e)
alert(`Create folder failed:\n${e?.message || e}`)
}
}
2025-09-17 07:44:29 -05:00
async function renameFolder(oldName: string) {
2025-09-16 23:06:40 -05:00
const nn = prompt('New folder name:', oldName)
const newName = sanitizeFolderName(nn || '')
if (!newName || newName === oldName) return
2025-09-17 07:44:29 -05:00
try {
2025-09-16 23:06:40 -05:00
await api('/api/rename', {
2025-09-17 07:44:29 -05:00
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, from: oldName, to: newName }),
2025-09-16 23:06:40 -05:00
})
2025-09-17 07:44:29 -05:00
const newSub = sub === oldName ? newName : sub
2025-09-16 23:06:40 -05:00
setSub(newSub)
await refresh(lib, newSub)
2025-09-17 07:44:29 -05:00
} catch (e: any) {
2025-09-16 23:06:40 -05:00
console.error('[Pegasus] rename folder error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
2025-09-17 07:44:29 -05:00
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
2025-09-16 23:06:40 -05:00
setSub(newSub)
await refresh(lib, newSub)
2025-09-17 07:44:29 -05:00
} catch (e: any) {
2025-09-16 23:06:40 -05:00
console.error('[Pegasus] delete folder error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
// destination listing (actions: rename files only at library root)
2025-09-17 07:44:29 -05:00
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 {
2025-09-16 04:32:16 -05:00
await api('/api/rename', {
2025-09-17 07:44:29 -05:00
method: 'POST',
headers: { 'content-type': 'application/json' },
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-17 07:44:29 -05:00
} catch (e: any) {
2025-09-16 04:32:16 -05:00
console.error('[Pegasus] rename error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
2025-09-08 00:48:47 -05:00
}
2025-09-17 07:44:29 -05:00
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',
})
2025-09-16 23:06:40 -05:00
await refresh(lib, sub)
2025-09-17 07:44:29 -05:00
} catch (e: any) {
2025-09-16 04:32:16 -05:00
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
2025-09-17 07:44:29 -05:00
const sortedRows = React.useMemo(() => {
2025-09-16 23:06:40 -05:00
const arr = Array.isArray(rows) ? rows.slice() : []
2025-09-17 07:44:29 -05:00
return arr.sort((a, b) => {
const dirFirst = Number(b?.is_dir ? 1 : 0) - Number(a?.is_dir ? 1 : 0)
2025-09-16 23:06:40 -05:00
if (dirFirst !== 0) return dirFirst
2025-09-17 07:44:29 -05:00
const an = a?.name ?? ''
const bn = b?.name ?? ''
2025-09-16 23:06:40 -05:00
return an.localeCompare(bn)
})
2025-09-16 07:37:10 -05:00
}, [rows])
2025-09-17 07:44:29 -05:00
// Existing filenames in the destination (to block collisions)
const existingNames = React.useMemo(() => new Set(sortedRows.filter((r) => !r.is_dir).map((r) => r.name)), [sortedRows])
2025-09-16 07:37:10 -05:00
2025-09-17 07:44:29 -05:00
// Duplicates inside this batch
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
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>
2025-09-17 07:44:29 -05:00
<p className="meta">
Destination: <strong>{destPath || '/(choose a library)'}</strong>
</p>
2025-09-16 23:06:40 -05:00
</hgroup>
</section>
2025-09-17 07:44:29 -05:00
{/* Choose content */}
2025-09-16 23:06:40 -05:00
<section>
2025-09-17 07:44:29 -05:00
<h3>Choose content</h3>
2025-09-16 23:06:40 -05:00
<div className="grid-3">
<label>
2025-09-17 07:44:29 -05:00
Default date
<input type="date" value={globalDate} onChange={(e) => setGlobalDate(e.target.value)} />
2025-09-16 23:06:40 -05:00
</label>
</div>
2025-09-17 07:44:29 -05:00
2025-09-16 23:06:40 -05:00
<div className="file-picker">
2025-09-16 07:37:10 -05:00
{mobile ? (
<>
2025-09-16 23:06:40 -05:00
<label>
Gallery/Photos
2025-09-17 07:44:29 -05:00
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
2025-09-16 23:06:40 -05:00
</label>
<label>
Camera (optional)
2025-09-17 07:44:29 -05:00
<input type="file" accept="image/*,video/*" capture="environment" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
2025-09-16 23:06:40 -05:00
</label>
2025-09-16 07:37:10 -05:00
</>
) : (
<>
2025-09-16 23:06:40 -05:00
<label>
Select file(s)
2025-09-17 07:44:29 -05:00
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
2025-09-16 23:06:40 -05:00
</label>
<label>
Select folder(s)
2025-09-17 07:44:29 -05:00
<input type="file" multiple ref={folderInputRef} onChange={(e) => e.target.files && handleChoose(e.target.files)} />
2025-09-16 23:06:40 -05:00
</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>
2025-09-17 07:44:29 -05:00
{/* Review */}
2025-09-16 23:06:40 -05:00
<section>
2025-09-17 07:44:29 -05:00
<h3>Review Files</h3>
2025-09-16 23:06:40 -05:00
{sel.length === 0 ? (
<p className="meta">Select at least one file.</p>
) : (
<>
<article>
2025-09-17 07:44:29 -05:00
<div style={{ display: 'grid', gap: 12 }}>
2025-09-16 23:06:40 -05:00
<label>
Description for all videos (optional)
2025-09-17 07:44:29 -05:00
<input placeholder="Short video description" value={bulkDesc} onChange={(e) => setBulkDesc(e.target.value)} />
2025-09-16 23:06:40 -05:00
</label>
<button type="button" className="stretch" onClick={applyDescToAllVideos} disabled={!bulkDesc.trim()}>
Apply to all videos
</button>
2025-09-17 07:44:29 -05:00
{videosNeedingDesc > 0 ? (
<small className="meta bad">{videosNeedingDesc} video(s) need a description</small>
) : (
<small className="meta mono-wrap">All videos have descriptions</small>
)}
2025-09-16 23:06:40 -05:00
</div>
</article>
2025-09-16 04:32:16 -05:00
2025-09-17 07:44:29 -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>
2025-09-17 07:44:29 -05:00
<h4 className="filename wrap-anywhere" title={s.file.name}>
{s.file.name}
</h4>
2025-09-16 23:06:40 -05:00
<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}
2025-09-17 07:44:29 -05:00
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)))
2025-09-16 23:06:40 -05:00
}}
/>
</label>
<label>
Date
2025-09-16 04:32:16 -05:00
<input
type="date"
value={s.date}
2025-09-17 07:44:29 -05:00
onChange={(e) => {
2025-09-16 04:32:16 -05:00
const v = e.target.value
2025-09-17 07:44:29 -05:00
setSel((old) => old.map((x, idx) => (idx === i ? { ...x, date: v, finalName: composeName(v, x.desc, x.file.name) } : x)))
2025-09-16 04:32:16 -05:00
}}
/>
2025-09-16 23:06:40 -05:00
</label>
2025-09-08 00:48:47 -05:00
2025-09-17 07:44:29 -05:00
<small className="meta" title={s.finalName}>
{s.finalName}
</small>
2025-09-16 23:06:40 -05:00
{typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
{s.err && <small className="meta bad">Error: {s.err}</small>}
2025-09-17 07:44:29 -05:00
{duplicateNamesInSelection.has(s.finalName) && (
<small className="meta bad">Duplicate name in this batch. Adjust description or date.</small>
)}
{existingNames.has(s.finalName) && <small className="meta bad">A file with this name already exists here.</small>}
2025-09-16 23:06:40 -05:00
</article>
))}
</>
)}
</section>
2025-09-17 07:44:29 -05:00
{/* Destination */}
2025-09-16 23:06:40 -05:00
<section>
2025-09-17 07:44:29 -05:00
<h3>Select Destination</h3>
<p className="meta">Manage a library & choose a destination.</p>
2025-09-16 07:37:10 -05:00
2025-09-17 07:44:29 -05:00
{/* Library only (required). No subfolder dropdown. */}
<div className="grid-3">
2025-09-16 23:06:40 -05:00
<label>
2025-09-17 07:44:29 -05:00
Library (required)
2025-09-16 23:06:40 -05:00
<select
2025-09-17 07:44:29 -05:00
value={lib}
onChange={(e) => {
const v = e.target.value
setLib(v)
setSub('')
if (v) void refresh(v, '')
}}
2025-09-16 23:06:40 -05:00
>
2025-09-17 07:44:29 -05:00
<option value=""> Select a library </option>
{libs.map((L) => (
<option key={L} value={L}>
{L}
</option>
))}
2025-09-16 23:06:40 -05:00
</select>
</label>
2025-09-16 07:37:10 -05:00
</div>
2025-09-16 23:06:40 -05:00
2025-09-17 07:44:29 -05:00
{!lib ? (
<p className="meta">Select a library to create subfolders and view contents.</p>
) : (
<>
{/* New subfolder: free typing; sanitize on create */}
<div className="grid-3">
<label style={{ gridColumn: '1 / -1' }}>
Add a new subfolder (optional):
<input
placeholder="letters, numbers, underscores, dashes"
value={newFolderRaw}
onChange={(e) => setNewFolderRaw(e.target.value)}
inputMode="text"
/>
</label>
2025-09-16 23:06:40 -05:00
2025-09-17 07:44:29 -05:00
{newFolderRaw.trim() && (
<div className="row-center" style={{ gridColumn: '1 / -1', marginTop: 8 }}>
<button
type="button"
onClick={() => {
void createSubfolder(newFolderRaw)
setNewFolderRaw('')
}}
>
Create
</button>
</div>
)}
2025-09-16 23:06:40 -05:00
</div>
2025-09-17 07:44:29 -05:00
{/* Current selection with actions; includes Home button to go to root */}
<article className="card">
<div className="row-between" style={{ marginBottom: 6 }}>
<div>
<span className="meta">Current Sub-Folder:</span> <strong className="wrap-anywhere">{sub || '(library root)'}</strong>
</div>
2025-09-16 07:37:10 -05:00
</div>
2025-09-17 07:44:29 -05:00
{sub && (
2025-09-17 09:38:28 -05:00
<div className="sub-actions">
2025-09-17 07:44:29 -05:00
<button
2025-09-17 09:38:28 -05:00
type="button"
2025-09-17 07:44:29 -05:00
className="icon-btn"
title="Go to library root"
aria-label="Go to library root"
onClick={() => { setSub(''); void refresh(lib, '') }}
>
🏠
</button>
2025-09-17 09:38:28 -05:00
<button
type="button"
onClick={() => void renameFolder(sub)}
>
Rename
</button>
<button
type="button"
className="icon-btn danger"
title="Delete subfolder"
aria-label="Delete subfolder"
onClick={() => void deleteFolder(sub)}
>
</button>
2025-09-17 07:44:29 -05:00
</div>
)}
</article>
2025-09-16 23:06:40 -05:00
2025-09-17 07:44:29 -05:00
{/* Subfolders: only Select (right), name + icon (left); clamp width to avoid horizontal overflow */}
<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} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={d}>📁 {d}</div>
</div>
<div className="row-right">
<button
onClick={() => {
setSub(d)
void refresh(lib, d)
}}
>
Select
</button>
</div>
</article>
))}
</div>
2025-09-16 23:06:40 -05:00
)}
2025-09-17 07:44:29 -05:00
</details>
{/* Contents: info left (clamped), actions right */}
<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} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={f.name || '(unnamed)'}>{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}</div>
<small className="meta meta-wrap">{f.is_dir ? 'folder' : fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}</small>
</div>
<div className="row-right">
{f.is_dir ? (
<button
onClick={() => {
setSub(f.name)
void refresh(lib, f.name)
}}
>
Open
</button>
) : (
2025-09-17 09:38:28 -05:00
<button
className="icon-btn bad"
title="Delete"
aria-label={`Delete ${f.name}`}
onClick={() => void deletePath(f.path, false)}
>
</button>
2025-09-17 07:44:29 -05:00
)}
</div>
</article>
))}
</div>
)}
</details>
</>
)}
2025-09-16 23:06:40 -05:00
</section>
2025-09-17 07:44:29 -05:00
<footer style={{ display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<small className="meta" aria-live="polite">
{status}
</small>
{hasNameIssues && <small className="meta bad">Resolve duplicate/existing filename conflicts to enable Upload.</small>}
</div>
2025-09-16 23:06:40 -05:00
<button
type="button"
2025-09-17 07:44:29 -05:00
onClick={() => {
const disabled = !lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues
if (!disabled) void doUpload()
}}
disabled={!lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues}
2025-09-16 23:06:40 -05:00
>
2025-09-17 07:44:29 -05:00
Upload {sel.length ? `(${sel.length})` : ''}
2025-09-16 23:06:40 -05:00
</button>
</footer>
</>
)
2025-09-08 00:48:47 -05:00
}
2025-09-16 00:05:16 -05:00
2025-09-17 07:44:29 -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]
}