minor style look and feel tweaks

This commit is contained in:
Brad Stein 2025-09-17 07:44:29 -05:00
parent 2e856c0b3e
commit dcf9a6ab90
4 changed files with 489 additions and 366 deletions

View File

@ -319,7 +319,7 @@ func main() {
}) })
// rename // 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) { r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) {
cl, err := internal.CurrentUser(r) cl, err := internal.CurrentUser(r)
if err != nil { if err != nil {
@ -641,10 +641,18 @@ func sanitizeDescriptor(s string) string {
return string(b) 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 // composeFinalName mirrors the UI: YYYY.MM.DD.Description.ext
// date is expected as YYYY-MM-DD; falls back to today if missing/bad. // date is expected as YYYY-MM-DD; falls back to today if missing/bad.
func composeFinalName(date, desc, orig string) string { func composeFinalName(date, desc, orig string) string {
y, m, d := "", "", "" var y, m, d string
if len(date) >= 10 && date[4] == '-' && date[7] == '-' { if len(date) >= 10 && date[4] == '-' && date[7] == '-' {
y, m, d = date[:4], date[5:7], date[8:10] y, m, d = date[:4], date[5:7], date[8:10]
} else { } else {
@ -654,9 +662,11 @@ func composeFinalName(date, desc, orig string) string {
d = fmt.Sprintf("%02d", now.Day()) d = fmt.Sprintf("%02d", now.Day())
} }
clean := sanitizeDescriptor(desc) clean := sanitizeDescriptor(desc)
if clean == "" { clean = "upload" }
stem := stemOf(orig)
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), ".")) ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
if ext == "" { ext = "bin" } 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. // moveFromTus relocates the finished upload file from the TUS scratch directory to dst.

View File

@ -7,12 +7,18 @@ import { api } from './api'
export default function App() { export default function App() {
const [authed, setAuthed] = React.useState<boolean | undefined>(undefined) const [authed, setAuthed] = React.useState<boolean | undefined>(undefined)
React.useEffect(()=>{ React.useEffect(() => {
api('/api/whoami').then(()=> setAuthed(true)).catch(()=> setAuthed(false)) api('/api/whoami')
.then(() => setAuthed(true))
.catch(() => setAuthed(false))
}, []) }, [])
async function logout(){ async function logout() {
try { await api('/api/logout', { method:'POST' }) } finally { location.reload() } try {
await api('/api/logout', { method: 'POST' })
} finally {
location.reload()
}
} }
return ( return (
@ -25,7 +31,7 @@ export default function App() {
{authed && <button onClick={logout}>Logout</button>} {authed && <button onClick={logout}>Logout</button>}
</header> </header>
{authed ? <Uploader/> : <Login/>} {authed ? <Uploader /> : <Login onLogin={() => setAuthed(true)} />}
</main> </main>
) )
} }

View File

@ -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()) console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
// ---------- helpers ---------- // ---------- helpers ----------
function sanitizeDesc(s:string){ function sanitizeDesc(s: string) {
s = s.trim().replace(/\s+/g,'_').replace(/[^A-Za-z0-9._-]+/g,'_') s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_')
if(!s) s = 'upload' if (!s) s = 'upload'
return s.slice(0,64) return s.slice(0, 64)
} }
function sanitizeFolderName(s:string){ function sanitizeFolderName(s: string) {
// 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-] // 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-]
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//,'').replace(/\/.*$/,'') s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '')
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g,'_').replace(/_+/g,'_') s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_')
return s.slice(0,64) return s.slice(0, 64)
} }
function extOf(n:string){ const i=n.lastIndexOf('.'); return i>-1 ? n.slice(i+1).toLowerCase() : '' } function extOf(n: string) {
function composeName(date:string, desc:string, orig:string){ const i = n.lastIndexOf('.')
const d = date || new Date().toISOString().slice(0,10) return i > -1 ? n.slice(i + 1).toLowerCase() : ''
const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}`
} }
function clampOneLevel(p:string){ if(!p) return ''; return p.replace(/^\/+|\/+$/g,'').split('/')[0] || '' } function stemOf(n: string) {
function normalizeRows(listRaw:any[]): FileRow[] { const i = n.lastIndexOf('.')
return (Array.isArray(listRaw) ? listRaw : []).map((r:any)=>({ const stem = i > -1 ? n.slice(0, i) : n
name: r?.name ?? r?.Name ?? '', // keep safe charset and avoid extra dots within segments
path: r?.path ?? r?.Path ?? '', 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), 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), mtime: Number(r?.mtime ?? r?.Mtime ?? 0),
})) }))
} }
const videoExt = new Set(['mp4','mkv','mov','avi','m4v','webm','mpg','mpeg','ts','m2ts']) 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 imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff'])
const extLower = (n:string)=> (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '') const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '')
const isVideoFile = (f:File)=> f.type.startsWith('video/') || videoExt.has(extLower(f.name)) 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 isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name))
function isDetailedError(e: unknown): e is tus.DetailedError { function isDetailedError(e: unknown): e is tus.DetailedError {
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any)) 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 { function useIsMobile(): boolean {
const [mobile, setMobile] = React.useState(false) const [mobile, setMobile] = React.useState(false)
React.useEffect(()=>{ setMobile(isLikelyMobileUA()) }, []) React.useEffect(() => {
setMobile(isLikelyMobileUA())
}, [])
return mobile return mobile
} }
@ -62,44 +81,55 @@ const NoResumeUrlStorage: any = {
addUpload: async (_u: any) => {}, addUpload: async (_u: any) => {},
removeUpload: async (_u: any) => {}, removeUpload: async (_u: any) => {},
listUploads: async () => [], listUploads: async () => [],
// compatibility: findUploadsByFingerprint: async (_fp: string) => [],
findUploadsByFingerprint: async (_fp: string) => []
} }
const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}` const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}`
// ---------- thumbnail ---------- // ---------- thumbnail ----------
function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) { function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) {
const [url, setUrl] = React.useState<string>() const [url, setUrl] = React.useState<string>()
React.useEffect(()=>{ React.useEffect(() => {
const u = URL.createObjectURL(file); setUrl(u) const u = URL.createObjectURL(file)
return () => { try { URL.revokeObjectURL(u) } catch {} } setUrl(u)
return () => {
try {
URL.revokeObjectURL(u)
} catch {}
}
}, [file]) }, [file])
if (!url) return null if (!url) return null
const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 } 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 (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" /> 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 ---------- // ---------- component ----------
export default function Uploader(){ export default function Uploader() {
const mobile = useIsMobile() const mobile = useIsMobile()
const [me, setMe] = React.useState<WhoAmI|undefined>() const [me, setMe] = React.useState<WhoAmI | undefined>()
const [libs, setLibs] = React.useState<string[]>([]) 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 [sub, setSub] = React.useState<string>('')
const [rootDirs, setRootDirs] = React.useState<string[]>([]) const [rootDirs, setRootDirs] = React.useState<string[]>([])
const [rows, setRows] = React.useState<FileRow[]>([]) const [rows, setRows] = React.useState<FileRow[]>([])
const [status, setStatus] = React.useState<string>('') 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 [uploading, setUploading] = React.useState<boolean>(false)
const [sel, setSel] = React.useState<Sel[]>([]) const [sel, setSel] = React.useState<Sel[]>([])
const [bulkDesc, setBulkDesc] = React.useState<string>('') // helper: apply to all videos const [bulkDesc, setBulkDesc] = React.useState<string>('') // helper: apply to all videos
const folderInputRef = React.useRef<HTMLInputElement>(null) 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(() => { React.useEffect(() => {
const el = folderInputRef.current const el = folderInputRef.current
if (!el) return if (!el) return
@ -107,47 +137,51 @@ export default function Uploader(){
el.removeAttribute('webkitdirectory') el.removeAttribute('webkitdirectory')
el.removeAttribute('directory') el.removeAttribute('directory')
} else { } else {
el.setAttribute('webkitdirectory','') el.setAttribute('webkitdirectory', '')
el.setAttribute('directory','') el.setAttribute('directory', '')
} }
}, [mobile]) }, [mobile])
// initial load // initial load
React.useEffect(()=>{ React.useEffect(() => {
(async ()=>{ ;(async () => {
try{ try {
setStatus('Loading profile…') setStatus('Loading profile…')
const m = await api<WhoAmI>('/api/whoami'); setMe(m as any) const m = await api<WhoAmI>('/api/whoami')
const mm:any = m setMe(m as any)
const mm: any = m
const L: string[] = const L: string[] =
Array.isArray(mm?.roots) ? mm.roots : Array.isArray(mm?.roots) ? mm.roots : Array.isArray(mm?.libs) ? mm.libs : typeof mm?.root === 'string' && mm.root ? [mm.root] : []
Array.isArray(mm?.libs) ? mm.libs :
(typeof mm?.root === 'string' && mm.root ? [mm.root] : [])
setLibs(L) setLibs(L)
const def = L[0] || '' setLib('') // do NOT auto-pick; user will choose
setLib(def)
setSub('') setSub('')
if (def) { await refresh(def, '') } setStatus('Choose a library to start')
setStatus(def ? `Ready · Destination: /${def}` : 'Choose a library to start') } catch (e: any) {
} catch(e:any) {
const msg = String(e?.message || e || '') const msg = String(e?.message || e || '')
setStatus(`Profile error: ${msg}`) setStatus(`Profile error: ${msg}`)
if (msg.toLowerCase().includes('no mapping')) { 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.') 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('/') location.replace('/')
} }
} }
})() })()
}, []) }, [])
React.useEffect(()=>{ React.useEffect(() => {
(async ()=>{ ;(async () => {
if (!lib) return if (!lib) {
setRootDirs([])
setRows([])
setSub('')
return
}
try { try {
setStatus(`Loading library “${lib}”…`) setStatus(`Loading library “${lib}”…`)
await refresh(lib, sub) await refresh(lib, sub)
} catch(e:any){ } catch (e: any) {
console.error('[Pegasus] refresh error', e) console.error('[Pegasus] refresh error', e)
setStatus(`List error: ${e?.message || e}`) setStatus(`List error: ${e?.message || e}`)
} }
@ -155,11 +189,15 @@ export default function Uploader(){
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lib]) }, [lib])
async function refresh(currLib:string, currSub:string){ async function refresh(currLib: string, currSub: string) {
if (!currLib) return
const one = clampOneLevel(currSub) const one = clampOneLevel(currSub)
const listRoot = normalizeRows(await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path: '' })}`)) 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) const rootDirNames = listRoot
setRootDirs(rootDirNames.sort((a,b)=>a.localeCompare(b))) .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 : '' const subOk = one && rootDirNames.includes(one) ? one : ''
if (subOk !== currSub) setSub(subOk) if (subOk !== currSub) setSub(subOk)
@ -171,43 +209,53 @@ export default function Uploader(){
setStatus(`Ready · Destination: ${show}`) setStatus(`Ready · Destination: ${show}`)
} }
function handleChoose(files: FileList){ function handleChoose(files: FileList) {
const arr = Array.from(files).map(f=>{ const arr = Array.from(files).map((f) => {
const base = (f as any).webkitRelativePath || f.name const base = (f as any).webkitRelativePath || f.name
const name = base.split('/').pop() || f.name const name = base.split('/').pop() || f.name
const desc = '' // start empty; required later for videos only 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) setSel(arr)
} }
// recompute finalName when global date changes // recompute finalName when global date changes
React.useEffect(()=>{ React.useEffect(() => {
setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)}))) setSel((old) => old.map((x) => ({ ...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name) })))
}, [globalDate]) }, [globalDate])
// Warn before closing mid-upload // Warn before closing mid-upload
React.useEffect(()=>{ React.useEffect(() => {
const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } } const handler = (e: BeforeUnloadEvent) => {
window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler) if (uploading) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [uploading]) }, [uploading])
// Apply description to all videos helper // Apply description to all videos helper
function applyDescToAllVideos() { function applyDescToAllVideos() {
if (!bulkDesc.trim()) return if (!bulkDesc.trim()) return
setSel(old => old.map(x => setSel((old) =>
isVideoFile(x.file) old.map((x) => (isVideoFile(x.file) ? { ...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name) } : x))
? ({...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name)}) )
: x
))
} }
async function doUpload(){ async function doUpload() {
if(!me) { setStatus('Not signed in'); return } if (!me) {
if(!lib) { alert('Please select a Library to upload into.'); return } setStatus('Not signed in')
return
}
if (!lib) {
alert('Please select a Library to upload into.')
return
}
// Require description only for videos: // 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) { if (missingVideos > 0) {
alert(`Please add a short description for all videos. ${missingVideos} video(s) missing a description.`) alert(`Please add a short description for all videos. ${missingVideos} video(s) missing a description.`)
return return
@ -215,22 +263,22 @@ export default function Uploader(){
setStatus('Starting upload…') setStatus('Starting upload…')
setUploading(true) setUploading(true)
try{ try {
for(const s of sel){ for (const s of sel) {
// eslint-disable-next-line no-await-in-loop // 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 } = { const opts: tus.UploadOptions & { withCredentials?: boolean } = {
endpoint: '/tus/', endpoint: '/tus/',
chunkSize: 5*1024*1024, chunkSize: 5 * 1024 * 1024,
retryDelays: [0, 1000, 3000, 5000, 10000], retryDelays: [0, 1000, 3000, 5000, 10000],
metadata: { metadata: {
filename: s.file.name, filename: s.file.name,
lib: lib, lib: lib,
subdir: sub || "", subdir: sub || '',
date: s.date, 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) let msg = String(err)
if (isDetailedError(err)) { if (isDetailedError(err)) {
const status = (err.originalRequest as any)?.status const status = (err.originalRequest as any)?.status
@ -238,18 +286,18 @@ export default function Uploader(){
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim() if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
} }
console.error('[Pegasus] tus error', s.file.name, err) 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}`) setStatus(`${s.file.name}: ${msg}`)
alert(`Upload failed: ${s.file.name}\n\n${msg}`) alert(`Upload failed: ${s.file.name}\n\n${msg}`)
reject(err as any) reject(err as any)
}, },
onProgress: (sent: number, total: number)=>{ onProgress: (sent: number, total: number) => {
const pct = Math.floor(sent/Math.max(total,1)*100) const pct = Math.floor((sent / Math.max(total, 1)) * 100)
setSel(old => old.map(x => x.file===s.file ? ({...x, progress: pct}) : x)) setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: pct } : x)))
setStatus(`${s.finalName}: ${pct}%`) setStatus(`${s.finalName}: ${pct}%`)
}, },
onSuccess: ()=>{ onSuccess: () => {
setSel(old => old.map(x => x.file===s.file ? ({...x, progress: 100, err: undefined}) : x)) setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: 100, err: undefined } : x)))
setStatus(`${s.finalName} uploaded`) setStatus(`${s.finalName} uploaded`)
console.log('[Pegasus] tus success', s.file.name) console.log('[Pegasus] tus success', s.file.name)
resolve() resolve()
@ -273,96 +321,112 @@ export default function Uploader(){
} }
// -------- one-level subfolder ops -------- // -------- one-level subfolder ops --------
async function createSubfolder(nameRaw:string){ async function createSubfolder(nameRaw: string) {
const name = sanitizeFolderName(nameRaw) const name = sanitizeFolderName(nameRaw)
if (!name) return if (!name) return
try{ try {
await api(`/api/mkdir`, { await api(`/api/mkdir`, {
method:'POST', method: 'POST',
headers:{'content-type':'application/json'}, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, path: name }) body: JSON.stringify({ lib: lib, path: name }),
}) })
await refresh(lib, name) // jump into new folder await refresh(lib, name) // jump into new folder
setSub(name) setSub(name)
} catch(e:any){ } catch (e: any) {
console.error('[Pegasus] mkdir error', e) console.error('[Pegasus] mkdir error', e)
alert(`Create folder failed:\n${e?.message || 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 nn = prompt('New folder name:', oldName)
const newName = sanitizeFolderName(nn || '') const newName = sanitizeFolderName(nn || '')
if (!newName || newName === oldName) return if (!newName || newName === oldName) return
try{ try {
await api('/api/rename', { await api('/api/rename', {
method:'POST', method: 'POST',
headers:{'content-type':'application/json'}, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, from: oldName, to: newName }) body: JSON.stringify({ lib: lib, from: oldName, to: newName }),
}) })
const newSub = (sub === oldName) ? newName : sub const newSub = sub === oldName ? newName : sub
setSub(newSub) setSub(newSub)
await refresh(lib, newSub) await refresh(lib, newSub)
} catch(e:any){ } catch (e: any) {
console.error('[Pegasus] rename folder error', e) console.error('[Pegasus] rename folder error', e)
alert(`Rename failed:\n${e?.message || e}`) alert(`Rename failed:\n${e?.message || e}`)
} }
} }
async function deleteFolder(name:string){ async function deleteFolder(name: string) {
if(!confirm(`Delete folder “${name}” (and its contents)?`)) return if (!confirm(`Delete folder “${name}” (and its contents)?`)) return
try{ try {
await api(`/api/file?${new URLSearchParams({ lib: lib, path: name, recursive:'true' })}`, { method:'DELETE' }) await api(`/api/file?${new URLSearchParams({ lib: lib, path: name, recursive: 'true' })}`, { method: 'DELETE' })
const newSub = (sub === name) ? '' : sub const newSub = sub === name ? '' : sub
setSub(newSub) setSub(newSub)
await refresh(lib, newSub) await refresh(lib, newSub)
} catch(e:any){ } catch (e: any) {
console.error('[Pegasus] delete folder error', e) console.error('[Pegasus] delete folder error', e)
alert(`Delete failed:\n${e?.message || e}`) alert(`Delete failed:\n${e?.message || e}`)
} }
} }
// destination listing (actions: rename files only at library root) // destination listing (actions: rename files only at library root)
async function renamePath(oldp:string){ async function renamePath(oldp: string) {
const base = (oldp.split('/').pop()||'') const base = oldp.split('/').pop() || ''
const name = prompt('New name (YYYY.MM.DD.description.ext):', base); if(!name) return const name = prompt('New name (YYYY.MM.DD.Description.OrigStem.ext):', base)
try{ if (!name) return
try {
await api('/api/rename', { await api('/api/rename', {
method:'POST', method: 'POST',
headers:{'content-type':'application/json'}, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, from: oldp, to: (oldp.split('/').slice(0,-1).concat(sanitizeFolderName(name))).join('/') }) body: JSON.stringify({ lib: lib, from: oldp, to: oldp.split('/').slice(0, -1).concat(sanitizeFolderName(name)).join('/') }),
}) })
await refresh(lib, sub) await refresh(lib, sub)
} catch(e:any){ } catch (e: any) {
console.error('[Pegasus] rename error', e) console.error('[Pegasus] rename error', e)
alert(`Rename failed:\n${e?.message || e}`) alert(`Rename failed:\n${e?.message || e}`)
} }
} }
async function deletePath(p:string, recursive:boolean){ async function deletePath(p: string, recursive: boolean) {
if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return if (!confirm(`Delete ${p}${recursive ? ' (recursive)' : ''}?`)) return
try{ try {
await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive?'true':'false' })}`, { method:'DELETE' }) await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive ? 'true' : 'false' })}`, {
method: 'DELETE',
})
await refresh(lib, sub) await refresh(lib, sub)
} catch(e:any){ } catch (e: any) {
console.error('[Pegasus] delete error', e) console.error('[Pegasus] delete error', e)
alert(`Delete failed:\n${e?.message || e}`) alert(`Delete failed:\n${e?.message || e}`)
} }
} }
// sort rows // sort rows
const sortedRows = React.useMemo(()=>{ const sortedRows = React.useMemo(() => {
const arr = Array.isArray(rows) ? rows.slice() : [] const arr = Array.isArray(rows) ? rows.slice() : []
return arr.sort((a,b)=>{ return arr.sort((a, b) => {
const dirFirst = (Number(b?.is_dir ? 1:0) - Number(a?.is_dir ? 1:0)) const dirFirst = Number(b?.is_dir ? 1 : 0) - Number(a?.is_dir ? 1 : 0)
if (dirFirst !== 0) return dirFirst if (dirFirst !== 0) return dirFirst
const an = (a?.name ?? '') const an = a?.name ?? ''
const bn = (b?.name ?? '') const bn = b?.name ?? ''
return an.localeCompare(bn) return an.localeCompare(bn)
}) })
}, [rows]) }, [rows])
const destPath = `/${[lib, sub].filter(Boolean).join('/')}` // Existing filenames in the destination (to block collisions)
const videosNeedingDesc = sel.filter(s => isVideoFile(s.file) && !s.desc.trim()).length 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 ( return (
<> <>
@ -370,85 +434,83 @@ export default function Uploader(){
<section> <section>
<hgroup> <hgroup>
<h2>Signed in: {me?.username}</h2> <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> </hgroup>
</section> </section>
{/* Add files */} {/* Choose content */}
<section> <section>
<h3>Add files</h3> <h3>Choose content</h3>
<div className="grid-3"> <div className="grid-3">
<label> <label>
Default date (applied to new selections) Default date
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} /> <input type="date" value={globalDate} onChange={(e) => setGlobalDate(e.target.value)} />
</label> </label>
</div> </div>
<div className="file-picker"> <div className="file-picker">
{mobile ? ( {mobile ? (
<> <>
<label> <label>
Gallery/Photos Gallery/Photos
<input type="file" multiple accept="image/*,video/*" <input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label> </label>
<label> <label>
Camera (optional) Camera (optional)
<input type="file" accept="image/*,video/*" capture="environment" <input type="file" accept="image/*,video/*" capture="environment" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label> </label>
</> </>
) : ( ) : (
<> <>
<label> <label>
Select file(s) Select file(s)
<input type="file" multiple accept="image/*,video/*" <input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label> </label>
<label> <label>
Select folder(s) Select folder(s)
<input type="file" multiple ref={folderInputRef} <input type="file" multiple ref={folderInputRef} onChange={(e) => e.target.files && handleChoose(e.target.files)} />
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label> </label>
</> </>
)} )}
</div> </div>
</section> </section>
{/* Review & upload */} {/* Review */}
<section> <section>
<h3>Review & Upload <span className="meta">{destPath || '/(choose a library)'}</span></h3> <h3>Review Files</h3>
{sel.length === 0 ? ( {sel.length === 0 ? (
<p className="meta">Select at least one file.</p> <p className="meta">Select at least one file.</p>
) : ( ) : (
<> <>
<article> <article>
<div style={{display:'grid', gap:12}}> <div style={{ display: 'grid', gap: 12 }}>
<label> <label>
Description for all videos (optional) Description for all videos (optional)
<input <input placeholder="Short video description" value={bulkDesc} onChange={(e) => setBulkDesc(e.target.value)} />
placeholder="Short video description"
value={bulkDesc}
onChange={e=> setBulkDesc(e.target.value)}
/>
</label> </label>
<button type="button" className="stretch" onClick={applyDescToAllVideos} disabled={!bulkDesc.trim()}> <button type="button" className="stretch" onClick={applyDescToAllVideos} disabled={!bulkDesc.trim()}>
Apply to all videos Apply to all videos
</button> </button>
{videosNeedingDesc > 0 {videosNeedingDesc > 0 ? (
? <small className="meta bad">{videosNeedingDesc} video(s) need a description</small> <small className="meta bad">{videosNeedingDesc} video(s) need a description</small>
: <small className="meta">All videos have descriptions</small> ) : (
} <small className="meta mono-wrap">All videos have descriptions</small>
)}
</div> </div>
</article> </article>
{sel.map((s,i)=>( {sel.map((s, i) => (
<article key={i} className="video-card"> <article key={i} className="video-card">
<div className="thumb-row"> <div className="thumb-row">
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} /> <PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
</div> </div>
<h4 className="filename ellipsis">{s.file.name}</h4> <h4 className="filename wrap-anywhere" title={s.file.name}>
{s.file.name}
</h4>
<label> <label>
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'} {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()} aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'} placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
value={s.desc} value={s.desc}
onChange={e=>{ onChange={(e) => {
const v=e.target.value 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)) setSel((old) => old.map((x, idx) => (idx === i ? { ...x, desc: v, finalName: composeName(x.date, v, x.file.name) } : x)))
}} }}
/> />
</label> </label>
@ -469,152 +531,211 @@ export default function Uploader(){
<input <input
type="date" type="date"
value={s.date} value={s.date}
onChange={e=>{ onChange={(e) => {
const v = e.target.value 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> </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>} {typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
{s.err && <small className="meta bad">Error: {s.err}</small>} {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> </article>
))} ))}
</> </>
)} )}
</section> </section>
{/* Folder (one-level manager) */} {/* Destination */}
<section> <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"> <div className="grid-3">
<label> <label>
Library Library (required)
<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)
<select <select
value={sub} value={lib}
onChange={e=>{ const v = clampOneLevel(e.target.value); setSub(v); void refresh(lib, v) }} onChange={(e) => {
disabled={!lib} const v = e.target.value
setLib(v)
setSub('')
if (v) void refresh(v, '')
}}
> >
<option value="">(Library root)</option> <option value=""> Select a library </option>
{rootDirs.map(d => <option key={d} value={d}>{d}</option>)} {libs.map((L) => (
<option key={L} value={L}>
{L}
</option>
))}
</select> </select>
</label> </label>
</div> </div>
<div className="grid-3"> {!lib ? (
<label> <p className="meta">Select a library to create subfolders and view contents.</p>
New subfolder name (optional) ) : (
<input <>
placeholder="letters, numbers, underscores, dashes" {/* New subfolder: free typing; sanitize on create */}
value={newFolder} <div className="grid-3">
onChange={e=> setNewFolder(sanitizeFolderName(e.target.value))} <label style={{ gridColumn: '1 / -1' }}>
disabled={!lib} Add a new subfolder (optional):
inputMode="text" <input
pattern="[A-Za-z0-9_.-]+" placeholder="letters, numbers, underscores, dashes"
title="Use letters, numbers, underscores, dashes or dots" value={newFolderRaw}
/> onChange={(e) => setNewFolderRaw(e.target.value)}
</label> inputMode="text"
/>
</label>
{Boolean(sanitizeFolderName(newFolder)) && ( {newFolderRaw.trim() && (
<div className="center-actions"> <div className="row-center" style={{ gridColumn: '1 / -1', marginTop: 8 }}>
<button <button
type="button" type="button"
onClick={()=>{ const n = sanitizeFolderName(newFolder); if (n) { void createSubfolder(n); setNewFolder('') } }} onClick={() => {
> void createSubfolder(newFolderRaw)
Create setNewFolderRaw('')
</button> }}
</div> >
)} Create
</div> </button>
</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. Youll 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>
)} )}
</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. 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>
)}
</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> </section>
<footer style={{display:'flex', gap:12, alignItems:'center', justifyContent:'space-between', marginTop:8}}> <footer style={{ display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
<small className="meta" aria-live="polite">{status}</small> <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 <button
type="button" type="button"
onClick={()=>{ const disabled = !lib || !sel.length || uploading || videosNeedingDesc>0; if (!disabled) void doUpload() }} onClick={() => {
disabled={!lib || !sel.length || uploading || videosNeedingDesc>0} 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> </button>
</footer> </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]
}

View File

@ -1,96 +1,34 @@
/* frontend/src/styles.css */ /* frontend/src/styles.css */
@import '@picocss/pico/css/pico.classless.min.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 */ /* Container */
main { max-width: 980px; margin: 0 auto; padding: 0 12px 24px; } main { max-width: 980px; margin: 0 auto; padding: 0 12px 24px; }
section { margin-block: 1rem; }
/* 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%; }
/* App bar */ /* App bar */
.app-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin: 12px 0 24px; } .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 { display:flex; align-items:center; gap:8px; font-size: 1.25rem; }
.brand .wing { font-size: 1.3em; line-height: 1; } .brand .wing { font-size: 1.3em; line-height: 1; }
/* Top row (Library / Subfolder / Date) */ /* Layout helpers */
.grid-3 { display: grid; gap: 12px; } .grid-3 { display: grid; gap: 12px; }
@media (min-width: 768px) { @media (min-width: 768px) {
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; } .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 { .video-card {
border: 1px solid var(--muted-border-color); border: 1px solid var(--muted-border-color);
border-radius: 12px; 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); } .preview-thumb { box-shadow: 0 1px 0 rgba(0,0,0,.12); }
.filename { margin: 0 0 4px; } .filename { margin: 0 0 4px; }
/* File pickers and form controls fill line width */ /* Utilities */
.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 */
.ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
small.meta { color: var(--muted-color); }
.bad { color: var(--del-color); } .bad { color: var(--del-color); }
.stretch { width: 100%; } .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 SubFolder 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;
}