it works again

This commit is contained in:
Brad Stein 2025-09-16 23:06:40 -05:00
parent 19978d9302
commit 2e856c0b3e
9 changed files with 907 additions and 485 deletions

View File

@ -5,6 +5,7 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
@ -86,7 +87,7 @@ func main() {
// === tusd setup (resumable uploads) === // === tusd setup (resumable uploads) ===
store := filestore.FileStore{Path: tusDir} store := filestore.FileStore{Path: tusDir}
// ensure upload scratch dir exists // ensure upload scratch dir exists
if err := os.MkdirAll(tusDir, 0o755); err != nil { if err := os.MkdirAll(tusDir, 0o2775); err != nil {
log.Fatalf("mkdir %s: %v", tusDir, err) log.Fatalf("mkdir %s: %v", tusDir, err)
} }
locker := memorylocker.New() locker := memorylocker.New()
@ -118,54 +119,66 @@ func main() {
// read metadata set by the UI // read metadata set by the UI
meta := ev.Upload.MetaData meta := ev.Upload.MetaData
desc := strings.TrimSpace(meta["desc"]) desc := strings.TrimSpace(meta["desc"])
if desc == "" {
internal.Logf("tus: missing desc; rejecting")
continue
}
date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty
subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/") subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
lib := strings.Trim(strings.TrimSpace(meta["lib"]), "/")
orig := meta["filename"] orig := meta["filename"]
if orig == "" { if orig == "" { orig = "upload.bin" }
orig = "upload.bin"
}
// resolve per-user root // Decide if description is required by extension (videos only)
userRootRel, err := um.Resolve(claims.Username) ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
if err != nil { isVideo := map[string]bool{
internal.Logf("tus: user map missing: %v", err) "mp4": true, "mkv": true, "mov": true, "avi": true, "m4v": true,
continue "webm": true, "mpg": true, "mpeg": true, "ts": true, "m2ts": true,
}[ext]
if isVideo && desc == "" {
log.Printf("tus: missing desc for video; rejecting")
return
} }
if desc == "" { desc = "upload" } // images can default
// compose final name & target // Resolve destination root under /media
finalName, err := internal.ComposeFinalName(date, desc, orig) var libRoot string
if err != nil { if lib != "" {
internal.Logf("tus: bad target name: %v", err) lib = sanitizeSegment(lib)
continue libRoot = filepath.Join(mediaRoot, lib) // explicit library
}
rootAbs, _ := internal.SafeJoin(mediaRoot, userRootRel)
var targetAbs string
if subdir == "" {
targetAbs, err = internal.SafeJoin(rootAbs, finalName)
} else { } else {
targetAbs, err = internal.SafeJoin(rootAbs, filepath.Join(subdir, finalName)) if rr, err := um.Resolve(claims.Username); err == nil && rr != "" {
libRoot = filepath.Join(mediaRoot, rr) // fallback to first mapped root
} else {
libRoot = "" // no valid root
} }
if err != nil { }
internal.Logf("tus: path escape prevented: %v", err)
// The library root must already exist; we only create one-level subfolders.
if libRoot == "" {
log.Printf("upload: library root not resolved for user %s", claims.Username)
continue
}
if fi, err := os.Stat(libRoot); err != nil || !fi.IsDir() {
log.Printf("upload: library root missing or not accessible: %s (%v)", libRoot, err)
continue continue
} }
srcPath := ev.Upload.Storage["Path"] // Optional one-level subdir under the library
_ = os.MkdirAll(filepath.Dir(targetAbs), 0o755) destRoot := libRoot
if internal.DryRun { if subdir != "" {
internal.Logf("[DRY] move %s -> %s", srcPath, targetAbs) subdir = sanitizeSegment(subdir)
} else if err := os.Rename(srcPath, targetAbs); err != nil { destRoot = filepath.Join(libRoot, subdir)
internal.Logf("move failed: %v", err) if err := os.MkdirAll(destRoot, 0o2775); err != nil {
log.Printf("mkdir %s: %v", destRoot, err)
continue
}
}
// Compose final filename and move from TUS scratch to final
finalName := composeFinalName(date, desc, orig)
dst := filepath.Join(destRoot, finalName)
if err := moveFromTus(ev, dst); err != nil {
log.Printf("move failed: %v", err)
continue continue
} }
internal.Logf("uploaded: %s", targetAbs)
// kick Jellyfin refresh
jf.RefreshLibrary(claims.JFToken) jf.RefreshLibrary(claims.JFToken)
} }
}() }()
@ -248,22 +261,29 @@ func main() {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
rootRel, err := um.Resolve(cl.Username)
// choose library: requested (if allowed) or default
allRoots, _ := um.ResolveAll(cl.Username)
reqLib := sanitizeSegment(r.URL.Query().Get("lib"))
var rootRel string
if reqLib != "" && contains(allRoots, reqLib) {
rootRel = reqLib
} else {
rootRel, err = um.Resolve(cl.Username)
if err != nil { if err != nil {
internal.Logf("list: map missing for %q", cl.Username) internal.Logf("list: map missing for %q", cl.Username)
http.Error(w, "no mapping", http.StatusForbidden) http.Error(w, "no mapping", http.StatusForbidden)
return return
} }
}
// one-level subdir (optional)
q := sanitizeSegment(r.URL.Query().Get("path")) // clamp to single segment
q := strings.TrimPrefix(r.URL.Query().Get("path"), "/")
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
dirAbs := rootAbs
var dirAbs string if q != "" {
if q == "" { if dirAbs, err = internal.SafeJoin(rootAbs, q); err != nil {
dirAbs = rootAbs
} else {
dirAbs, err = internal.SafeJoin(rootAbs, q)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
@ -278,7 +298,6 @@ func main() {
out := make([]listEntry, 0, len(ents)) out := make([]listEntry, 0, len(ents))
for _, d := range ents { for _, d := range ents {
info, _ := d.Info() info, _ := d.Info()
var size int64 var size int64
if info != nil && !d.IsDir() { if info != nil && !d.IsDir() {
size = info.Size() size = info.Size()
@ -287,7 +306,6 @@ func main() {
if info != nil { if info != nil {
mtime = info.ModTime().Unix() mtime = info.ModTime().Unix()
} }
out = append(out, listEntry{ out = append(out, listEntry{
Name: d.Name(), Name: d.Name(),
Path: filepath.Join(q, d.Name()), Path: filepath.Join(q, d.Name()),
@ -309,6 +327,7 @@ func main() {
return return
} }
var p struct { var p struct {
Lib string `json:"lib"`
From string `json:"from"` From string `json:"from"`
To string `json:"to"` To string `json:"to"`
} }
@ -317,30 +336,33 @@ func main() {
return return
} }
// enforce final name on files (allow any name for directories) // enforce final name format on files (directories may be free-form one level)
if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) { if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) {
http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest) http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest)
return return
} }
rootRel, _ := um.Resolve(cl.Username) // choose library: requested (if allowed) or default
allRoots, _ := um.ResolveAll(cl.Username)
lib := sanitizeSegment(p.Lib)
var rootRel string
if lib != "" && contains(allRoots, lib) {
rootRel = lib
} else {
rootRel, _ = um.Resolve(cl.Username)
}
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
fromAbs, err := internal.SafeJoin(rootAbs, p.From) fromAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.From, "/"))
if err != nil { if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
http.Error(w, "forbidden", http.StatusForbidden) toAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.To, "/"))
return if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
}
toAbs, err := internal.SafeJoin(rootAbs, p.To) _ = os.MkdirAll(filepath.Dir(toAbs), 0o2775)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
_ = os.MkdirAll(filepath.Dir(toAbs), 0o755)
if internal.DryRun { if internal.DryRun {
internal.Logf("[DRY] mv %s -> %s", fromAbs, toAbs) internal.Logf("[DRY] mv %s -> %s", fromAbs, toAbs)
} else if err := os.Rename(fromAbs, toAbs); err != nil { } else if err := os.Rename(fromAbs, toAbs); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError); return
return
} }
jf.RefreshLibrary(cl.JFToken) jf.RefreshLibrary(cl.JFToken)
writeJSON(w, map[string]any{"ok": true}) writeJSON(w, map[string]any{"ok": true})
@ -353,16 +375,29 @@ func main() {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
rootRel, _ := um.Resolve(cl.Username)
path := r.URL.Query().Get("path") // choose library: requested (if allowed) or default
allRoots, _ := um.ResolveAll(cl.Username)
lib := sanitizeSegment(r.URL.Query().Get("lib"))
var rootRel string
if lib != "" && contains(allRoots, lib) {
rootRel = lib
} else {
rootRel, _ = um.Resolve(cl.Username)
}
path := strings.TrimPrefix(r.URL.Query().Get("path"), "/")
rec := r.URL.Query().Get("recursive") == "true" rec := r.URL.Query().Get("recursive") == "true"
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
abs, err := internal.SafeJoin(rootAbs, path) abs, err := internal.SafeJoin(rootAbs, path)
if err != nil { if err != nil {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
if rec { if internal.DryRun {
internal.Logf("[DRY] rm%s %s", map[bool]string{true: " -r", false: ""}[rec], abs)
} else if rec {
err = os.RemoveAll(abs) err = os.RemoveAll(abs)
} else { } else {
err = os.Remove(abs) err = os.Remove(abs)
@ -379,19 +414,46 @@ func main() {
r.Post("/api/mkdir", func(w http.ResponseWriter, r *http.Request) { r.Post("/api/mkdir", func(w http.ResponseWriter, r *http.Request) {
cl, err := internal.CurrentUser(r) cl, err := internal.CurrentUser(r)
if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return } if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return }
var p struct{ Path string `json:"path"` } // e.g., "Trips/2025-09"
var p struct {
Lib string `json:"lib"`
Path string `json:"path"` // single-level folder name
}
if err := json.NewDecoder(r.Body).Decode(&p); err != nil { if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "bad json", http.StatusBadRequest); return http.Error(w, "bad json", http.StatusBadRequest); return
} }
rootRel, err := um.Resolve(cl.Username)
// choose library: requested (if allowed) or default
allRoots, _ := um.ResolveAll(cl.Username)
lib := sanitizeSegment(p.Lib)
var rootRel string
if lib != "" && contains(allRoots, lib) {
rootRel = lib
} else {
rootRel, err = um.Resolve(cl.Username)
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return } if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
}
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
abs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.Path, "/")) if fi, err := os.Stat(rootAbs); err != nil || !fi.IsDir() {
http.Error(w, "library not found", http.StatusBadRequest)
return
}
seg := sanitizeSegment(p.Path) // single-level + charset + spaces→_
if seg == "" {
http.Error(w, "folder name is empty", http.StatusBadRequest)
return
}
abs, err := internal.SafeJoin(rootAbs, seg)
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return } if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
if internal.DryRun { if internal.DryRun {
internal.Logf("[DRY] mkdir -p %s", abs) internal.Logf("[DRY] mkdir -p %s", abs)
} else if err := os.MkdirAll(abs, 0o755); err != nil { } else if err := os.MkdirAll(abs, 0o2775); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError); return http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
writeJSON(w, map[string]any{"ok": true}) writeJSON(w, map[string]any{"ok": true})
}) })
@ -525,3 +587,137 @@ func claimsFromHook(ev tusd.HookEvent) (internal.Claims, error) {
func env(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def } func env(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def }
func must(err error, msg string) { if err != nil { log.Fatalf("%s: %v", msg, err) } } func must(err error, msg string) { if err != nil { log.Fatalf("%s: %v", msg, err) } }
func sanitizeSegment(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, " ", "_")
s = strings.Trim(s, "/")
if i := strings.IndexByte(s, '/'); i >= 0 { s = s[:i] }
// allow only [A-Za-z0-9_.-]
out := make([]rune, 0, len(s))
for _, r := range s {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '_', r == '-', r == '.':
out = append(out, r)
default:
out = append(out, '_')
}
}
if len(out) > 64 { out = out[:64] }
return string(out)
}
func contains(ss []string, v string) bool {
for _, s := range ss {
if s == v { return true }
}
return false
}
// sanitizeDescriptor keeps [A-Za-z0-9_-], converts whitespace to underscores,
// collapses runs, and limits to 64 chars. Used in final filenames.
func sanitizeDescriptor(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, " ", "_")
if s == "" { return "upload" }
var b []rune
lastUnderscore := false
for _, r := range s {
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-'
if !ok { r = '_' }
if r == '_' {
if lastUnderscore { continue }
lastUnderscore = true
} else {
lastUnderscore = false
}
b = append(b, r)
if len(b) >= 64 { break }
}
if len(b) == 0 { return "upload" }
return string(b)
}
// composeFinalName mirrors the UI: YYYY.MM.DD.Description.ext
// date is expected as YYYY-MM-DD; falls back to today if missing/bad.
func composeFinalName(date, desc, orig string) string {
y, m, d := "", "", ""
if len(date) >= 10 && date[4] == '-' && date[7] == '-' {
y, m, d = date[:4], date[5:7], date[8:10]
} else {
now := time.Now()
y = fmt.Sprintf("%04d", now.Year())
m = fmt.Sprintf("%02d", int(now.Month()))
d = fmt.Sprintf("%02d", now.Day())
}
clean := sanitizeDescriptor(desc)
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
if ext == "" { ext = "bin" }
return fmt.Sprintf("%s.%s.%s.%s.%s", y, m, d, clean, ext)
}
// moveFromTus relocates the finished upload file from the TUS scratch directory to dst.
// Works with tusd's filestore by probing common data file names.
func moveFromTus(ev tusd.HookEvent, dst string) error {
// Likely data file names/locations used by tusd filestore across versions
candidates := []string{
filepath.Join(tusDir, ev.Upload.ID),
filepath.Join(tusDir, ev.Upload.ID+".bin"),
filepath.Join(tusDir, "data", ev.Upload.ID),
filepath.Join(tusDir, "data", ev.Upload.ID+".bin"),
filepath.Join(tusDir, "uploads", ev.Upload.ID),
filepath.Join(tusDir, "uploads", ev.Upload.ID+".bin"),
}
var src string
for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() {
src = p
break
}
}
if src == "" {
// last resort: scan the directory for a file starting with the id
entries, _ := os.ReadDir(tusDir)
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), ev.Upload.ID) {
src = filepath.Join(tusDir, e.Name())
break
}
}
}
if src == "" {
return fmt.Errorf("tus data file not found for id %s", ev.Upload.ID)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o2775); err != nil {
return err
}
if internal.DryRun {
internal.Logf("[DRY] mv %s -> %s", src, dst)
return nil
}
// Try fast rename; if it crosses devices, fall back to copy+remove.
if err := os.Rename(src, dst); err != nil {
in, err2 := os.Open(src)
if err2 != nil { return err2 }
defer in.Close()
out, err3 := os.Create(dst)
if err3 != nil { return err3 }
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
_ = out.Close()
_ = os.Remove(src)
}
// Remove sidecars if present
_ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".info"))
_ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".json"))
return nil
}

