minor style look and feel tweaks
This commit is contained in:
parent
2e856c0b3e
commit
dcf9a6ab90
@ -319,7 +319,7 @@ func main() {
|
||||
})
|
||||
|
||||
// rename
|
||||
var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9]{1,8}$`)
|
||||
var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}(?:\.[A-Za-z0-9_-]{1,64})?\.[A-Za-z0-9]{1,8}$`)
|
||||
r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) {
|
||||
cl, err := internal.CurrentUser(r)
|
||||
if err != nil {
|
||||
@ -641,10 +641,18 @@ func sanitizeDescriptor(s string) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// stemOf strips the final extension and cleans the base.
|
||||
func stemOf(orig string) string {
|
||||
base := strings.TrimSuffix(orig, filepath.Ext(orig))
|
||||
s := sanitizeDescriptor(strings.ReplaceAll(base, ".", "_"))
|
||||
if s == "" { s = "file" }
|
||||
return s
|
||||
}
|
||||
|
||||
// composeFinalName mirrors the UI: YYYY.MM.DD.Description.ext
|
||||
// date is expected as YYYY-MM-DD; falls back to today if missing/bad.
|
||||
func composeFinalName(date, desc, orig string) string {
|
||||
y, m, d := "", "", ""
|
||||
var y, m, d string
|
||||
if len(date) >= 10 && date[4] == '-' && date[7] == '-' {
|
||||
y, m, d = date[:4], date[5:7], date[8:10]
|
||||
} else {
|
||||
@ -654,9 +662,11 @@ func composeFinalName(date, desc, orig string) string {
|
||||
d = fmt.Sprintf("%02d", now.Day())
|
||||
}
|
||||
clean := sanitizeDescriptor(desc)
|
||||
if clean == "" { clean = "upload" }
|
||||
stem := stemOf(orig)
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
|
||||
if ext == "" { ext = "bin" }
|
||||
return fmt.Sprintf("%s.%s.%s.%s.%s", y, m, d, clean, ext)
|
||||
return fmt.Sprintf("%s.%s.%s.%s.%s.%s", y, m, d, clean, stem, ext)
|
||||
}
|
||||
|
||||
// moveFromTus relocates the finished upload file from the TUS scratch directory to dst.
|
||||
|
||||
@ -7,12 +7,18 @@ import { api } from './api'
|
||||
export default function App() {
|
||||
const [authed, setAuthed] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(()=>{
|
||||
api('/api/whoami').then(()=> setAuthed(true)).catch(()=> setAuthed(false))
|
||||
React.useEffect(() => {
|
||||
api('/api/whoami')
|
||||
.then(() => setAuthed(true))
|
||||
.catch(() => setAuthed(false))
|
||||
}, [])
|
||||
|
||||
async function logout(){
|
||||
try { await api('/api/logout', { method:'POST' }) } finally { location.reload() }
|
||||
async function logout() {
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' })
|
||||
} finally {
|
||||
location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -25,7 +31,7 @@ export default function App() {
|
||||
{authed && <button onClick={logout}>Logout</button>}
|
||||
</header>
|
||||
|
||||
{authed ? <Uploader/> : <Login/>}
|
||||
{authed ? <Uploader /> : <Login onLogin={() => setAuthed(true)} />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@ -9,37 +9,54 @@ type Sel = { file: File; desc: string; date: string; finalName: string; progress
|
||||
console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
|
||||
|
||||
// ---------- helpers ----------
|
||||
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)
|
||||
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)
|
||||
}
|
||||
function sanitizeFolderName(s:string){
|
||||
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)
|
||||
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() : '' }
|
||||
function composeName(date:string, desc:string, orig:string){
|
||||
const d = date || new Date().toISOString().slice(0,10)
|
||||
const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}`
|
||||
function extOf(n: string) {
|
||||
const i = n.lastIndexOf('.')
|
||||
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
|
||||
}
|
||||
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 ?? '',
|
||||
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 ?? '',
|
||||
is_dir: Boolean(r?.is_dir ?? r?.IsDir ?? r?.isDir ?? false),
|
||||
size: Number(r?.size ?? r?.Size ?? 0),
|
||||
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))
|
||||
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))
|
||||
|
||||
function isDetailedError(e: unknown): e is tus.DetailedError {
|
||||
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any))
|
||||
@ -53,7 +70,9 @@ function isLikelyMobileUA(): boolean {
|
||||
}
|
||||
function useIsMobile(): boolean {
|
||||
const [mobile, setMobile] = React.useState(false)
|
||||
React.useEffect(()=>{ setMobile(isLikelyMobileUA()) }, [])
|
||||
React.useEffect(() => {
|
||||
setMobile(isLikelyMobileUA())
|
||||
}, [])
|
||||
return mobile
|
||||
}
|
||||
|
||||
@ -62,44 +81,55 @@ const NoResumeUrlStorage: any = {
|
||||
addUpload: async (_u: any) => {},
|
||||
removeUpload: async (_u: any) => {},
|
||||
listUploads: async () => [],
|
||||
// compatibility:
|
||||
findUploadsByFingerprint: async (_fp: string) => []
|
||||
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 {} }
|
||||
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>
|
||||
return (
|
||||
<div style={{ ...baseStyle, display: 'grid', placeItems: 'center' }} className="preview-thumb">
|
||||
📄
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- component ----------
|
||||
export default function Uploader(){
|
||||
export default function Uploader() {
|
||||
const mobile = useIsMobile()
|
||||
|
||||
const [me, setMe] = React.useState<WhoAmI|undefined>()
|
||||
const [me, setMe] = React.useState<WhoAmI | undefined>()
|
||||
const [libs, setLibs] = React.useState<string[]>([])
|
||||
const [lib, setLib] = React.useState<string>('')
|
||||
const [lib, setLib] = React.useState<string>('') // required to upload
|
||||
|
||||
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 [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)
|
||||
|
||||
// keep raw input for folder name (let user type anything; sanitize on create)
|
||||
const [newFolderRaw, setNewFolderRaw] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = folderInputRef.current
|
||||
if (!el) return
|
||||
@ -107,47 +137,51 @@ export default function Uploader(){
|
||||
el.removeAttribute('webkitdirectory')
|
||||
el.removeAttribute('directory')
|
||||
} else {
|
||||
el.setAttribute('webkitdirectory','')
|
||||
el.setAttribute('directory','')
|
||||
el.setAttribute('webkitdirectory', '')
|
||||
el.setAttribute('directory', '')
|
||||
}
|
||||
}, [mobile])
|
||||
|
||||
// initial load
|
||||
React.useEffect(()=>{
|
||||
(async ()=>{
|
||||
try{
|
||||
React.useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
setStatus('Loading profile…')
|
||||
const m = await api<WhoAmI>('/api/whoami'); setMe(m as any)
|
||||
const mm:any = m
|
||||
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] : [])
|
||||
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)
|
||||
setLib('') // do NOT auto-pick; user will choose
|
||||
setSub('')
|
||||
if (def) { await refresh(def, '') }
|
||||
setStatus(def ? `Ready · Destination: /${def}` : 'Choose a library to start')
|
||||
} catch(e:any) {
|
||||
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 {}
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' })
|
||||
} catch {}
|
||||
location.replace('/')
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
React.useEffect(()=>{
|
||||
(async ()=>{
|
||||
if (!lib) return
|
||||
React.useEffect(() => {
|
||||
;(async () => {
|
||||
if (!lib) {
|
||||
setRootDirs([])
|
||||
setRows([])
|
||||
setSub('')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setStatus(`Loading library “${lib}”…`)
|
||||
await refresh(lib, sub)
|
||||
} catch(e:any){
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] refresh error', e)
|
||||
setStatus(`List error: ${e?.message || e}`)
|
||||
}
|
||||
@ -155,11 +189,15 @@ export default function Uploader(){
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lib])
|
||||
|
||||
async function refresh(currLib:string, currSub:string){
|
||||
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 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)
|
||||
|
||||
@ -171,43 +209,53 @@ export default function Uploader(){
|
||||
setStatus(`Ready · Destination: ${show}`)
|
||||
}
|
||||
|
||||
function handleChoose(files: FileList){
|
||||
const arr = Array.from(files).map(f=>{
|
||||
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 = '' // start empty; required later for videos only
|
||||
return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
|
||||
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))
|
||||
console.log('[Pegasus] selected files', arr.map((a) => a.file.name))
|
||||
setSel(arr)
|
||||
}
|
||||
|
||||
// recompute finalName when global date changes
|
||||
React.useEffect(()=>{
|
||||
setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)})))
|
||||
React.useEffect(() => {
|
||||
setSel((old) => old.map((x) => ({ ...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name) })))
|
||||
}, [globalDate])
|
||||
|
||||
// Warn before closing mid-upload
|
||||
React.useEffect(()=>{
|
||||
const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } }
|
||||
window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler)
|
||||
React.useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (uploading) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [uploading])
|
||||
|
||||
// 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
|
||||
))
|
||||
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 }
|
||||
async function doUpload() {
|
||||
if (!me) {
|
||||
setStatus('Not signed in')
|
||||
return
|
||||
}
|
||||
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
|
||||
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
|
||||
@ -215,22 +263,22 @@ export default function Uploader(){
|
||||
|
||||
setStatus('Starting upload…')
|
||||
setUploading(true)
|
||||
try{
|
||||
for(const s of sel){
|
||||
try {
|
||||
for (const s of sel) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise<void>((resolve,reject)=>{
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
|
||||
endpoint: '/tus/',
|
||||
chunkSize: 5*1024*1024,
|
||||
chunkSize: 5 * 1024 * 1024,
|
||||
retryDelays: [0, 1000, 3000, 5000, 10000],
|
||||
metadata: {
|
||||
filename: s.file.name,
|
||||
lib: lib,
|
||||
subdir: sub || "",
|
||||
subdir: sub || '',
|
||||
date: s.date,
|
||||
desc: s.desc // server enforces: required for videos only
|
||||
desc: s.desc, // server enforces: required for videos only
|
||||
},
|
||||
onError: (err: Error | tus.DetailedError)=>{
|
||||
onError: (err: Error | tus.DetailedError) => {
|
||||
let msg = String(err)
|
||||
if (isDetailedError(err)) {
|
||||
const status = (err.originalRequest as any)?.status
|
||||
@ -238,18 +286,18 @@ export default function Uploader(){
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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()
|
||||
@ -273,96 +321,112 @@ export default function Uploader(){
|
||||
}
|
||||
|
||||
// -------- one-level subfolder ops --------
|
||||
async function createSubfolder(nameRaw:string){
|
||||
async function createSubfolder(nameRaw: string) {
|
||||
const name = sanitizeFolderName(nameRaw)
|
||||
if (!name) return
|
||||
try{
|
||||
try {
|
||||
await api(`/api/mkdir`, {
|
||||
method:'POST',
|
||||
headers:{'content-type':'application/json'},
|
||||
body: JSON.stringify({ lib: lib, path: name })
|
||||
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){
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] mkdir error', e)
|
||||
alert(`Create folder failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
async function renameFolder(oldName:string){
|
||||
async function renameFolder(oldName: string) {
|
||||
const nn = prompt('New folder name:', oldName)
|
||||
const newName = sanitizeFolderName(nn || '')
|
||||
if (!newName || newName === oldName) return
|
||||
try{
|
||||
try {
|
||||
await api('/api/rename', {
|
||||
method:'POST',
|
||||
headers:{'content-type':'application/json'},
|
||||
body: JSON.stringify({ lib: lib, from: oldName, to: newName })
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ lib: lib, from: oldName, to: newName }),
|
||||
})
|
||||
const newSub = (sub === oldName) ? newName : sub
|
||||
const newSub = sub === oldName ? newName : sub
|
||||
setSub(newSub)
|
||||
await refresh(lib, newSub)
|
||||
} catch(e:any){
|
||||
} 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
|
||||
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){
|
||||
} 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
|
||||
try{
|
||||
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('/') })
|
||||
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){
|
||||
} 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' })
|
||||
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){
|
||||
} catch (e: any) {
|
||||
console.error('[Pegasus] delete error', e)
|
||||
alert(`Delete failed:\n${e?.message || e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// sort rows
|
||||
const sortedRows = React.useMemo(()=>{
|
||||
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))
|
||||
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 ?? '')
|
||||
const an = a?.name ?? ''
|
||||
const bn = b?.name ?? ''
|
||||
return an.localeCompare(bn)
|
||||
})
|
||||
}, [rows])
|
||||
|
||||
const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
|
||||
const videosNeedingDesc = sel.filter(s => isVideoFile(s.file) && !s.desc.trim()).length
|
||||
// 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])
|
||||
|
||||
const [newFolder, setNewFolder] = React.useState('')
|
||||
// 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
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -370,85 +434,83 @@ export default function Uploader(){
|
||||
<section>
|
||||
<hgroup>
|
||||
<h2>Signed in: {me?.username}</h2>
|
||||
<p className="meta">Destination: <strong>{destPath || '/(choose a library)'}</strong></p>
|
||||
<p className="meta">
|
||||
Destination: <strong>{destPath || '/(choose a library)'}</strong>
|
||||
</p>
|
||||
</hgroup>
|
||||
</section>
|
||||
|
||||
{/* Add files */}
|
||||
{/* Choose content */}
|
||||
<section>
|
||||
<h3>Add files</h3>
|
||||
<h3>Choose content</h3>
|
||||
<div className="grid-3">
|
||||
<label>
|
||||
Default date (applied to new selections)
|
||||
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
|
||||
Default date
|
||||
<input type="date" value={globalDate} onChange={(e) => setGlobalDate(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="file-picker">
|
||||
{mobile ? (
|
||||
<>
|
||||
<label>
|
||||
Gallery/Photos
|
||||
<input type="file" multiple accept="image/*,video/*"
|
||||
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
|
||||
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
|
||||
</label>
|
||||
<label>
|
||||
Camera (optional)
|
||||
<input type="file" accept="image/*,video/*" capture="environment"
|
||||
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
|
||||
<input type="file" accept="image/*,video/*" capture="environment" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
Select file(s)
|
||||
<input type="file" multiple accept="image/*,video/*"
|
||||
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
|
||||
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
|
||||
</label>
|
||||
<label>
|
||||
Select folder(s)
|
||||
<input type="file" multiple ref={folderInputRef}
|
||||
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
|
||||
<input type="file" multiple ref={folderInputRef} onChange={(e) => e.target.files && handleChoose(e.target.files)} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Review & upload */}
|
||||
{/* Review */}
|
||||
<section>
|
||||
<h3>Review & Upload → <span className="meta">{destPath || '/(choose a library)'}</span></h3>
|
||||
<h3>Review Files</h3>
|
||||
|
||||
{sel.length === 0 ? (
|
||||
<p className="meta">Select at least one file.</p>
|
||||
) : (
|
||||
<>
|
||||
<article>
|
||||
<div style={{display:'grid', gap:12}}>
|
||||
<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)}
|
||||
/>
|
||||
<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>
|
||||
}
|
||||
{videosNeedingDesc > 0 ? (
|
||||
<small className="meta bad">{videosNeedingDesc} video(s) need a description</small>
|
||||
) : (
|
||||
<small className="meta mono-wrap">All videos have descriptions</small>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{sel.map((s,i)=>(
|
||||
{sel.map((s, i) => (
|
||||
<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>
|
||||
<h4 className="filename wrap-anywhere" title={s.file.name}>
|
||||
{s.file.name}
|
||||
</h4>
|
||||
|
||||
<label>
|
||||
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
|
||||
@ -457,9 +519,9 @@ export default function Uploader(){
|
||||
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))
|
||||
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>
|
||||
@ -469,152 +531,211 @@ export default function Uploader(){
|
||||
<input
|
||||
type="date"
|
||||
value={s.date}
|
||||
onChange={e=>{
|
||||
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))
|
||||
setSel((old) => old.map((x, idx) => (idx === i ? { ...x, date: v, finalName: composeName(v, x.desc, x.file.name) } : x)))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<small className="meta">→ {s.finalName}</small>
|
||||
<small className="meta" title={s.finalName}>
|
||||
→ {s.finalName}
|
||||
</small>
|
||||
{typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
|
||||
{s.err && <small className="meta bad">Error: {s.err}</small>}
|
||||
{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>}
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Folder (one-level manager) */}
|
||||
{/* Destination */}
|
||||
<section>
|
||||
<h3>Library</h3>
|
||||
<h3>Select Destination</h3>
|
||||
<p className="meta">Manage a library & choose a destination.</p>
|
||||
|
||||
{/* Library only (required). No subfolder dropdown. */}
|
||||
<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>
|
||||
|
||||
<label>
|
||||
Subfolder (optional, 1 level)
|
||||
Library (required)
|
||||
<select
|
||||
value={sub}
|
||||
onChange={e=>{ const v = clampOneLevel(e.target.value); setSub(v); void refresh(lib, v) }}
|
||||
disabled={!lib}
|
||||
value={lib}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setLib(v)
|
||||
setSub('')
|
||||
if (v) void refresh(v, '')
|
||||
}}
|
||||
>
|
||||
<option value="">(Library root)</option>
|
||||
{rootDirs.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
<option value="">— Select a library —</option>
|
||||
{libs.map((L) => (
|
||||
<option key={L} value={L}>
|
||||
{L}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{!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>
|
||||
|
||||
{Boolean(sanitizeFolderName(newFolder)) && (
|
||||
<div className="center-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={()=>{ const n = sanitizeFolderName(newFolder); if (n) { void createSubfolder(n); setNewFolder('') } }}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{newFolderRaw.trim() && (
|
||||
<div className="row-center" style={{ gridColumn: '1 / -1', marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void createSubfolder(newFolderRaw)
|
||||
setNewFolderRaw('')
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
{sub && (
|
||||
<div className="row-center buttons-row">
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Go to library root"
|
||||
aria-label="Go to library root"
|
||||
onClick={() => { setSub(''); void refresh(lib, '') }}
|
||||
>
|
||||
🏠
|
||||
</button>
|
||||
<button onClick={() => void renameFolder(sub)}>Rename</button>
|
||||
<button onClick={() => void deleteFolder(sub)} className="bad">Delete</button>
|
||||
<button onClick={() => { setSub(''); void refresh(lib, '') }}>Clear</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* 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. You’ll 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>
|
||||
)}
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={()=>{ const disabled = !lib || !sel.length || uploading || videosNeedingDesc>0; if (!disabled) void doUpload() }}
|
||||
disabled={!lib || !sel.length || uploading || videosNeedingDesc>0}
|
||||
onClick={() => {
|
||||
const disabled = !lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues
|
||||
if (!disabled) void doUpload()
|
||||
}}
|
||||
disabled={!lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues}
|
||||
>
|
||||
Upload {sel.length? `(${sel.length})` : ''}
|
||||
Upload {sel.length ? `(${sel.length})` : ''}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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] }
|
||||
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]
|
||||
}
|
||||
|
||||
@ -1,96 +1,34 @@
|
||||
/* frontend/src/styles.css */
|
||||
@import '@picocss/pico/css/pico.classless.min.css';
|
||||
|
||||
/* Small, framework-friendly helpers only */
|
||||
:root { --thumb: 112px; }
|
||||
|
||||
main { max-width: 960px; margin: 0 auto; padding: 0 12px 24px; }
|
||||
section { margin-block: 1rem; }
|
||||
small.meta { color: var(--muted-color); }
|
||||
|
||||
/* Responsive columns for form controls */
|
||||
.columns {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr; /* mobile: one column */
|
||||
align-items: end;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.columns { grid-template-columns: repeat(3, minmax(0,1fr)); } /* desktop: 3 cols */
|
||||
}
|
||||
|
||||
/* Two-up area (e.g., file pickers) */
|
||||
.file-picker {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr; /* mobile: stack */
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.file-picker { grid-template-columns: 1fr 1fr; } /* desktop: 2 cols */
|
||||
}
|
||||
|
||||
/* File card: thumb above on mobile, side-by-side on wide screens */
|
||||
.file-card {
|
||||
display: grid;
|
||||
grid-template-columns: var(--thumb) 1fr;
|
||||
gap: .75rem;
|
||||
align-items: start;
|
||||
}
|
||||
.file-thumb {
|
||||
width: var(--thumb);
|
||||
height: var(--thumb);
|
||||
object-fit: cover;
|
||||
border-radius: .5rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.file-card { grid-template-columns: 1fr; }
|
||||
.file-thumb { width: 100%; height: auto; max-height: 240px; }
|
||||
}
|
||||
|
||||
/* Keep the action visible while scrolling the list */
|
||||
.sticky-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: .75rem;
|
||||
background: var(--card-background-color);
|
||||
border-top: 1px solid var(--muted-border-color, #2e2e2e);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
main { max-width: 980px; margin: 0 auto; padding: 0 12px 24px; }
|
||||
|
||||
/* Stacked groups by default; switch to columns on larger screens */
|
||||
.grid-3 { display: grid; gap: 12px; }
|
||||
@media (min-width: 768px) {
|
||||
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; }
|
||||
}
|
||||
|
||||
/* File review card layout */
|
||||
.file-card { display: grid; grid-template-columns: 96px 1fr; gap: 12px; align-items: start; }
|
||||
@media (max-width: 520px) {
|
||||
.file-card { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Make form controls fill their line */
|
||||
label > input, label > select, label > textarea { width: 100%; }
|
||||
section { margin-block: 1rem; }
|
||||
|
||||
/* App bar */
|
||||
.app-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin: 12px 0 24px; }
|
||||
.brand { display:flex; align-items:center; gap:8px; font-size: 1.25rem; }
|
||||
.brand .wing { font-size: 1.3em; line-height: 1; }
|
||||
|
||||
/* Top row (Library / Subfolder / Date) */
|
||||
/* Layout helpers */
|
||||
.grid-3 { display: grid; gap: 12px; }
|
||||
@media (min-width: 768px) {
|
||||
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; }
|
||||
}
|
||||
.file-picker { display: grid; gap: 12px; }
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.row-right { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.row-center { display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
|
||||
/* “Cards” for files */
|
||||
/* Inputs fill their line */
|
||||
label > input, label > select, label > textarea { width: 100%; }
|
||||
|
||||
/* Subtle card and list rows */
|
||||
.card { padding: 10px 12px; border-radius: 10px; background: rgba(255,255,255,0.03); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06); }
|
||||
.list-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-radius: 8px; }
|
||||
.list-row:not(:last-child) { margin-bottom: 4px; }
|
||||
|
||||
/* File review cards */
|
||||
.video-card {
|
||||
border: 1px solid var(--muted-border-color);
|
||||
border-radius: 12px;
|
||||
@ -103,16 +41,64 @@ label > input, label > select, label > textarea { width: 100%; }
|
||||
.preview-thumb { box-shadow: 0 1px 0 rgba(0,0,0,.12); }
|
||||
.filename { margin: 0 0 4px; }
|
||||
|
||||
/* File pickers and form controls fill line width */
|
||||
.file-picker { display: grid; gap: 12px; }
|
||||
label > input, label > select, label > textarea { width: 100%; }
|
||||
|
||||
/* Centered one-off actions */
|
||||
.center-actions { display:flex; justify-content:center; margin-top: 6px; }
|
||||
|
||||
/* Misc */
|
||||
/* Utilities */
|
||||
.ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
small.meta { color: var(--muted-color); }
|
||||
.bad { color: var(--del-color); }
|
||||
.stretch { width: 100%; }
|
||||
small.meta { color: var(--muted-color); }
|
||||
.file-picker { display: grid; gap: 12px; }
|
||||
|
||||
/* --- Prevent horizontal overflow / allow wrapping --- */
|
||||
|
||||
/* Never let rows outgrow the viewport */
|
||||
.list-row,
|
||||
.card,
|
||||
.video-card { max-width: 100%; }
|
||||
|
||||
/* Left column of rows: allow wrapping and shrinking */
|
||||
.list-row .name,
|
||||
.list-row .meta-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* enables flexbox text clamping */
|
||||
}
|
||||
|
||||
/* Right button cluster should never shrink weirdly */
|
||||
.row-right {
|
||||
flex: 0 0 auto; /* keep buttons the same width */
|
||||
}
|
||||
|
||||
/* Generic helpers for wrapping long tokens (dots, underscores, hashes) */
|
||||
.wrap-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Mono lines that can wrap (e.g., final filenames) */
|
||||
.mono-wrap {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Long headings / filenames on cards should wrap instead of overflow */
|
||||
.filename { overflow-wrap: anywhere; word-break: break-word; }
|
||||
|
||||
/* If any native control still nudges layout, this prevents accidental sideways scroll */
|
||||
html, body { max-width: 100%; overflow-x: hidden; }
|
||||
|
||||
/* Let grid children inside cards shrink; prevents horizontal overflow */
|
||||
.video-card > * { min-width: 0; }
|
||||
|
||||
/* Ensure long file names wrap instead of overflowing */
|
||||
.filename { overflow-wrap: anywhere; word-break: break-word; }
|
||||
|
||||
/* Center the actions row in the Current Sub‑Folder card */
|
||||
.buttons-row {
|
||||
justify-content: center; /* explicit, even though row-center already centers */
|
||||
flex-wrap: wrap; /* wrap on very small screens */
|
||||
}
|
||||
|
||||
/* Neutralize any framework inline margins to keep the group perfectly centered;
|
||||
gap from the parent flex container provides the spacing */
|
||||
.buttons-row > button {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user