large ui change

This commit is contained in:
Brad Stein 2025-09-16 07:37:10 -05:00
parent d0766e05cb
commit 19978d9302
3 changed files with 291 additions and 118 deletions

View File

@ -3,52 +3,54 @@
############################ ############################
# Frontend build (Vite/React) # Frontend build (Vite/React)
############################ ############################
FROM node:20-alpine AS fe # Run toolchains on the build machine, not target arch
FROM --platform=$BUILDPLATFORM node:20-alpine AS fe
WORKDIR /src/frontend WORKDIR /src/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci RUN --mount=type=cache,target=/root/.npm npm ci
COPY frontend/ . COPY frontend/ .
# default Vite outDir is "dist" under CWD
RUN npm run build RUN npm run build
# expose artifacts in a neutral location # expose artifacts in a neutral location
RUN mkdir -p /out && cp -r dist/* /out/ RUN mkdir -p /out && cp -r dist/* /out/
############################ ############################
# Backend build (Go) # Backend build (Go)
############################ ############################
FROM golang:1.22-alpine AS be FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS be
RUN apk add --no-cache ca-certificates upx git RUN apk add --no-cache ca-certificates upx git
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ ARG TARGETOS
ARG TARGETARCH
ENV CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
GOPROXY=https://proxy.golang.org,direct \ GOPROXY=https://proxy.golang.org,direct \
GOPRIVATE=scm.bstein.dev GOPRIVATE=scm.bstein.dev
WORKDIR /src/backend WORKDIR /src/backend
# 1) Copy mod files first for better caching, include both go.mod and go.sum # 1) Cache modules
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
# 2) Warm module cache
RUN --mount=type=cache,target=/go/pkg/mod go mod download RUN --mount=type=cache,target=/go/pkg/mod go mod download
# 3) Copy the rest of the backend sources # 2) Source
COPY backend/ . COPY backend/ .
# 4) Bring in the FE assets where the embed expects them # 3) FE assets where the embed expects them (//go:embed web/dist/**)
# (your code likely has: //go:embed web/dist/**)
COPY --from=fe /out ./web/dist COPY --from=fe /out ./web/dist
# 5) In case code imports added deps not present during step (1), tidy now # 4) Tidy (in case new deps appeared)
RUN --mount=type=cache,target=/go/pkg/mod go mod tidy RUN --mount=type=cache,target=/go/pkg/mod go mod tidy
# 6) Build the binary; fail if go build fails. Allow UPX to fail harmlessly. # 5) Build the binary; fail if build fails; allow UPX to fail only.
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \
go build -trimpath -ldflags="-s -w" -o /pegasus ./main.go && \ set -eux; \
upx -q --lzma /pegasus || true mkdir -p /out; \
go build -trimpath -ldflags="-s -w" -o /out/pegasus ./main.go; \
test -f /out/pegasus; \
upx -q --lzma /out/pegasus || true
############################ ############################
# Final, minimal image # Final, minimal image
############################ ############################
FROM gcr.io/distroless/static:nonroot AS final FROM gcr.io/distroless/static:nonroot AS final
COPY --from=be /pegasus /pegasus COPY --from=be /out/pegasus /pegasus
USER nonroot:nonroot USER nonroot:nonroot
ENTRYPOINT ["/pegasus"] ENTRYPOINT ["/pegasus"]

View File

@ -45,9 +45,19 @@ type loggingRW struct {
status int status int
} }
type listEntry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
}
func writeJSON(w http.ResponseWriter, v any) { func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v) if err := json.NewEncoder(w).Encode(v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} }
func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) } func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) }
@ -233,14 +243,6 @@ func main() {
// list entries // list entries
r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) { r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) {
type entry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
}
cl, err := internal.CurrentUser(r) cl, err := internal.CurrentUser(r)
if err != nil { if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
@ -248,11 +250,14 @@ func main() {
} }
rootRel, err := um.Resolve(cl.Username) rootRel, err := um.Resolve(cl.Username)
if err != nil { if err != nil {
internal.Logf("mkdir map missing for %q", cl.Username) internal.Logf("list: map missing for %q", cl.Username)
http.Error(w, "no mapping", http.StatusForbidden); return http.Error(w, "no mapping", http.StatusForbidden)
return
} }
q := strings.TrimPrefix(r.URL.Query().Get("path"), "/") q := strings.TrimPrefix(r.URL.Query().Get("path"), "/")
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
var dirAbs string var dirAbs string
if q == "" { if q == "" {
dirAbs = rootAbs dirAbs = rootAbs
@ -263,39 +268,35 @@ func main() {
return return
} }
} }
ents, err := os.ReadDir(dirAbs) ents, err := os.ReadDir(dirAbs)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
type entry struct {
Name string out := make([]listEntry, 0, len(ents))
Path string
IsDir bool
Size int64
Mtime int64
}
var out []entry
for _, d := range ents { for _, d := range ents {
info, _ := d.Info() info, _ := d.Info()
out = append(out, entry{
var size int64
if info != nil && !d.IsDir() {
size = info.Size()
}
var mtime int64
if info != nil {
mtime = info.ModTime().Unix()
}
out = append(out, listEntry{
Name: d.Name(), Name: d.Name(),
Path: filepath.Join(q, d.Name()), Path: filepath.Join(q, d.Name()),
IsDir: d.IsDir(), IsDir: d.IsDir(),
Size: func() int64 { Size: size,
if info != nil && !d.IsDir() { Mtime: mtime,
return info.Size()
}
return 0
}(),
Mtime: func() int64 {
if info != nil {
return info.ModTime().Unix()
}
return 0
}(),
}) })
} }
writeJSON(w, out) writeJSON(w, out)
}) })

View File

@ -1,13 +1,24 @@
import React, { useEffect, useRef, useState } from 'react' // frontend/src/Uploader.tsx
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { api, WhoAmI } from './api' import { api, WhoAmI } from './api'
import * as tus from 'tus-js-client' import * as tus from 'tus-js-client'
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number } type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string } type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string }
const [mobile, setMobile] = useState<boolean>(false)
useEffect(()=>{ setMobile(isLikelyMobile()) }, [])
// Simple bundle heartbeat so you can confirm the loaded JS is current // Simple bundle heartbeat so you can confirm the loaded JS is current
console.log('[Pegasus] FE bundle activated at', new Date().toISOString()) console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
function isLikelyMobile(): boolean {
if (typeof window === 'undefined') return false
const ua = navigator.userAgent || ''
const touch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
// catch Android/iOS/iPadOS
return touch || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
}
function sanitizeDesc(s:string){ function sanitizeDesc(s:string){
s = s.trim().replace(/[^A-Za-z0-9 _-]+/g,'_').replace(/\s+/g,'_') s = s.trim().replace(/[^A-Za-z0-9 _-]+/g,'_').replace(/\s+/g,'_')
if(!s) s = 'upload' if(!s) s = 'upload'
@ -19,37 +30,87 @@ function composeName(date:string, desc:string, orig:string){
const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}` const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}`
} }
// Type guard to narrow tus error // Narrow tus error
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))
} }
// Normalize API list rows (works with old CamelCase or new snake_case)
function normalizeRows(raw: any): FileRow[] {
if (!Array.isArray(raw)) return []
return raw.map((r: any) => ({
name: r?.name ?? r?.Name ?? '',
path: r?.path ?? r?.Path ?? '',
is_dir: typeof r?.is_dir === 'boolean' ? r.is_dir
: typeof r?.IsDir === 'boolean' ? r.IsDir
: false,
size: typeof r?.size === 'number' ? r.size : (typeof r?.Size === 'number' ? r.Size : 0),
mtime: typeof r?.mtime === 'number' ? r.mtime : (typeof r?.Mtime === 'number' ? r.Mtime : 0),
})).filter(r => r && typeof r.name === 'string' && typeof r.path === 'string')
}
// simple mobile detection (no CSS change needed)
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)')
const handler = (e: MediaQueryListEvent | MediaQueryList) =>
setIsMobile(('matches' in e ? e.matches : (e as MediaQueryList).matches))
handler(mq)
mq.addEventListener('change', handler as any)
return () => mq.removeEventListener('change', handler as any)
}, [])
return isMobile
}
export default function Uploader(){ export default function Uploader(){
const [me, setMe] = useState<WhoAmI|undefined>() const [me, setMe] = useState<WhoAmI|undefined>()
const [cwd, setCwd] = useState<string>('') // relative to user's mapped root const [libraries, setLibraries] = useState<string[]>([]) // from whoami.roots or [whoami.root]
const [selectedLib, setSelectedLib] = useState<string>('') // must be chosen to upload
const [subdir, setSubdir] = useState<string>('') // one-level subfolder name (no '/')
const [rows, setRows] = useState<FileRow[]>([]) const [rows, setRows] = useState<FileRow[]>([])
const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}` const destPath = `/${[selectedLib, subdir].filter(Boolean).join('/')}`
const [status, setStatus] = useState<string>('') const [status, setStatus] = useState<string>('')
const [globalDate, setGlobalDate] = useState<string>(new Date().toISOString().slice(0,10)) const [globalDate, setGlobalDate] = useState<string>(new Date().toISOString().slice(0,10))
const [uploading, setUploading] = useState<boolean>(false) const [uploading, setUploading] = useState<boolean>(false)
const [sel, setSel] = useState<Sel[]>([]) const [sel, setSel] = useState<Sel[]>([])
const folderInputRef = useRef<HTMLInputElement>(null) // to set webkitdirectory const folderInputRef = useRef<HTMLInputElement>(null) // to set webkitdirectory
const isMobile = useIsMobile()
// Enable directory selection on the folder picker (non-standard attr) // Enable directory selection on the folder picker (non-standard attr)
useEffect(() => { useEffect(() => {
const el = folderInputRef.current const el = folderInputRef.current
if (el) { if (!el) return
if (mobile) {
// ensure we don't accidentally force folder mode on mobile
el.removeAttribute('webkitdirectory')
el.removeAttribute('directory')
} else {
el.setAttribute('webkitdirectory','') el.setAttribute('webkitdirectory','')
el.setAttribute('directory','') el.setAttribute('directory','')
} }
}, []) }, [mobile])
// Fetch whoami + list
async function refresh(path=''){ async function refresh(path=''){
try { try {
const m = await api<WhoAmI>('/api/whoami'); setMe(m) const m = await api<any>('/api/whoami')
const list = await api<FileRow[]>('/api/list?path='+encodeURIComponent(path)) setMe(m as WhoAmI)
setRows(list); setCwd(path)
setStatus(`Ready · Destination: /${[m.root, path].filter(Boolean).join('/')}`) const libs: string[] =
Array.isArray(m?.roots) && m.roots.length ? m.roots.slice()
: m?.root ? [m.root] : []
setLibraries(libs)
// Auto-select if exactly one library
setSelectedLib(prev => prev || (libs.length === 1 ? libs[0] : ''))
const listRaw = await api<any>('/api/list?path='+encodeURIComponent(path))
const list = normalizeRows(listRaw)
setRows(list); setSubdir(path)
setStatus(`Ready · Destination: /${[libs[0] ?? '', path].filter(Boolean).join('/')}`)
console.log('[Pegasus] list ok', { path, count: list.length }) console.log('[Pegasus] list ok', { path, count: list.length })
} catch (e:any) { } catch (e:any) {
const msg = String(e?.message || e || '') const msg = String(e?.message || e || '')
@ -75,7 +136,7 @@ export default function Uploader(){
setSel(arr) setSel(arr)
} }
// When the global date changes, recompute per-file finalName (leave per-file date in place) // When the global date changes, recompute per-file finalName
useEffect(()=>{ 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])
@ -88,9 +149,12 @@ export default function Uploader(){
async function doUpload(){ async function doUpload(){
if(!me) { setStatus('Not signed in'); return } if(!me) { setStatus('Not signed in'); return }
if(!selectedLib){ alert('Please select a Library to upload to.'); return }
if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return } if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return }
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
@ -99,11 +163,16 @@ export default function Uploader(){
endpoint: '/tus/', endpoint: '/tus/',
chunkSize: 5*1024*1024, chunkSize: 5*1024*1024,
retryDelays: [0, 1000, 3000, 5000, 10000], retryDelays: [0, 1000, 3000, 5000, 10000],
resume: false, // ignore any old http:// resume URLs // Avoid resuming old HTTP URLs: don't store fingerprints, and use a random fingerprint so nothing matches
removeFingerprintOnSuccess: true, // keep storage clean going forward storeFingerprintForResuming: false,
removeFingerprintOnSuccess: true,
fingerprint: (async () => `${Date.now()}-${Math.random().toString(36).slice(2)}-${s.file.name}`) as any,
metadata: { metadata: {
filename: s.file.name, filename: s.file.name,
subdir: cwd || "", // Server treats this as a subdirectory under the user's mapped root.
// When backend supports multiple libraries, it will also need the selected library.
subdir: subdir || "",
date: s.date, // per-file YYYY-MM-DD date: s.date, // per-file YYYY-MM-DD
desc: s.desc // server composes final desc: s.desc // server composes final
}, },
@ -136,27 +205,27 @@ export default function Uploader(){
opts.withCredentials = true opts.withCredentials = true
const up = new tus.Upload(s.file, opts) const up = new tus.Upload(s.file, opts)
console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, cwd }) console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, subdir })
up.start() up.start()
}) })
} }
setStatus('All uploads complete') setStatus('All uploads complete')
setSel([]) setSel([])
await refresh(cwd) await refresh(subdir)
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
async function rename(oldp:string){ async function rename(oldp:string){
const name = prompt('New name (YYYY.MM.DD.description.ext):', oldp.split('/').pop()||''); if(!name) return const name = prompt('New name (YYYY.MM.DD.description.ext for files, or folder name):', oldp.split('/').pop()||''); 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({from:oldp, to: (oldp.split('/').slice(0,-1).concat(name)).join('/')}) body: JSON.stringify({from:oldp, to: (oldp.split('/').slice(0,-1).concat(name)).join('/')})
}) })
await refresh(cwd) await refresh(subdir)
} 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}`)
@ -167,7 +236,7 @@ export default function Uploader(){
if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return
try{ try{
await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' }) await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' })
await refresh(cwd) await refresh(subdir)
} 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}`)
@ -175,50 +244,111 @@ export default function Uploader(){
} }
function goUp(){ function goUp(){
const up = cwd.split('/').slice(0,-1).join('/') setSubdir(''); void refresh('')
setCwd(up); void refresh(up)
} }
// Precompute a safely sorted copy for rendering (no in-place mutation)
const rowsSorted = useMemo(() => {
const copy = Array.isArray(rows) ? [...rows] : []
return copy
.filter(r => r && typeof r.name === 'string')
.sort((a, b) => (Number(b.is_dir) - Number(a.is_dir)) || a.name.localeCompare(b.name))
}, [rows])
// Restrict to one-level: only allow "Open" when we're at library root (subdir === '')
const canOpenDeeper = subdir === ''
return (<> return (<>
<section className="card"> <section className="card">
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', gap:8, flexWrap:'wrap'}}> <div style={{display:'flex', justifyContent:'space-between', alignItems:'center', gap:8, flexWrap:'wrap'}}>
<div><b>Signed in:</b> {me?.username} · <span className="meta">root: /{me?.root}</span></div> <div><b>Signed in:</b> {me?.username}</div>
<div className="meta">Destination: <b>{destPath || '/(unknown)'}</b></div> <div className="meta">Destination: <b>{destPath || '/(select a library)'}</b></div>
</div> </div>
{/* Destination (Library + Subfolder) */}
<div className="grid" style={{alignItems:'end'}}> <div className="grid" style={{alignItems:'end'}}>
<div>
<label className="meta">Library</label>
{libraries.length <= 1 ? (
<div className="item" style={{padding:10}}>
<span>{libraries[0] || '(none)'}</span>
</div>
) : (
<select
value={selectedLib}
onChange={e=> setSelectedLib(e.target.value)}
style={{border:'1px solid #2a2f45', background:'#1c2138', color:'var(--fg)', borderRadius:8, padding:10, width:'100%'}}
>
<option value="" disabled>Choose a library</option>
{libraries.map(lib => <option key={lib} value={lib}>{lib}</option>)}
</select>
)}
</div>
<div> <div>
<label className="meta">Default date (applied to new selections)</label> <label className="meta">Default date (applied to new selections)</label>
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} /> <input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
</div> </div>
{/* Pickers: mobile vs desktop */}
<div style={{display:'flex', gap:8, flexWrap:'wrap'}}> <div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
{mobile ? (
// ---- Mobile: show a Gallery picker (no capture) + optional Camera quick-capture
<>
<div> <div>
<label className="meta">Gallery/Photos</label> <label className="meta">Gallery/Photos</label>
<input <input
type="file" type="file"
multiple multiple
// no "capture" -> browsers tend to open Photo/Media picker instead of camera or filesystem
accept="image/*,video/*" accept="image/*,video/*"
capture="environment" onChange={e => e.target.files && handleChoose(e.target.files)}
onChange={e=> e.target.files && handleChoose(e.target.files)}
/> />
</div> </div>
<div className="hide-on-mobile"> <div>
<label className="meta">Files/Folders</label> <label className="meta">Camera (optional)</label>
{/* set webkitdirectory/directory via ref to satisfy TS */} <input
type="file"
// keeping this separate provides a clear way to open the camera immediately if desired
accept="image/*,video/*"
capture="environment"
onChange={e => e.target.files && handleChoose(e.target.files)}
/>
</div>
</>
) : (
// ---- Desktop: show both Files and Folder(s) pickers
<>
<div>
<label className="meta">Select file(s)</label>
<input
type="file"
multiple
accept="image/*,video/*"
onChange={e => e.target.files && handleChoose(e.target.files)}
/>
</div>
<div>
<label className="meta">Select folder(s)</label>
{/* set webkitdirectory/directory via ref (done in useEffect) */}
<input <input
type="file" type="file"
multiple multiple
ref={folderInputRef} ref={folderInputRef}
onChange={e=> e.target.files && handleChoose(e.target.files)} onChange={e => e.target.files && handleChoose(e.target.files)}
/> />
</div> </div>
</>
)}
</div> </div>
{(() => { {(() => {
const uploadDisabled = !sel.length || uploading || sel.some(s=>!s.desc.trim()) const uploadDisabled =
!selectedLib || !sel.length || uploading || sel.some(s=>!s.desc.trim())
let disabledReason = '' let disabledReason = ''
if (!sel.length) disabledReason = 'Select at least one file.' if (!selectedLib) disabledReason = 'Pick a library.'
else if (!sel.length) disabledReason = 'Select at least one file.'
else if (uploading) disabledReason = 'Upload in progress…' else if (uploading) disabledReason = 'Upload in progress…'
else if (sel.some(s=>!s.desc.trim())) disabledReason = 'Add a short description for every file.' else if (sel.some(s=>!s.desc.trim())) disabledReason = 'Add a short description for every file.'
@ -243,6 +373,7 @@ export default function Uploader(){
})()} })()}
</div> </div>
{/* Per-file editor */}
{sel.length>0 && ( {sel.length>0 && (
<div className="card"> <div className="card">
<h4>Ready to upload</h4> <h4>Ready to upload</h4>
@ -287,31 +418,68 @@ export default function Uploader(){
<div className="meta" style={{marginTop:8}}>{status}</div> <div className="meta" style={{marginTop:8}}>{status}</div>
</section> </section>
{/* Destination details and subfolder management */}
<section className="card"> <section className="card">
<h3>Folder</h3> <h3>Destination</h3>
{rows.length === 0 ? (
{/* Current subfolder header & actions (one level only) */}
<div className="item" style={{display:'flex', gap:12, alignItems:'center', justifyContent:'space-between', flexWrap:'wrap'}}>
<div className="meta">
Library: <b>{selectedLib || '(none)'}</b>
{' · '}
Subfolder: <b>{subdir ? subdir : '(root)'}</b>
</div>
<div className="meta">
{subdir && (
<>
<a href="#" onClick={(e)=>{e.preventDefault(); void rename(subdir)}}>Rename subfolder</a>
{' · '}
<a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(subdir, true)}}>Delete subfolder</a>
{' · '}
</>
)}
{subdir && <a href="#" onClick={(e)=>{e.preventDefault(); goUp()}}> Up to library root</a>}
</div>
</div>
{/* Create subfolder (root only) */}
{!subdir && (
<CreateFolder
cwd={''}
onCreate={(p)=>{ setSubdir(p); void refresh(p) }}
/>
)}
{/* Listing */}
{rowsSorted.length === 0 ? (
<div className="item"> <div className="item">
<div className="meta"> <div className="meta">
No items to show here. Youll upload into <b>{destPath}</b>. No items to show here. Youll upload into <b>{destPath || '/(select a library)'}</b>.
</div> </div>
<CreateFolder cwd={cwd} onCreate={(p)=>{ setCwd(p); void refresh(p) }} />
</div> </div>
) : ( ) : (
<div className="grid"> <div className="grid">
{cwd && ( {/* Only show "Open" when at library root to enforce one-level depth */}
<div className="item"> {rowsSorted.map(f =>
<a className="name" href="#" onClick={(e)=>{e.preventDefault(); goUp()}}> Up</a>
</div>
)}
{rows.sort((a,b)=> (Number(b.is_dir)-Number(a.is_dir)) || a.name.localeCompare(b.name)).map(f=>
<div key={f.path} className="item"> <div key={f.path} className="item">
<div className="name">{f.is_dir?'📁':'🎞️'} {f.name}</div> <div className="name">{f.is_dir?'📁':'🎞️'} {f.name}</div>
<div className="meta">{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}</div> <div className="meta">{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}</div>
<div className="meta"> <div className="meta">
{f.is_dir {f.is_dir ? (
? (<><a href="#" onClick={(e)=>{e.preventDefault(); setCwd(f.path); void refresh(f.path)}}>Open</a> · <a href="#" onClick={(e)=>{e.preventDefault(); void rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(f.path,true)}}>Delete</a></>) <>
: (<><a href="#" onClick={(e)=>{e.preventDefault(); void rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(f.path,false)}}>Delete</a></>) {canOpenDeeper
? <a href="#" onClick={(e)=>{e.preventDefault(); setSubdir(f.path); void refresh(f.path)}}>Open</a>
: <span className="meta">Open (disabled)</span>
} }
{' · '}<a href="#" onClick={(e)=>{e.preventDefault(); void rename(f.path)}}>Rename</a>
{' · '}<a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(f.path,true)}}>Delete</a>
</>
) : (
<>
<a href="#" onClick={(e)=>{e.preventDefault(); void rename(f.path)}}>Rename</a>
{' · '}<a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(f.path,false)}}>Delete</a>
</>
)}
</div> </div>
</div> </div>
)} )}
@ -326,7 +494,9 @@ function fmt(n:number){ if(n<1024) return n+' B'; const u=['KB','MB','GB','TB'];
function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void }) { function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void }) {
const [name, setName] = React.useState(''); const [name, setName] = React.useState('');
async function submit() { async function submit() {
const clean = name.trim().replace(/[\/]+/g,'/').replace(/^\//,'').replace(/[^\w\-\s.]/g,'_'); // single-level only
let clean = name.trim().replace(/[\/]+/g,'').replace(/[^\w\-\s.]/g,'_')
clean = clean.replace(/\s+/g,'_')
if (!clean) return; if (!clean) return;
const path = [cwd, clean].filter(Boolean).join('/'); const path = [cwd, clean].filter(Boolean).join('/');
console.log('[Pegasus] mkdir click', { path }) console.log('[Pegasus] mkdir click', { path })
@ -341,7 +511,7 @@ function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void
} }
return ( return (
<div style={{marginTop:10, display:'flex', gap:8, alignItems:'center'}}> <div style={{marginTop:10, display:'flex', gap:8, alignItems:'center'}}>
<input placeholder="New folder name" value={name} onChange={e=>setName(e.target.value)} /> <input placeholder="New subfolder name (one-level)" value={name} onChange={e=>setName(e.target.value)} />
<button type="button" className="btn" onClick={submit} disabled={!name.trim()}>Create</button> <button type="button" className="btn" onClick={submit} disabled={!name.trim()}>Create</button>
</div> </div>
); );