View File

@ -1,8 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pegasus</title> <title>Pegasus</title>
</head> </head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body> <body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html> </html>

View File

@ -8,6 +8,7 @@
"name": "pegasus-frontend", "name": "pegasus-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@picocss/pico": "^2.1.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tus-js-client": "^4.3.1" "tus-js-client": "^4.3.1"
@ -794,6 +795,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@picocss/pico": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz",
"integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.34", "version": "1.0.0-beta.34",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz",

View File

@ -9,6 +9,7 @@
"preview": "vite preview --port 5173" "preview": "vite preview --port 5173"
}, },
"dependencies": { "dependencies": {
"@picocss/pico": "^2.1.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tus-js-client": "^4.3.1" "tus-js-client": "^4.3.1"

View File

@ -1,17 +1,31 @@
// frontend/src/App.tsx // frontend/src/App.tsx
import React, { useEffect, useState } from 'react' import React from 'react'
import { api } from './api'
import Login from './Login'
import Uploader from './Uploader' import Uploader from './Uploader'
import './styles.css' import Login from './Login'
import { api } from './api'
export default function App() { export default function App() {
const [authed, setAuthed] = useState<boolean>(false) const [authed, setAuthed] = React.useState<boolean | undefined>(undefined)
useEffect(()=>{ api('/api/whoami').then(()=>setAuthed(true)).catch(()=>setAuthed(false)) }, [])
React.useEffect(()=>{
api('/api/whoami').then(()=> setAuthed(true)).catch(()=> setAuthed(false))
}, [])
async function logout(){
try { await api('/api/logout', { method:'POST' }) } finally { location.reload() }
}
return ( return (
<> <main>
<header><h1>🪽 Pegasus</h1>{authed && <button className="btn" onClick={()=> api('/api/logout',{method:'POST'}).then(()=>location.replace('/'))}>Logout</button>}</header> <header className="app-header">
<main>{authed ? <Uploader/> : <Login onLogin={()=>setAuthed(true)} /> }</main> <div className="brand">
</> <span className="wing" aria-hidden>🪽</span>
<strong>Pegasus</strong>
</div>
{authed && <button onClick={logout}>Logout</button>}
</header>
{authed ? <Uploader/> : <Login/>}
</main>
) )
} }

View File

@ -1,90 +1,109 @@
// frontend/src/Uploader.tsx // frontend/src/Uploader.tsx
import React, { useEffect, useMemo, useRef, useState } from 'react' import React 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
console.log('[Pegasus] FE bundle activated at', new Date().toISOString()) console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
function isLikelyMobile(): boolean { // ---------- helpers ----------
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(/\s+/g,'_').replace(/[^A-Za-z0-9._-]+/g,'_')
if(!s) s = 'upload' if(!s) s = 'upload'
return s.slice(0,64) return s.slice(0,64)
} }
function extOf(n:string){ const i=n.lastIndexOf('.'); return i>-1 ? n.slice(i+1).toLowerCase() : 'bin' } function sanitizeFolderName(s:string){
// 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-]
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//,'').replace(/\/.*$/,'')
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g,'_').replace(/_+/g,'_')
return s.slice(0,64)
}
function extOf(n:string){ const i=n.lastIndexOf('.'); return i>-1 ? n.slice(i+1).toLowerCase() : '' }
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) // YYYY-MM-DD 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('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}`
} }
function clampOneLevel(p:string){ if(!p) return ''; return p.replace(/^\/+|\/+$/g,'').split('/')[0] || '' }
function normalizeRows(listRaw:any[]): FileRow[] {
return (Array.isArray(listRaw) ? listRaw : []).map((r:any)=>({
name: r?.name ?? r?.Name ?? '',
path: r?.path ?? r?.Path ?? '',
is_dir: Boolean(r?.is_dir ?? r?.IsDir ?? r?.isDir ?? false),
size: Number(r?.size ?? r?.Size ?? 0),
mtime: Number(r?.mtime ?? r?.Mtime ?? 0),
}))
}
const videoExt = new Set(['mp4','mkv','mov','avi','m4v','webm','mpg','mpeg','ts','m2ts'])
const imageExt = new Set(['jpg','jpeg','png','gif','heic','heif','webp','bmp','tif','tiff'])
const extLower = (n:string)=> (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '')
const isVideoFile = (f:File)=> f.type.startsWith('video/') || videoExt.has(extLower(f.name))
const isImageFile = (f:File)=> f.type.startsWith('image/') || imageExt.has(extLower(f.name))
// 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 isLikelyMobileUA(): boolean {
function normalizeRows(raw: any): FileRow[] { if (typeof window === 'undefined') return false
if (!Array.isArray(raw)) return [] const ua = navigator.userAgent || ''
return raw.map((r: any) => ({ const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
name: r?.name ?? r?.Name ?? '', return coarse || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
path: r?.path ?? r?.Path ?? '', }
is_dir: typeof r?.is_dir === 'boolean' ? r.is_dir function useIsMobile(): boolean {
: typeof r?.IsDir === 'boolean' ? r.IsDir const [mobile, setMobile] = React.useState(false)
: false, React.useEffect(()=>{ setMobile(isLikelyMobileUA()) }, [])
size: typeof r?.size === 'number' ? r.size : (typeof r?.Size === 'number' ? r.Size : 0), return mobile
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) // Disable tus resume completely (v2 API: addUpload/removeUpload/listUploads)
function useIsMobile() { const NoResumeUrlStorage: any = {
const [isMobile, setIsMobile] = useState(false) addUpload: async (_u: any) => {},
useEffect(() => { removeUpload: async (_u: any) => {},
const mq = window.matchMedia('(max-width: 640px)') listUploads: async () => [],
const handler = (e: MediaQueryListEvent | MediaQueryList) => // compatibility:
setIsMobile(('matches' in e ? e.matches : (e as MediaQueryList).matches)) findUploadsByFingerprint: async (_fp: string) => []
handler(mq) }
mq.addEventListener('change', handler as any) const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}`
return () => mq.removeEventListener('change', handler as any)
}, []) // ---------- thumbnail ----------
return isMobile function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) {
const [url, setUrl] = React.useState<string>()
React.useEffect(()=>{
const u = URL.createObjectURL(file); setUrl(u)
return () => { try { URL.revokeObjectURL(u) } catch {} }
}, [file])
if (!url) return null
const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 }
if (isImageFile(file)) return <img src={url} alt="" style={baseStyle} className="preview-thumb" />
if (isVideoFile(file)) return <video src={url} muted preload="metadata" playsInline style={baseStyle} className="preview-thumb" />
return <div style={{...baseStyle, display:'grid', placeItems:'center'}} className="preview-thumb">📄</div>
} }
// ---------- component ----------
export default function Uploader(){ export default function Uploader(){
const [me, setMe] = useState<WhoAmI|undefined>() const mobile = useIsMobile()
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 [me, setMe] = React.useState<WhoAmI|undefined>()
const [rows, setRows] = useState<FileRow[]>([]) const [libs, setLibs] = React.useState<string[]>([])
const destPath = `/${[selectedLib, subdir].filter(Boolean).join('/')}` const [lib, setLib] = React.useState<string>('')
const [status, setStatus] = useState<string>('') const [sub, setSub] = React.useState<string>('')
const [globalDate, setGlobalDate] = useState<string>(new Date().toISOString().slice(0,10)) const [rootDirs, setRootDirs] = React.useState<string[]>([])
const [uploading, setUploading] = useState<boolean>(false) const [rows, setRows] = React.useState<FileRow[]>([])
const [sel, setSel] = useState<Sel[]>([])
const folderInputRef = useRef<HTMLInputElement>(null) // to set webkitdirectory
const isMobile = useIsMobile()
// Enable directory selection on the folder picker (non-standard attr) const [status, setStatus] = React.useState<string>('')
useEffect(() => { const [globalDate, setGlobalDate] = React.useState<string>(new Date().toISOString().slice(0,10))
const [uploading, setUploading] = React.useState<boolean>(false)
const [sel, setSel] = React.useState<Sel[]>([])
const [bulkDesc, setBulkDesc] = React.useState<string>('') // helper: apply to all videos
const folderInputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
const el = folderInputRef.current const el = folderInputRef.current
if (!el) return if (!el) return
if (mobile) { if (mobile) {
// ensure we don't accidentally force folder mode on mobile
el.removeAttribute('webkitdirectory') el.removeAttribute('webkitdirectory')
el.removeAttribute('directory') el.removeAttribute('directory')
} else { } else {
@ -93,68 +112,109 @@ export default function Uploader(){
} }
}, [mobile]) }, [mobile])
// Fetch whoami + list // initial load
async function refresh(path=''){ React.useEffect(()=>{
(async ()=>{
try{ try{
const m = await api<any>('/api/whoami') setStatus('Loading profile…')
setMe(m as WhoAmI) const m = await api<WhoAmI>('/api/whoami'); setMe(m as any)
const mm:any = m
const libs: string[] = const L: string[] =
Array.isArray(m?.roots) && m.roots.length ? m.roots.slice() Array.isArray(mm?.roots) ? mm.roots :
: m?.root ? [m.root] : [] Array.isArray(mm?.libs) ? mm.libs :
(typeof mm?.root === 'string' && mm.root ? [mm.root] : [])
setLibraries(libs) setLibs(L)
// Auto-select if exactly one library const def = L[0] || ''
setSelectedLib(prev => prev || (libs.length === 1 ? libs[0] : '')) setLib(def)
setSub('')
const listRaw = await api<any>('/api/list?path='+encodeURIComponent(path)) if (def) { await refresh(def, '') }
const list = normalizeRows(listRaw) setStatus(def ? `Ready · Destination: /${def}` : 'Choose a library to start')
setRows(list); setSubdir(path)
setStatus(`Ready · Destination: /${[libs[0] ?? '', path].filter(Boolean).join('/')}`)
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 || '')
console.error('[Pegasus] list error', e) setStatus(`Profile error: ${msg}`)
setStatus(`List 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('/') // back to login location.replace('/')
} }
} }
})()
}, [])
React.useEffect(()=>{
(async ()=>{
if (!lib) return
try {
setStatus(`Loading library “${lib}”…`)
await refresh(lib, sub)
} catch(e:any){
console.error('[Pegasus] refresh error', e)
setStatus(`List error: ${e?.message || e}`)
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lib])
async function refresh(currLib:string, currSub:string){
const one = clampOneLevel(currSub)
const listRoot = normalizeRows(await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path: '' })}`))
const rootDirNames = listRoot.filter(e=>e.is_dir).map(e=>e.name).filter(Boolean)
setRootDirs(rootDirNames.sort((a,b)=>a.localeCompare(b)))
const subOk = one && rootDirNames.includes(one) ? one : ''
if (subOk !== currSub) setSub(subOk)
const path = subOk ? subOk : ''
const list = subOk ? await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path })}`) : listRoot
setRows(normalizeRows(list))
const show = `/${[currLib, path].filter(Boolean).join('/')}`
setStatus(`Ready · Destination: ${show}`)
} }
useEffect(()=>{ setStatus('Loading profile & folder list…'); refresh('') }, [])
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; user must fill before upload 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)
} }
// When the global date changes, recompute per-file finalName // recompute finalName when global date changes
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
useEffect(()=>{ React.useEffect(()=>{
const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } } const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } }
window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler) window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler)
}, [uploading]) }, [uploading])
// Apply description to all videos helper
function applyDescToAllVideos() {
if (!bulkDesc.trim()) return
setSel(old => old.map(x =>
isVideoFile(x.file)
? ({...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name)})
: x
))
}
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(!lib) { alert('Please select a Library to upload into.'); return }
if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return } // Require description only for videos:
const missingVideos = sel.filter(s => isVideoFile(s.file) && !s.desc.trim()).length
if (missingVideos > 0) {
alert(`Please add a short description for all videos. ${missingVideos} video(s) missing a description.`)
return
}
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
@ -163,18 +223,12 @@ 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],
// Avoid resuming old HTTP URLs: don't store fingerprints, and use a random fingerprint so nothing matches
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,
// Server treats this as a subdirectory under the user's mapped root. lib: lib,
// When backend supports multiple libraries, it will also need the selected library. subdir: sub || "",
subdir: subdir || "", date: s.date,
date: s.date, // per-file YYYY-MM-DD desc: s.desc // server enforces: required for videos only
desc: s.desc // server composes final
}, },
onError: (err: Error | tus.DetailedError)=>{ onError: (err: Error | tus.DetailedError)=>{
let msg = String(err) let msg = String(err)
@ -201,198 +255,217 @@ export default function Uploader(){
resolve() resolve()
}, },
} }
// Ensure cookie is sent even if the tus typings dont list this field
opts.withCredentials = true opts.withCredentials = true
;(opts as any).urlStorage = NoResumeUrlStorage
;(opts as any).fingerprint = NoResumeFingerprint
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, subdir }) console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, lib, sub })
up.start() up.start()
}) })
} }
setStatus('All uploads complete') setStatus('All uploads complete')
setSel([]) setSel([])
await refresh(subdir) await refresh(lib, sub)
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
async function rename(oldp:string){ // -------- one-level subfolder ops --------
const name = prompt('New name (YYYY.MM.DD.description.ext for files, or folder name):', oldp.split('/').pop()||''); if(!name) return async function createSubfolder(nameRaw:string){
const name = sanitizeFolderName(nameRaw)
if (!name) return
try{
await api(`/api/mkdir`, {
method:'POST',
headers:{'content-type':'application/json'},
body: JSON.stringify({ lib: lib, path: name })
})
await refresh(lib, name) // jump into new folder
setSub(name)
} catch(e:any){
console.error('[Pegasus] mkdir error', e)
alert(`Create folder failed:\n${e?.message || e}`)
}
}
async function renameFolder(oldName:string){
const nn = prompt('New folder name:', oldName)
const newName = sanitizeFolderName(nn || '')
if (!newName || newName === oldName) return
try{ try{
await api('/api/rename', { 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({ lib: lib, from: oldName, to: newName })
}) })
await refresh(subdir) const newSub = (sub === oldName) ? newName : sub
setSub(newSub)
await refresh(lib, newSub)
} catch(e:any){
console.error('[Pegasus] rename folder error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
async function deleteFolder(name:string){
if(!confirm(`Delete folder “${name}” (and its contents)?`)) return
try{
await api(`/api/file?${new URLSearchParams({ lib: lib, path: name, recursive:'true' })}`, { method:'DELETE' })
const newSub = (sub === name) ? '' : sub
setSub(newSub)
await refresh(lib, newSub)
} catch(e:any){
console.error('[Pegasus] delete folder error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
// destination listing (actions: rename files only at library root)
async function renamePath(oldp:string){
const base = (oldp.split('/').pop()||'')
const name = prompt('New name (YYYY.MM.DD.description.ext):', base); if(!name) return
try{
await api('/api/rename', {
method:'POST',
headers:{'content-type':'application/json'},
body: JSON.stringify({ lib: lib, from: oldp, to: (oldp.split('/').slice(0,-1).concat(sanitizeFolderName(name))).join('/') })
})
await refresh(lib, sub)
} catch(e:any){ } catch(e:any){
console.error('[Pegasus] rename error', e) console.error('[Pegasus] rename error', e)
alert(`Rename failed:\n${e?.message || e}`) alert(`Rename failed:\n${e?.message || e}`)
} }
} }
async function deletePath(p:string, recursive:boolean){
async function del(p:string, recursive:boolean){
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?${new URLSearchParams({ lib: lib, path: p, recursive: recursive?'true':'false' })}`, { method:'DELETE' })
await refresh(subdir) await refresh(lib, sub)
} catch(e:any){ } catch(e:any){
console.error('[Pegasus] delete error', e) console.error('[Pegasus] delete error', e)
alert(`Delete failed:\n${e?.message || e}`) alert(`Delete failed:\n${e?.message || e}`)
} }
} }
function goUp(){ // sort rows
setSubdir(''); void refresh('') const sortedRows = React.useMemo(()=>{
} const arr = Array.isArray(rows) ? rows.slice() : []
return arr.sort((a,b)=>{
// Precompute a safely sorted copy for rendering (no in-place mutation) const dirFirst = (Number(b?.is_dir ? 1:0) - Number(a?.is_dir ? 1:0))
const rowsSorted = useMemo(() => { if (dirFirst !== 0) return dirFirst
const copy = Array.isArray(rows) ? [...rows] : [] const an = (a?.name ?? '')
return copy const bn = (b?.name ?? '')
.filter(r => r && typeof r.name === 'string') return an.localeCompare(bn)
.sort((a, b) => (Number(b.is_dir) - Number(a.is_dir)) || a.name.localeCompare(b.name)) })
}, [rows]) }, [rows])
// Restrict to one-level: only allow "Open" when we're at library root (subdir === '') const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
const canOpenDeeper = subdir === '' const videosNeedingDesc = sel.filter(s => isVideoFile(s.file) && !s.desc.trim()).length
return (<> const [newFolder, setNewFolder] = React.useState('')
<section className="card">
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', gap:8, flexWrap:'wrap'}}>
<div><b>Signed in:</b> {me?.username}</div>
<div className="meta">Destination: <b>{destPath || '/(select a library)'}</b></div>
</div>
{/* Destination (Library + Subfolder) */}
<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>
<label className="meta">Default date (applied to new selections)</label>
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
</div>
{/* Pickers: mobile vs desktop */}
<div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
{mobile ? (
// ---- Mobile: show a Gallery picker (no capture) + optional Camera quick-capture
<>
<div>
<label className="meta">Gallery/Photos</label>
<input
type="file"
multiple
// no "capture" -> browsers tend to open Photo/Media picker instead of camera or filesystem
accept="image/*,video/*"
onChange={e => e.target.files && handleChoose(e.target.files)}
/>
</div>
<div>
<label className="meta">Camera (optional)</label>
<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
type="file"
multiple
ref={folderInputRef}
onChange={e => e.target.files && handleChoose(e.target.files)}
/>
</div>
</>
)}
</div>
{(() => {
const uploadDisabled =
!selectedLib || !sel.length || uploading || sel.some(s=>!s.desc.trim())
let disabledReason = ''
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 (sel.some(s=>!s.desc.trim())) disabledReason = 'Add a short description for every file.'
return ( return (
<> <>
<button {/* Header context */}
type="button" <section>
className="btn" <hgroup>
onClick={()=>{ <h2>Signed in: {me?.username}</h2>
console.log('[Pegasus] Upload click', { files: sel.length, uploading, disabled: uploadDisabled }) <p className="meta">Destination: <strong>{destPath || '/(choose a library)'}</strong></p>
setStatus('Upload button clicked') </hgroup>
if (!uploadDisabled) { void doUpload() } </section>
}}
disabled={uploadDisabled} {/* Add files */}
aria-disabled={uploadDisabled} <section>
> <h3>Add files</h3>
Upload {sel.length? `(${sel.length})` : ''} <div className="grid-3">
</button> <label>
{disabledReason && <div className="meta bad">{disabledReason}</div>} Default date (applied to new selections)
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
</label>
</div>
<div className="file-picker">
{mobile ? (
<>
<label>
Gallery/Photos
<input type="file" multiple accept="image/*,video/*"
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label>
<label>
Camera (optional)
<input type="file" accept="image/*,video/*" capture="environment"
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label>
</> </>
) ) : (
})()} <>
<label>
Select file(s)
<input type="file" multiple accept="image/*,video/*"
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label>
<label>
Select folder(s)
<input type="file" multiple ref={folderInputRef}
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
</label>
</>
)}
</div>
</section>
{/* Review & upload */}
<section>
<h3>Review & Upload <span className="meta">{destPath || '/(choose a library)'}</span></h3>
{sel.length === 0 ? (
<p className="meta">Select at least one file.</p>
) : (
<>
<article>
<div style={{display:'grid', gap:12}}>
<label>
Description for all videos (optional)
<input
placeholder="Short video description"
value={bulkDesc}
onChange={e=> setBulkDesc(e.target.value)}
/>
</label>
<button type="button" className="stretch" onClick={applyDescToAllVideos} disabled={!bulkDesc.trim()}>
Apply to all videos
</button>
{videosNeedingDesc > 0
? <small className="meta bad">{videosNeedingDesc} video(s) need a description</small>
: <small className="meta">All videos have descriptions</small>
}
</div>
</article>
{sel.map((s,i)=>(
<article key={i} className="video-card">
<div className="thumb-row">
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
</div> </div>
{/* Per-file editor */} <h4 className="filename ellipsis">{s.file.name}</h4>
{sel.length>0 && (
<div className="card"> <label>
<h4>Ready to upload</h4> {isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
<div className="grid">
{sel.map((s,i)=>(
<div key={i} className="item">
<div className="meta">{s.file.name}</div>
<input <input
required={isVideoFile(s.file)}
aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
value={s.desc} value={s.desc}
placeholder="Short description (required)"
onChange={e=>{ onChange={e=>{
const v=e.target.value const v=e.target.value
setSel(old => old.map((x,idx)=> idx===i setSel(old => old.map((x,idx)=> idx===i ? ({...x, desc:v, finalName: composeName(x.date, v, x.file.name)}) : x))
? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)})
: x ))
}} }}
/> />
<div style={{display:'grid', gridTemplateColumns:'auto 1fr', gap:8, alignItems:'center', marginTop:6}}> </label>
<span className="meta">Date</span>
<label>
Date
<input <input
type="date" type="date"
value={s.date} value={s.date}
@ -401,118 +474,147 @@ export default function Uploader(){
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))
}} }}
/> />
</div> </label>
<div className="meta"> {s.finalName}</div>
{typeof s.progress === 'number' && ( <small className="meta"> {s.finalName}</small>
<> {typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
<progress max={100} value={s.progress}></progress> {s.err && <small className="meta bad">Error: {s.err}</small>}
{s.err && <div className="meta bad">Error: {s.err}</div>} </article>
))}
</> </>
)} )}
</section>
{/* Folder (one-level manager) */}
<section>
<h3>Library</h3>
<div className="grid-3">
<label>
Library
<select value={lib} onChange={e=>{ setLib(e.target.value); setSub('') }}>
<option value="" disabled> Select a library </option>
{libs.map(L => <option key={L} value={L}>{L}</option>)}
</select>
{libs.length<=1 && <small className="meta">Auto-selected</small>}
</label>
<label>
Subfolder (optional, 1 level)
<select
value={sub}
onChange={e=>{ const v = clampOneLevel(e.target.value); setSub(v); void refresh(lib, v) }}
disabled={!lib}
>
<option value="">(Library root)</option>
{rootDirs.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</label>
</div> </div>
<div className="grid-3">
<label>
New subfolder name (optional)
<input
placeholder="letters, numbers, underscores, dashes"
value={newFolder}
onChange={e=> setNewFolder(sanitizeFolderName(e.target.value))}
disabled={!lib}
inputMode="text"
pattern="[A-Za-z0-9_.-]+"
title="Use letters, numbers, underscores, dashes or dots"
/>
</label>
{Boolean(sanitizeFolderName(newFolder)) && (
<div className="center-actions">
<button
type="button"
onClick={()=>{ const n = sanitizeFolderName(newFolder); if (n) { void createSubfolder(n); setNewFolder('') } }}
>
Create
</button>
</div>
)}
</div>
<article>
<div style={{display:'flex', gap:8, alignItems:'center', justifyContent:'space-between'}}>
<div>Current: <strong>{sub || '(library root)'}</strong></div>
{sub && (
<div style={{display:'flex', gap:8}}>
<button onClick={()=> void renameFolder(sub)}>Rename</button>
<button onClick={()=> void deleteFolder(sub)} className="bad">Delete</button>
<button onClick={()=>{ setSub(''); void refresh(lib, '') }}>Clear</button>
</div>
)}
</div>
</article>
<details open style={{marginTop:8}}>
<summary>Subfolders</summary>
{rootDirs.length === 0 ? (
<p className="meta">No subfolders yet. Youll upload into <b>/{lib}</b>.</p>
) : (
<div style={{display:'grid', gap:8}}>
{rootDirs.map(d=>(
<article key={d} style={{display:'flex', alignItems:'center', justifyContent:'space-between'}}>
<div>📁 {d}</div>
<div style={{display:'flex', gap:8}}>
<button onClick={()=>{ setSub(d); void refresh(lib, d) }}>Select</button>
<button onClick={()=> void renameFolder(d)}>Rename</button>
<button onClick={()=> void deleteFolder(d)} className="bad">Delete</button>
</div>
</article>
))} ))}
</div> </div>
</div>
)} )}
</details>
<div className="meta" style={{marginTop:8}}>{status}</div> <details open style={{marginTop:8}}>
</section> <summary>Contents of {destPath || '/(choose)'}</summary>
{sortedRows.length === 0 ? (
{/* Destination details and subfolder management */} <p className="meta">Empty.</p>
<section className="card">
<h3>Destination</h3>
{/* 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="meta">
No items to show here. Youll upload into <b>{destPath || '/(select a library)'}</b>.
</div>
</div>
) : ( ) : (
<div className="grid"> <div style={{display:'grid', gap:8}}>
{/* Only show "Open" when at library root to enforce one-level depth */} {sortedRows.map(f =>
{rowsSorted.map(f => <article key={f.path} style={{display:'flex', alignItems:'center', justifyContent:'space-between'}}>
<div key={f.path} className="item"> <div>
<div className="name">{f.is_dir?'📁':'🎞️'} {f.name}</div> <div className="ellipsis">{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}</div>
<div className="meta">{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}</div> <small className="meta">{f.is_dir?'folder':fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}</small>
<div className="meta"> </div>
<div style={{display:'flex', gap:8}}>
{f.is_dir ? ( {f.is_dir ? (
<> <>
{canOpenDeeper <button onClick={()=>{ setSub(f.name); void refresh(lib, f.name) }}>Open</button>
? <a href="#" onClick={(e)=>{e.preventDefault(); setSubdir(f.path); void refresh(f.path)}}>Open</a> <button onClick={()=> void renameFolder(f.name)}>Rename</button>
: <span className="meta">Open (disabled)</span> <button onClick={()=> void deleteFolder(f.name)} className="bad">Delete</button>
}
{' · '}<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> {sub === '' && <button onClick={()=> void renamePath(f.path)}>Rename</button>}
{' · '}<a className="bad" href="#" onClick={(e)=>{e.preventDefault(); void del(f.path,false)}}>Delete</a> <button onClick={()=> void deletePath(f.path, false)} className="bad">Delete</button>
</> </>
)} )}
</div> </div>
</div> </article>
)} )}
</div> </div>
)} )}
</details>
</section> </section>
</>)
<footer style={{display:'flex', gap:12, alignItems:'center', justifyContent:'space-between', marginTop:8}}>
<small className="meta" aria-live="polite">{status}</small>
<button
type="button"
onClick={()=>{ const disabled = !lib || !sel.length || uploading || videosNeedingDesc>0; if (!disabled) void doUpload() }}
disabled={!lib || !sel.length || uploading || videosNeedingDesc>0}
>
Upload {sel.length? `(${sel.length})` : ''}
</button>
</footer>
</>
)
} }
function fmt(n:number){ if(n<1024) return n+' B'; const u=['KB','MB','GB','TB']; let i=-1; do{ n/=1024; i++ }while(n>=1024&&i<u.length-1); return n.toFixed(1)+' '+u[i] } function fmt(n:number){ if(n<1024) return n+' B'; const u=['KB','MB','GB','TB']; let i=-1; do{ n/=1024; i++ }while(n>=1024&&i<u.length-1); return n.toFixed(1)+' '+u[i] }
function CreateFolder({ cwd, onCreate }:{ cwd:string; onCreate:(p:string)=>void }) {
const [name, setName] = React.useState('');
async function submit() {
// single-level only
let clean = name.trim().replace(/[\/]+/g,'').replace(/[^\w\-\s.]/g,'_')
clean = clean.replace(/\s+/g,'_')
if (!clean) return;
const path = [cwd, clean].filter(Boolean).join('/');
console.log('[Pegasus] mkdir click', { path })
try {
await api('/api/mkdir', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path }) });
onCreate(path);
setName('');
} catch (e:any) {
console.error('[Pegasus] mkdir error', e)
alert(`Create folder failed:\n${e?.message || e}`)
}
}
return (
<div style={{marginTop:10, display:'flex', gap:8, alignItems:'center'}}>
<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>
</div>
);
}

View File

@ -1,20 +1,9 @@
// frontend/src/api.ts // frontend/src/api.ts
import 'tus-js-client' import 'tus-js-client'
import type { UrlStorage } from 'tus-js-client'
declare module 'tus-js-client' { declare module 'tus-js-client' {
interface UploadOptions { interface UploadOptions {
/** Send cookies on CORS requests */
withCredentials?: boolean withCredentials?: boolean
/** Disable reading/writing resume URLs (older defs may miss this) */
resume?: boolean
/** Do not store fingerprints for resuming */
storeFingerprintForResuming?: boolean
/** Remove any stored fingerprint after a successful upload */
removeFingerprintOnSuccess?: boolean
/** Allow providing a no-op storage to fully disable resuming */
urlStorage?: UrlStorage
} }
} }
@ -23,9 +12,13 @@ export async function api<T=any>(path: string, init?: RequestInit): Promise<T> {
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
const ct = r.headers.get('content-type') || ''; const ct = r.headers.get('content-type') || '';
if (ct.includes('json')) return r.json() as Promise<T>; if (ct.includes('json')) return r.json() as Promise<T>;
// Fallback: try to parse body as JSON; otherwise return text
const txt = await r.text(); const txt = await r.text();
try { return JSON.parse(txt) as T } catch { return txt as any as T } try { return JSON.parse(txt) as T } catch { return txt as any as T }
} }
export type WhoAmI = { username: string; root: string }; export type WhoAmI = {
username: string;
root?: string;
roots?: string[];
libs?: string[];
};

View File

@ -2,4 +2,15 @@
import React from 'react' import React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App' import App from './App'
createRoot(document.getElementById('root')!).render(<App/>) import './styles.css'
const el = document.getElementById('root')
if (!el) {
throw new Error('Missing <div id="root"></div> in index.html')
}
createRoot(el).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@ -1,24 +1,118 @@
/* frontend/src/styles.css */ /* frontend/src/styles.css */
:root{--bg:#0f1222;--fg:#e8e8f0;--muted:#9aa0a6;--card:#171a2e;--accent:#7aa2f7;--bad:#ef5350;--good:#66bb6a} @import '@picocss/pico/css/pico.classless.min.css';
*{box-sizing:border-box}
html,body,#root{height:100%} /* Small, framework-friendly helpers only */
body{margin:0;font:16px/1.45 system-ui;background:var(--bg);color:var(--fg)} :root { --thumb: 112px; }
header{display:flex;gap:8px;align-items:center;justify-content:space-between;padding:14px 16px;background:#12152a;position:sticky;top:0}
h1{font-size:18px;margin:0} main { max-width: 960px; margin: 0 auto; padding: 0 12px 24px; }
main{max-width:960px;margin:0 auto;padding:16px} section { margin-block: 1rem; }
.card{background:var(--card);border:1px solid #242847;border-radius:12px;padding:14px;margin:10px 0} small.meta { color: var(--muted-color); }
.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} /* Responsive columns for form controls */
input[type=file]{padding:10px} .columns {
.hide-on-mobile{display:block} display: grid;
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%} gap: 1rem;
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px} grid-template-columns: 1fr; /* mobile: one column */
.item{padding:12px;border:1px solid #2a2f45;border-radius:10px;background:#1a1e34} align-items: end;
.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}
.hide-on-mobile{display:none}
.btn{padding:14px 16px}
} }
@media (min-width: 720px) {
.columns { grid-template-columns: repeat(3, minmax(0,1fr)); } /* desktop: 3 cols */
}
/* Two-up area (e.g., file pickers) */
.file-picker {
display: grid;
gap: 1rem;
grid-template-columns: 1fr; /* mobile: stack */
}
@media (min-width: 720px) {
.file-picker { grid-template-columns: 1fr 1fr; } /* desktop: 2 cols */
}
/* File card: thumb above on mobile, side-by-side on wide screens */
.file-card {
display: grid;
grid-template-columns: var(--thumb) 1fr;
gap: .75rem;
align-items: start;
}
.file-thumb {
width: var(--thumb);
height: var(--thumb);
object-fit: cover;
border-radius: .5rem;
}
@media (max-width: 600px) {
.file-card { grid-template-columns: 1fr; }
.file-thumb { width: 100%; height: auto; max-height: 240px; }
}
/* Keep the action visible while scrolling the list */
.sticky-actions {
position: sticky;
bottom: 0;
padding: .75rem;
background: var(--card-background-color);
border-top: 1px solid var(--muted-border-color, #2e2e2e);
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: center;
z-index: 10;
}
/* Container */
main { max-width: 980px; margin: 0 auto; padding: 0 12px 24px; }
/* Stacked groups by default; switch to columns on larger screens */
.grid-3 { display: grid; gap: 12px; }
@media (min-width: 768px) {
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; }
}
/* File review card layout */
.file-card { display: grid; grid-template-columns: 96px 1fr; gap: 12px; align-items: start; }
@media (max-width: 520px) {
.file-card { grid-template-columns: 1fr; }
}
/* Make form controls fill their line */
label > input, label > select, label > textarea { width: 100%; }
/* App bar */
.app-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin: 12px 0 24px; }
.brand { display:flex; align-items:center; gap:8px; font-size: 1.25rem; }
.brand .wing { font-size: 1.3em; line-height: 1; }
/* Top row (Library / Subfolder / Date) */
.grid-3 { display: grid; gap: 12px; }
@media (min-width: 768px) {
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; }
}
/* “Cards” for files */
.video-card {
border: 1px solid var(--muted-border-color);
border-radius: 12px;
padding: 12px;
background: var(--surface-2, rgba(255,255,255,0.02));
display: grid;
gap: 10px;
}
.thumb-row { display:grid; place-items:center; }
.preview-thumb { box-shadow: 0 1px 0 rgba(0,0,0,.12); }
.filename { margin: 0 0 4px; }
/* File pickers and form controls fill line width */
.file-picker { display: grid; gap: 12px; }
label > input, label > select, label > textarea { width: 100%; }
/* Centered one-off actions */
.center-actions { display:flex; justify-content:center; margin-top: 6px; }
/* Misc */
.ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bad { color: var(--del-color); }
.stretch { width: 100%; }
small.meta { color: var(--muted-color); }
.file-picker { display: grid; gap: 12px; }