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

@ -8,11 +8,17 @@ 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

@ -20,12 +20,29 @@ function sanitizeFolderName(s:string){
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) {
const i = n.lastIndexOf('.')
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
}
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) { function composeName(date: string, desc: string, orig: string) {
const d = date || new Date().toISOString().slice(0, 10) const d = date || new Date().toISOString().slice(0, 10)
const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}` 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 clampOneLevel(p:string){ if(!p) return ''; return p.replace(/^\/+|\/+$/g,'').split('/')[0] || '' }
function normalizeRows(listRaw: any[]): FileRow[] { function normalizeRows(listRaw: any[]): FileRow[] {
return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({ return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({
name: r?.name ?? r?.Name ?? '', name: r?.name ?? r?.Name ?? '',
@ -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,8 +81,7 @@ 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)}`
@ -71,14 +89,23 @@ const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().
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 ----------
@ -87,7 +114,7 @@ export default function Uploader(){
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[]>([])
@ -100,6 +127,9 @@ export default function Uploader(){
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
@ -114,27 +144,26 @@ export default function Uploader(){
// 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')
setMe(m as any)
const mm: any = m 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('/')
} }
} }
@ -142,8 +171,13 @@ export default function Uploader(){
}, []) }, [])
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)
@ -156,9 +190,13 @@ export default function Uploader(){
}, [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
.filter((e) => e.is_dir)
.map((e) => e.name)
.filter(Boolean)
setRootDirs(rootDirNames.sort((a, b) => a.localeCompare(b))) 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)
@ -172,42 +210,52 @@ export default function Uploader(){
} }
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
@ -226,9 +274,9 @@ export default function Uploader(){
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)
@ -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()
@ -280,7 +328,7 @@ export default function Uploader(){
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)
@ -297,9 +345,9 @@ export default function Uploader(){
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) {
@ -311,7 +359,7 @@ export default function Uploader(){
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) {
@ -322,13 +370,14 @@ export default function Uploader(){
// 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)
if (!name) 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: 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) {
@ -339,7 +388,9 @@ export default function Uploader(){
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)
@ -351,18 +402,31 @@ export default function Uploader(){
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,53 +434,52 @@ 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>
@ -426,19 +489,16 @@ export default function Uploader(){
<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>
@ -448,7 +508,9 @@ export default function Uploader(){
<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,67 +531,80 @@ 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>
{!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"> <div className="grid-3">
<label> <label style={{ gridColumn: '1 / -1' }}>
New subfolder name (optional) Add a new subfolder (optional):
<input <input
placeholder="letters, numbers, underscores, dashes" placeholder="letters, numbers, underscores, dashes"
value={newFolder} value={newFolderRaw}
onChange={e=> setNewFolder(sanitizeFolderName(e.target.value))} onChange={(e) => setNewFolderRaw(e.target.value)}
disabled={!lib}
inputMode="text" inputMode="text"
pattern="[A-Za-z0-9_.-]+"
title="Use letters, numbers, underscores, dashes or dots"
/> />
</label> </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)
setNewFolderRaw('')
}}
> >
Create Create
</button> </button>
@ -537,32 +612,53 @@ export default function Uploader(){
)} )}
</div> </div>
<article> {/* Current selection with actions; includes Home button to go to root */}
<div style={{display:'flex', gap:8, alignItems:'center', justifyContent:'space-between'}}> <article className="card">
<div>Current: <strong>{sub || '(library root)'}</strong></div> <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 && ( {sub && (
<div style={{display:'flex', gap:8}}> <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 renameFolder(sub)}>Rename</button>
<button onClick={() => void deleteFolder(sub)} className="bad">Delete</button> <button onClick={() => void deleteFolder(sub)} className="bad">Delete</button>
<button onClick={() => { setSub(''); void refresh(lib, '') }}>Clear</button> <button onClick={() => { setSub(''); void refresh(lib, '') }}>Clear</button>
</div> </div>
)} )}
</div>
</article> </article>
{/* Subfolders: only Select (right), name + icon (left); clamp width to avoid horizontal overflow */}
<details open style={{ marginTop: 8 }}> <details open style={{ marginTop: 8 }}>
<summary>Subfolders</summary> <summary>Subfolders</summary>
{rootDirs.length === 0 ? ( {rootDirs.length === 0 ? (
<p className="meta">No subfolders yet. Youll upload into <b>/{lib}</b>.</p> <p className="meta">
No subfolders yet. Youll upload into <b>/{lib}</b>.
</p>
) : ( ) : (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{rootDirs.map(d=>( {rootDirs.map((d) => (
<article key={d} style={{display:'flex', alignItems:'center', justifyContent:'space-between'}}> <article key={d} className="list-row">
<div>📁 {d}</div> <div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div style={{display:'flex', gap:8}}> <div className="wrap-anywhere" title={d}>📁 {d}</div>
<button onClick={()=>{ setSub(d); void refresh(lib, d) }}>Select</button> </div>
<button onClick={()=> void renameFolder(d)}>Rename</button> <div className="row-right">
<button onClick={()=> void deleteFolder(d)} className="bad">Delete</button> <button
onClick={() => {
setSub(d)
void refresh(lib, d)
}}
>
Select
</button>
</div> </div>
</article> </article>
))} ))}
@ -570,45 +666,61 @@ export default function Uploader(){
)} )}
</details> </details>
{/* Contents: info left (clamped), actions right */}
<details open style={{ marginTop: 8 }}> <details open style={{ marginTop: 8 }}>
<summary>Contents of {destPath || '/(choose)'}</summary> <summary>Contents of {destPath || '/(choose)'}</summary>
{sortedRows.length === 0 ? ( {sortedRows.length === 0 ? (
<p className="meta">Empty.</p> <p className="meta">Empty.</p>
) : ( ) : (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{sortedRows.map(f => {sortedRows.map((f) => (
<article key={f.path} style={{display:'flex', alignItems:'center', justifyContent:'space-between'}}> <article key={f.path} className="list-row">
<div> <div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="ellipsis">{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}</div> <div className="wrap-anywhere" title={f.name || '(unnamed)'}>{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> <small className="meta meta-wrap">{f.is_dir ? 'folder' : fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}</small>
</div> </div>
<div style={{display:'flex', gap:8}}> <div className="row-right">
{f.is_dir ? ( {f.is_dir ? (
<> <button
<button onClick={()=>{ setSub(f.name); void refresh(lib, f.name) }}>Open</button> onClick={() => {
<button onClick={()=> void renameFolder(f.name)}>Rename</button> setSub(f.name)
<button onClick={()=> void deleteFolder(f.name)} className="bad">Delete</button> void refresh(lib, f.name)
</> }}
>
Open
</button>
) : ( ) : (
<> <>
{sub === '' && <button onClick={() => void renamePath(f.path)}>Rename</button>} {sub === '' && <button onClick={() => void renamePath(f.path)}>Rename</button>}
<button onClick={()=> void deletePath(f.path, false)} className="bad">Delete</button> <button onClick={() => void deletePath(f.path, false)} className="bad">
Delete
</button>
</> </>
)} )}
</div> </div>
</article> </article>
)} ))}
</div> </div>
)} )}
</details> </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>
@ -617,4 +729,13 @@ export default function Uploader(){
) )
} }
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;
}