many fixes, hopefully uploading
This commit is contained in:
parent
4456e07fe2
commit
04d1e28f1f
@ -1,3 +1,4 @@
|
||||
// backend/internal/auth.go
|
||||
package internal
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// backend/internal/debuglog.go
|
||||
package internal
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// backend/internal/fs.go
|
||||
package internal
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// backend/internal/jellyfin.go
|
||||
package internal
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// backend/internal/naming.go
|
||||
package internal
|
||||
|
||||
import (
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// backend/internal/session.go
|
||||
package internal
|
||||
|
||||
import (
|
||||
@ -43,5 +44,14 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
|
||||
}
|
||||
|
||||
func ClearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Expires: time.Unix(0,0), Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Expires: time.Unix(0,0),
|
||||
MaxAge: -1,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// backend/internal/usermap.go
|
||||
package internal
|
||||
|
||||
import (
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"scm.bstein.dev/bstein/Pegasus/backend/internal"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
var webFS embed.FS
|
||||
|
||||
var (
|
||||
@ -32,6 +33,12 @@ var (
|
||||
jf = internal.NewJellyfin()
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev" // set via -ldflags "-X main.version=1.2.0 -X main.git=$(git rev-parse --short HEAD) -X main.builtAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
git = ""
|
||||
builtAt = ""
|
||||
)
|
||||
|
||||
type loggingRW struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
@ -42,11 +49,19 @@ func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.Wr
|
||||
func main() {
|
||||
internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot)
|
||||
|
||||
if os.Getenv("PEGASUS_SESSION_KEY") == "" {
|
||||
log.Fatal("PEGASUS_SESSION_KEY is not set")
|
||||
}
|
||||
|
||||
um, err := internal.LoadUserMap(userMapFile)
|
||||
must(err, "load user map")
|
||||
|
||||
// === tusd setup (resumable uploads) ===
|
||||
store := filestore.FileStore{Path: tusDir}
|
||||
// ensure upload scratch dir exists
|
||||
if err := os.MkdirAll(tusDir, 0o755); err != nil {
|
||||
log.Fatalf("mkdir %s: %v", tusDir, err)
|
||||
}
|
||||
locker := memorylocker.New()
|
||||
composer := tusd.NewStoreComposer()
|
||||
store.UseIn(composer)
|
||||
@ -58,7 +73,7 @@ func main() {
|
||||
StoreComposer: composer,
|
||||
NotifyCompleteUploads: true,
|
||||
// CompleteUploads: completeC,
|
||||
MaxSize: 0, // unlimited
|
||||
MaxSize: 4 * 1024 * 1024 * 1024, // 4GB per file
|
||||
}
|
||||
tusHandler, err := tusd.NewUnroutedHandler(config)
|
||||
must(err, "init tus handler")
|
||||
@ -132,6 +147,19 @@ func main() {
|
||||
r := chi.NewRouter()
|
||||
r.Use(corsForTus)
|
||||
|
||||
// health/version
|
||||
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
r.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"version": version,
|
||||
"git": git,
|
||||
"built_at": builtAt,
|
||||
})
|
||||
})
|
||||
|
||||
// auth
|
||||
r.Post("/api/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var f struct {
|
||||
@ -308,16 +336,38 @@ func main() {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||
})
|
||||
|
||||
// mount tus (behind auth)
|
||||
r.Route("/tus", func(rt chi.Router) {
|
||||
rt.Use(sessionRequired)
|
||||
rt.Post("/*", tusHandler.PostFile)
|
||||
rt.Head("/*", tusHandler.HeadFile)
|
||||
rt.Patch("/*", tusHandler.PatchFile)
|
||||
rt.Delete("/*", tusHandler.DelFile)
|
||||
rt.Get("/*", tusHandler.GetFile) // optional
|
||||
// mkdir (create subdirectory under the user's mapped root)
|
||||
r.Post("/api/mkdir", func(w http.ResponseWriter, r *http.Request) {
|
||||
cl, err := internal.CurrentUser(r)
|
||||
if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return }
|
||||
var p struct{ Path string `json:"path"` } // e.g., "Trips/2025-09"
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest); return
|
||||
}
|
||||
rootRel, err := um.Resolve(cl.Username)
|
||||
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
||||
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
||||
abs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.Path, "/"))
|
||||
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
||||
if internal.DryRun {
|
||||
internal.Logf("[DRY] mkdir -p %s", abs)
|
||||
} else if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError); return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||
})
|
||||
|
||||
// mount tus (behind auth)
|
||||
r.Route("/tus", func(rt chi.Router) {
|
||||
rt.Use(sessionRequired)
|
||||
// Create upload: POST /tus/
|
||||
rt.Post("/", tusHandler.PostFile)
|
||||
// Upload resource endpoints: /tus/{id}
|
||||
rt.Head("/{id}", tusHandler.HeadFile)
|
||||
rt.Patch("/{id}", tusHandler.PatchFile)
|
||||
rt.Delete("/{id}", tusHandler.DelFile)
|
||||
rt.Get("/{id}", tusHandler.GetFile) // optional
|
||||
})
|
||||
// metrics
|
||||
r.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
@ -327,8 +377,8 @@ func main() {
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
static.ServeHTTP(w, r)
|
||||
})
|
||||
// catch-all (must be last)
|
||||
r.Handle("/*", http.StripPrefix("/", static))
|
||||
// catch-all for SPA routes, GET only
|
||||
r.Method("GET", "/*", http.StripPrefix("/", static))
|
||||
|
||||
// debug endpoints (registered before server starts)
|
||||
r.Get("/debug/env", func(w http.ResponseWriter, r *http.Request) {
|
||||
@ -367,6 +417,10 @@ func main() {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"wrote": test})
|
||||
})
|
||||
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "no route for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
||||
})
|
||||
|
||||
// ---- wrap router with verbose request logging in debug ----
|
||||
root := http.Handler(r)
|
||||
if internal.Debug {
|
||||
@ -399,14 +453,21 @@ func sessionRequired(next http.Handler) http.Handler {
|
||||
|
||||
func corsForTus(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Allow tus headers for resumable uploads
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
// CORS for tus (preflight must succeed; cookies allowed)
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin) // cannot be * when credentials=true
|
||||
w.Header().Set("Vary", "Origin")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Location, Upload-Offset, Upload-Length, Tus-Resumable")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,HEAD,PATCH,DELETE,OPTIONS")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
w.Header().Set("Access-Control-Allow-Headers",
|
||||
"Content-Type, Tus-Resumable, Upload-Length, Upload-Defer-Length, Upload-Metadata, Upload-Offset, Upload-Concat, Upload-Checksum, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Expose-Headers",
|
||||
"Location, Upload-Offset, Upload-Length, Tus-Resumable, Upload-Checksum")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// frontend/src/App.tsx
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { api } from './api'
|
||||
import Login from './Login'
|
||||
@ -9,7 +10,7 @@ export default function App(){
|
||||
useEffect(()=>{ api('/api/whoami').then(()=>setAuthed(true)).catch(()=>setAuthed(false)) }, [])
|
||||
return (
|
||||
<>
|
||||
<header><h1>🪽 Pegasus</h1>{authed && <button className="btn" onClick={()=> api('/api/logout',{method:'POST'}).then(()=>location.reload())}>Logout</button>}</header>
|
||||
<header><h1>🪽 Pegasus</h1>{authed && <button className="btn" onClick={()=> api('/api/logout',{method:'POST'}).then(()=>location.replace('/'))}>Logout</button>}</header>
|
||||
<main>{authed ? <Uploader/> : <Login onLogin={()=>setAuthed(true)} /> }</main>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// frontend/src/Login.tsx
|
||||
import React, { useState } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
// frontend/src/Uploader.tsx
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { api, WhoAmI } from './api'
|
||||
import * as tus from 'tus-js-client'
|
||||
|
||||
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
|
||||
type Sel = { file: File; desc: string; finalName: string }
|
||||
type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string }
|
||||
|
||||
function sanitizeDesc(s:string){
|
||||
s = s.trim().replace(/[^A-Za-z0-9 _-]+/g,'_').replace(/\s+/g,'_')
|
||||
@ -16,16 +17,39 @@ function composeName(date:string, desc:string, orig:string){
|
||||
const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}`
|
||||
}
|
||||
|
||||
// Type guard to narrow tus error
|
||||
function isDetailedError(e: unknown): e is tus.DetailedError {
|
||||
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any))
|
||||
}
|
||||
|
||||
export default function Uploader(){
|
||||
const [me, setMe] = useState<WhoAmI|undefined>()
|
||||
const [cwd, setCwd] = useState<string>('')
|
||||
const [rows, setRows] = useState<FileRow[]>([])
|
||||
const [cwd, setCwd] = useState<string>('') // relative to user's mapped root
|
||||
const [rows, setRows] = useState<FileRow[]>([]) // <-- missing before
|
||||
const destPath = `/${[me?.root, cwd].filter(Boolean).join('/')}`
|
||||
const [status, setStatus] = useState<string>('')
|
||||
const [date, setDate] = 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 [sel, setSel] = useState<Sel[]>([])
|
||||
const folderInputRef = useRef<HTMLInputElement>(null) // to set webkitdirectory
|
||||
|
||||
async function refresh(path=''){ const m = await api<WhoAmI>('/api/whoami'); setMe(m)
|
||||
const list = await api<FileRow[]>('/api/list?path='+encodeURIComponent(path)); setRows(list); setCwd(path)
|
||||
// enable directory selection on the folder picker (non-standard attr)
|
||||
useEffect(() => {
|
||||
const el = folderInputRef.current
|
||||
if (el) {
|
||||
el.setAttribute('webkitdirectory','')
|
||||
el.setAttribute('directory','')
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function refresh(path=''){
|
||||
try {
|
||||
const m = await api<WhoAmI>('/api/whoami'); setMe(m)
|
||||
const list = await api<FileRow[]>('/api/list?path='+encodeURIComponent(path))
|
||||
setRows(list); setCwd(path)
|
||||
} catch (e: any) {
|
||||
setStatus(`List error: ${e.message || e}`)
|
||||
}
|
||||
}
|
||||
useEffect(()=>{ refresh('') }, [])
|
||||
|
||||
@ -33,39 +57,68 @@ export default function Uploader(){
|
||||
const arr = Array.from(files).map(f=>{
|
||||
const base = (f as any).webkitRelativePath || f.name
|
||||
const name = base.split('/').pop() || f.name
|
||||
const guess = name.replace(/\.[^/.]+$/,'').replace(/[_-]+/g,' ')
|
||||
const desc = sanitizeDesc(guess)
|
||||
return { file:f, desc, finalName: composeName(date, desc, name) }
|
||||
const desc = '' // start empty; user must fill before upload
|
||||
return { file:f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
|
||||
})
|
||||
setSel(arr)
|
||||
}
|
||||
useEffect(()=>{ setSel(old => old.map(x=> ({...x, finalName: composeName(date, x.desc, x.file.name)}))) }, [date])
|
||||
|
||||
useEffect(()=>{
|
||||
setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)})))
|
||||
}, [globalDate])
|
||||
|
||||
useEffect(()=>{
|
||||
const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } }
|
||||
window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [uploading])
|
||||
|
||||
async function doUpload(){
|
||||
if(!me) return
|
||||
if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return }
|
||||
setStatus('')
|
||||
setStatus('Starting upload...')
|
||||
setUploading(true)
|
||||
for(const s of sel){
|
||||
await new Promise<void>((resolve,reject)=>{
|
||||
const up = new tus.Upload(s.file, {
|
||||
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
|
||||
endpoint: '/tus/',
|
||||
chunkSize: 5*1024*1024,
|
||||
retryDelays: [0, 1000, 3000, 5000, 10000],
|
||||
withCredentials: true,
|
||||
metadata: {
|
||||
filename: s.file.name,
|
||||
subdir: cwd || "",
|
||||
date, // YYYY-MM-DD
|
||||
desc: s.desc // server composes YYYY.MM.DD.desc.ext
|
||||
date: s.date, // per-file YYYY-MM-DD
|
||||
desc: s.desc // server composes final
|
||||
},
|
||||
onError: (err)=>{ setStatus(`✖ ${s.file.name}: ${err}`); reject(err) },
|
||||
onProgress: (sent, total)=>{ const pct = Math.floor(sent/total*100); setStatus(`⬆ ${s.finalName}: ${pct}%`) },
|
||||
onSuccess: ()=>{ setStatus(`✔ ${s.finalName} uploaded`); resolve() },
|
||||
})
|
||||
onError: (err: Error | tus.DetailedError)=>{
|
||||
let msg = String(err)
|
||||
if (isDetailedError(err)) {
|
||||
const status = (err.originalRequest as any)?.status
|
||||
const statusText = (err.originalRequest as any)?.statusText
|
||||
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
|
||||
}
|
||||
setSel(old => old.map(x => x.file===s.file ? ({...x, err: msg}) : x))
|
||||
setStatus(`✖ ${s.file.name}: ${msg}`)
|
||||
alert(`Upload failed: ${s.file.name}\n\n${msg}`)
|
||||
reject(err as any)
|
||||
},
|
||||
onProgress: (sent: number, total: number)=>{
|
||||
const pct = Math.floor(sent/Math.max(total,1)*100)
|
||||
setSel(old => old.map(x => x.file===s.file ? ({...x, progress: pct}) : x))
|
||||
setStatus(`⬆ ${s.finalName}: ${pct}%`)
|
||||
},
|
||||
onSuccess: ()=>{
|
||||
setSel(old => old.map(x => x.file===s.file ? ({...x, progress: 100, err: undefined}) : x))
|
||||
setStatus(`✔ ${s.finalName} uploaded`); resolve()
|
||||
},
|
||||
}
|
||||
// ensure cookie is sent even if your tus typings don’t have this field
|
||||
opts.withCredentials = true
|
||||
|
||||
const up = new tus.Upload(s.file, opts)
|
||||
up.start()
|
||||
})
|
||||
}
|
||||
setSel([]); refresh(cwd)
|
||||
setUploading(false); setSel([]); refresh(cwd); setStatus('All uploads complete')
|
||||
}
|
||||
|
||||
async function rename(oldp:string){
|
||||
@ -78,23 +131,42 @@ export default function Uploader(){
|
||||
await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' })
|
||||
refresh(cwd)
|
||||
}
|
||||
function goUp(){
|
||||
const up = cwd.split('/').slice(0,-1).join('/')
|
||||
setCwd(up); refresh(up)
|
||||
}
|
||||
|
||||
return (<>
|
||||
<section className="card">
|
||||
<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 className="meta">{cwd ? `/${cwd}` : 'Choose a folder below'}</div>
|
||||
<div className="meta">Destination: <b>{destPath || '/(unknown)'}</b></div>
|
||||
</div>
|
||||
<div className="grid" style={{alignItems:'end'}}>
|
||||
<div>
|
||||
<label className="meta">Date (auto‑applied)</label>
|
||||
<input type="date" value={date} onChange={e=> setDate(e.target.value)} />
|
||||
<label className="meta">Default date (applied to new selections)</label>
|
||||
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="meta">Pick files or folders</label>
|
||||
<input type="file" multiple webkitdirectory="true" onChange={e=> e.target.files && handleChoose(e.target.files)} />
|
||||
<div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
|
||||
<div>
|
||||
<label className="meta">Gallery/Photos</label>
|
||||
<input type="file" multiple accept="image/*,video/*" capture="environment"
|
||||
onChange={e=> e.target.files && handleChoose(e.target.files)} />
|
||||
</div>
|
||||
<div className="hide-on-mobile">
|
||||
<label className="meta">Files/Folders</label>
|
||||
{/* set webkitdirectory/directory via ref to satisfy TS */}
|
||||
<input type="file" multiple ref={folderInputRef}
|
||||
onChange={e=> e.target.files && handleChoose(e.target.files)} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn" onClick={doUpload} disabled={!sel.length}>Upload {sel.length? `(${sel.length})` : ''}</button>
|
||||
<button className="btn" onClick={doUpload}
|
||||
disabled={!sel.length || uploading || sel.some(s=>!s.desc.trim())}>
|
||||
Upload {sel.length? `(${sel.length})` : ''}
|
||||
</button>
|
||||
{sel.length>0 && sel.some(s=>!s.desc.trim()) && (
|
||||
<div className="meta bad">Add a short description for every file to enable Upload.</div>
|
||||
)}
|
||||
</div>
|
||||
{sel.length>0 && (
|
||||
<div className="card">
|
||||
@ -103,10 +175,23 @@ export default function Uploader(){
|
||||
{sel.map((s,i)=>(
|
||||
<div key={i} className="item">
|
||||
<div className="meta">{s.file.name}</div>
|
||||
<input value={s.desc} onChange={e=> {
|
||||
const v=e.target.value; setSel(old => old.map((x,idx)=> idx===i ? ({...x, desc:v, finalName: composeName(date, v, x.file.name)}) : x ))
|
||||
<input value={s.desc} placeholder="Short description (required)" onChange={e=> {
|
||||
const v=e.target.value
|
||||
setSel(old => old.map((x,idx)=> idx===i
|
||||
? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)})
|
||||
: x ))
|
||||
}} />
|
||||
<div style={{display:'grid', gridTemplateColumns:'auto 1fr', gap:8, alignItems:'center', marginTop:6}}>
|
||||
<span className="meta">Date</span>
|
||||
<input type="date" value={s.date} onChange={e=>{
|
||||
const v = e.target.value; setSel(old => old.map((x,idx)=> idx===i ? ({...x, date:v, finalName: composeName(v, x.desc, x.file.name)}) : x))
|
||||
}} />
|
||||
</div>
|
||||
<div className="meta">→ {s.finalName}</div>
|
||||
{typeof s.progress === 'number' && (<>
|
||||
<progress max={100} value={s.progress}></progress>
|
||||
{s.err && <div className="meta bad">Error: {s.err}</div>}
|
||||
</>)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -117,10 +202,18 @@ export default function Uploader(){
|
||||
|
||||
<section className="card">
|
||||
<h3>Folder</h3>
|
||||
{rows.length === 0 ? (
|
||||
<div className="item">
|
||||
<div className="meta">
|
||||
No items to show here. You’ll upload into <b>{destPath}</b>.
|
||||
</div>
|
||||
<CreateFolder cwd={cwd} onCreate={(p)=>{ setCwd(p); refresh(p) }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{cwd && (
|
||||
<div className="item">
|
||||
<a className="name" href="#" onClick={()=> setCwd(cwd.split('/').slice(0,-1).join('/')) || refresh(cwd.split('/').slice(0,-1).join('/'))}>⬆ Up</a>
|
||||
<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=>
|
||||
@ -129,14 +222,34 @@ export default function Uploader(){
|
||||
<div className="meta">{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}</div>
|
||||
<div className="meta">
|
||||
{f.is_dir
|
||||
? (<><a href="#" onClick={()=>{ setCwd(f.path); refresh(f.path) }}>Open</a> · <a href="#" onClick={()=>rename(f.path)}>Rename</a> · <a className="bad" href="#" onClick={()=>del(f.path,true)}>Delete</a></>)
|
||||
: (<><a href="#" onClick={()=>rename(f.path)}>Rename</a> · <a className="bad" href="#" onClick={()=>del(f.path,false)}>Delete</a></>)
|
||||
? (<><a href="#" onClick={(e)=>{e.preventDefault(); setCwd(f.path); refresh(f.path)}}>Open</a> · <a href="#" onClick={(e)=>{e.preventDefault(); rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); del(f.path,true)}}>Delete</a></>)
|
||||
: (<><a href="#" onClick={(e)=>{e.preventDefault(); rename(f.path)}}>Rename</a> · <a className="bad" href="#" onClick={(e)=>{e.preventDefault(); del(f.path,false)}}>Delete</a></>)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>)
|
||||
}
|
||||
|
||||
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 CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void }) {
|
||||
const [name, setName] = React.useState('');
|
||||
async function submit() {
|
||||
const clean = name.trim().replace(/[\/]+/g,'/').replace(/^\//,'').replace(/[^\w\-\s.]/g,'_');
|
||||
if (!clean) return;
|
||||
const path = [cwd, clean].filter(Boolean).join('/');
|
||||
await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) });
|
||||
onCreate(path);
|
||||
setName('');
|
||||
}
|
||||
return (
|
||||
<div style={{marginTop:10, display:'flex', gap:8, alignItems:'center'}}>
|
||||
<input placeholder="New folder name" value={name} onChange={e=>setName(e.target.value)} />
|
||||
<button className="btn" onClick={submit} disabled={!name.trim()}>Create</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// frontend/src/api.ts
|
||||
export async function api<T=any>(path: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(path, { credentials:'include', ...init });
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// frontend/src/main.tsx
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
/* frontend/src/styles.css */
|
||||
:root{--bg:#0f1222;--fg:#e8e8f0;--muted:#9aa0a6;--card:#171a2e;--accent:#7aa2f7;--bad:#ef5350;--good:#66bb6a}
|
||||
*{box-sizing:border-box}
|
||||
html,body,#root{height:100%}
|
||||
@ -7,10 +8,17 @@ h1{font-size:18px;margin:0}
|
||||
main{max-width:960px;margin:0 auto;padding:16px}
|
||||
.card{background:var(--card);border:1px solid #242847;border-radius:12px;padding:14px;margin:10px 0}
|
||||
.btn{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px}
|
||||
input[type=file]::-webkit-file-upload-button{padding:10px;border-radius:8px}
|
||||
input[type=file]{padding:10px}
|
||||
.hide-on-mobile{display:block}
|
||||
input[type=file],input[type=text],input[type=password]{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px;width:100%}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
|
||||
.item{padding:12px;border:1px solid #2a2f45;border-radius:10px;background:#1a1e34}
|
||||
.meta{font-size:12px;color:var(--muted)}
|
||||
progress{width:100%;height:8px}
|
||||
.bad{color:var(--bad)} .good{color:var(--good)}
|
||||
@media (max-width:640px){ header{padding:12px} .grid{grid-template-columns:1fr 1fr} }
|
||||
@media (max-width:640px){ header{padding:12px}
|
||||
.grid{grid-template-columns:1fr}
|
||||
.hide-on-mobile{display:none}
|
||||
.btn{padding:14px 16px}
|
||||
}
|
||||
|
||||
9
frontend/src/types/tus-augment.d.ts
vendored
Normal file
9
frontend/src/types/tus-augment.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
// frontend/src/types/tus-augment.d.ts
|
||||
// Augment tus-js-client to include withCredentials in UploadOptions
|
||||
import 'tus-js-client'
|
||||
declare module 'tus-js-client' {
|
||||
interface UploadOptions {
|
||||
/** When true, set XHR.withCredentials so cookies are sent with CORS requests */
|
||||
withCredentials?: boolean;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user