it works again
This commit is contained in:
parent
19978d9302
commit
2e856c0b3e
368
backend/main.go
368
backend/main.go
@ -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()
|
||||||
@ -116,56 +117,68 @@ 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 == "" {
|
date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty
|
||||||
internal.Logf("tus: missing desc; rejecting")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty
|
|
||||||
subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
|
subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
|
||||||
orig := meta["filename"]
|
lib := strings.Trim(strings.TrimSpace(meta["lib"]), "/")
|
||||||
if orig == "" {
|
orig := meta["filename"]
|
||||||
orig = "upload.bin"
|
if orig == "" { 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
|
||||||
if err != nil {
|
} else {
|
||||||
internal.Logf("tus: path escape prevented: %v", err)
|
libRoot = "" // no valid root
|
||||||
continue
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
srcPath := ev.Upload.Storage["Path"]
|
// The library root must already exist; we only create one-level subfolders.
|
||||||
_ = os.MkdirAll(filepath.Dir(targetAbs), 0o755)
|
if libRoot == "" {
|
||||||
if internal.DryRun {
|
log.Printf("upload: library root not resolved for user %s", claims.Username)
|
||||||
internal.Logf("[DRY] move %s -> %s", srcPath, targetAbs)
|
continue
|
||||||
} else if err := os.Rename(srcPath, targetAbs); err != nil {
|
}
|
||||||
internal.Logf("move failed: %v", err)
|
if fi, err := os.Stat(libRoot); err != nil || !fi.IsDir() {
|
||||||
continue
|
log.Printf("upload: library root missing or not accessible: %s (%v)", libRoot, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional one-level subdir under the library
|
||||||
|
destRoot := libRoot
|
||||||
|
if subdir != "" {
|
||||||
|
subdir = sanitizeSegment(subdir)
|
||||||
|
destRoot = filepath.Join(libRoot, subdir)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
if err != nil {
|
// choose library: requested (if allowed) or default
|
||||||
internal.Logf("list: map missing for %q", cl.Username)
|
allRoots, _ := um.ResolveAll(cl.Username)
|
||||||
http.Error(w, "no mapping", http.StatusForbidden)
|
reqLib := sanitizeSegment(r.URL.Query().Get("lib"))
|
||||||
return
|
var rootRel string
|
||||||
|
if reqLib != "" && contains(allRoots, reqLib) {
|
||||||
|
rootRel = reqLib
|
||||||
|
} else {
|
||||||
|
rootRel, err = um.Resolve(cl.Username)
|
||||||
|
if err != nil {
|
||||||
|
internal.Logf("list: map missing for %q", cl.Username)
|
||||||
|
http.Error(w, "no mapping", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
q := strings.TrimPrefix(r.URL.Query().Get("path"), "/")
|
// one-level subdir (optional)
|
||||||
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
q := sanitizeSegment(r.URL.Query().Get("path")) // clamp to single segment
|
||||||
|
|
||||||
var dirAbs string
|
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
|
||||||
if q == "" {
|
dirAbs := rootAbs
|
||||||
dirAbs = rootAbs
|
if q != "" {
|
||||||
} else {
|
if dirAbs, err = internal.SafeJoin(rootAbs, q); err != nil {
|
||||||
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,41 +327,45 @@ 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"`
|
||||||
}
|
}
|
||||||
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)
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
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})
|
||||||
})
|
})
|
||||||
|
|
||||||
// delete
|
// delete
|
||||||
@ -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)
|
||||||
@ -372,28 +407,55 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
jf.RefreshLibrary(cl.JFToken)
|
jf.RefreshLibrary(cl.JFToken)
|
||||||
writeJSON(w, map[string]any{"ok": true})
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
})
|
})
|
||||||
|
|
||||||
// mkdir (create subdirectory under the user's mapped root)
|
// mkdir (create subdirectory under the user's mapped root)
|
||||||
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)
|
|
||||||
if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
|
// 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 }
|
||||||
|
}
|
||||||
|
|
||||||
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})
|
||||||
})
|
})
|
||||||
|
|
||||||
// mount tus (behind auth)
|
// mount tus (behind auth)
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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() {
|
||||||
|
const [authed, setAuthed] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(()=>{
|
||||||
|
api('/api/whoami').then(()=> setAuthed(true)).catch(()=> setAuthed(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function logout(){
|
||||||
|
try { await api('/api/logout', { method:'POST' }) } finally { location.reload() }
|
||||||
|
}
|
||||||
|
|
||||||
export default function App(){
|
|
||||||
const [authed, setAuthed] = useState<boolean>(false)
|
|
||||||
useEffect(()=>{ api('/api/whoami').then(()=>setAuthed(true)).catch(()=>setAuthed(false)) }, [])
|
|
||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(()=>{
|
||||||
try {
|
(async ()=>{
|
||||||
const m = await api<any>('/api/whoami')
|
try{
|
||||||
setMe(m as WhoAmI)
|
setStatus('Loading profile…')
|
||||||
|
const m = await api<WhoAmI>('/api/whoami'); setMe(m as any)
|
||||||
const libs: string[] =
|
const mm:any = m
|
||||||
Array.isArray(m?.roots) && m.roots.length ? m.roots.slice()
|
const L: string[] =
|
||||||
: m?.root ? [m.root] : []
|
Array.isArray(mm?.roots) ? mm.roots :
|
||||||
|
Array.isArray(mm?.libs) ? mm.libs :
|
||||||
setLibraries(libs)
|
(typeof mm?.root === 'string' && mm.root ? [mm.root] : [])
|
||||||
// Auto-select if exactly one library
|
setLibs(L)
|
||||||
setSelectedLib(prev => prev || (libs.length === 1 ? libs[0] : ''))
|
const def = L[0] || ''
|
||||||
|
setLib(def)
|
||||||
const listRaw = await api<any>('/api/list?path='+encodeURIComponent(path))
|
setSub('')
|
||||||
const list = normalizeRows(listRaw)
|
if (def) { await refresh(def, '') }
|
||||||
setRows(list); setSubdir(path)
|
setStatus(def ? `Ready · Destination: /${def}` : 'Choose a library to start')
|
||||||
setStatus(`Ready · Destination: /${[libs[0] ?? '', path].filter(Boolean).join('/')}`)
|
} catch(e:any) {
|
||||||
console.log('[Pegasus] list ok', { path, count: list.length })
|
const msg = String(e?.message || e || '')
|
||||||
} catch (e:any) {
|
setStatus(`Profile error: ${msg}`)
|
||||||
const msg = String(e?.message || e || '')
|
if (msg.toLowerCase().includes('no mapping')) {
|
||||||
console.error('[Pegasus] list error', e)
|
alert('Your account is not linked to any upload library yet. Please contact the admin to be granted access.')
|
||||||
setStatus(`List error: ${msg}`)
|
try { await api('/api/logout', { method:'POST' }) } catch {}
|
||||||
if (msg.toLowerCase().includes('no mapping')) {
|
location.replace('/')
|
||||||
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 {}
|
|
||||||
location.replace('/') // back to login
|
|
||||||
}
|
}
|
||||||
}
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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 don’t 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) */}
|
return (
|
||||||
<div className="grid" style={{alignItems:'end'}}>
|
<>
|
||||||
<div>
|
{/* Header context */}
|
||||||
<label className="meta">Library</label>
|
<section>
|
||||||
{libraries.length <= 1 ? (
|
<hgroup>
|
||||||
<div className="item" style={{padding:10}}>
|
<h2>Signed in: {me?.username}</h2>
|
||||||
<span>{libraries[0] || '(none)'}</span>
|
<p className="meta">Destination: <strong>{destPath || '/(choose a library)'}</strong></p>
|
||||||
</div>
|
</hgroup>
|
||||||
) : (
|
</section>
|
||||||
<select
|
|
||||||
value={selectedLib}
|
{/* Add files */}
|
||||||
onChange={e=> setSelectedLib(e.target.value)}
|
<section>
|
||||||
style={{border:'1px solid #2a2f45', background:'#1c2138', color:'var(--fg)', borderRadius:8, padding:10, width:'100%'}}
|
<h3>Add files</h3>
|
||||||
>
|
<div className="grid-3">
|
||||||
<option value="" disabled>Choose a library…</option>
|
<label>
|
||||||
{libraries.map(lib => <option key={lib} value={lib}>{lib}</option>)}
|
Default date (applied to new selections)
|
||||||
</select>
|
<input type="date" value={globalDate} onChange={e=> setGlobalDate(e.target.value)} />
|
||||||
)}
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="file-picker">
|
||||||
<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 ? (
|
||||||
// ---- Mobile: show a Gallery picker (no capture) + optional Camera quick-capture
|
|
||||||
<>
|
<>
|
||||||
<div>
|
<label>
|
||||||
<label className="meta">Gallery/Photos</label>
|
Gallery/Photos
|
||||||
<input
|
<input type="file" multiple accept="image/*,video/*"
|
||||||
type="file"
|
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
|
||||||
multiple
|
</label>
|
||||||
// no "capture" -> browsers tend to open Photo/Media picker instead of camera or filesystem
|
<label>
|
||||||
accept="image/*,video/*"
|
Camera (optional)
|
||||||
onChange={e => e.target.files && handleChoose(e.target.files)}
|
<input type="file" accept="image/*,video/*" capture="environment"
|
||||||
/>
|
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
|
||||||
</div>
|
</label>
|
||||||
<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>
|
||||||
<label className="meta">Select file(s)</label>
|
Select file(s)
|
||||||
<input
|
<input type="file" multiple accept="image/*,video/*"
|
||||||
type="file"
|
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
|
||||||
multiple
|
</label>
|
||||||
accept="image/*,video/*"
|
<label>
|
||||||
onChange={e => e.target.files && handleChoose(e.target.files)}
|
Select folder(s)
|
||||||
/>
|
<input type="file" multiple ref={folderInputRef}
|
||||||
</div>
|
onChange={e => e.target.files && handleChoose(e.target.files)} disabled={!lib} />
|
||||||
<div>
|
</label>
|
||||||
<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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{(() => {
|
{/* Review & upload */}
|
||||||
const uploadDisabled =
|
<section>
|
||||||
!selectedLib || !sel.length || uploading || sel.some(s=>!s.desc.trim())
|
<h3>Review & Upload → <span className="meta">{destPath || '/(choose a library)'}</span></h3>
|
||||||
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 (
|
{sel.length === 0 ? (
|
||||||
<>
|
<p className="meta">Select at least one file.</p>
|
||||||
<button
|
) : (
|
||||||
type="button"
|
<>
|
||||||
className="btn"
|
<article>
|
||||||
onClick={()=>{
|
<div style={{display:'grid', gap:12}}>
|
||||||
console.log('[Pegasus] Upload click', { files: sel.length, uploading, disabled: uploadDisabled })
|
<label>
|
||||||
setStatus('Upload button clicked')
|
Description for all videos (optional)
|
||||||
if (!uploadDisabled) { void doUpload() }
|
<input
|
||||||
}}
|
placeholder="Short video description"
|
||||||
disabled={uploadDisabled}
|
value={bulkDesc}
|
||||||
aria-disabled={uploadDisabled}
|
onChange={e=> setBulkDesc(e.target.value)}
|
||||||
>
|
/>
|
||||||
Upload {sel.length? `(${sel.length})` : ''}
|
</label>
|
||||||
</button>
|
<button type="button" className="stretch" onClick={applyDescToAllVideos} disabled={!bulkDesc.trim()}>
|
||||||
{disabledReason && <div className="meta bad">{disabledReason}</div>}
|
Apply to all videos
|
||||||
</>
|
</button>
|
||||||
)
|
{videosNeedingDesc > 0
|
||||||
})()}
|
? <small className="meta bad">{videosNeedingDesc} video(s) need a description</small>
|
||||||
</div>
|
: <small className="meta">All videos have descriptions</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
{/* Per-file editor */}
|
|
||||||
{sel.length>0 && (
|
|
||||||
<div className="card">
|
|
||||||
<h4>Ready to upload</h4>
|
|
||||||
<div className="grid">
|
|
||||||
{sel.map((s,i)=>(
|
{sel.map((s,i)=>(
|
||||||
<div key={i} className="item">
|
<article key={i} className="video-card">
|
||||||
<div className="meta">{s.file.name}</div>
|
<div className="thumb-row">
|
||||||
<input
|
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
|
||||||
value={s.desc}
|
</div>
|
||||||
placeholder="Short description (required)"
|
|
||||||
onChange={e=> {
|
<h4 className="filename ellipsis">{s.file.name}</h4>
|
||||||
const v=e.target.value
|
|
||||||
setSel(old => old.map((x,idx)=> idx===i
|
<label>
|
||||||
? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)})
|
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
|
||||||
: x ))
|
<input
|
||||||
}}
|
required={isVideoFile(s.file)}
|
||||||
/>
|
aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
|
||||||
<div style={{display:'grid', gridTemplateColumns:'auto 1fr', gap:8, alignItems:'center', marginTop:6}}>
|
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
|
||||||
<span className="meta">Date</span>
|
value={s.desc}
|
||||||
|
onChange={e=>{
|
||||||
|
const v=e.target.value
|
||||||
|
setSel(old => old.map((x,idx)=> idx===i ? ({...x, desc:v, finalName: composeName(x.date, v, x.file.name)}) : x))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</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="meta" style={{marginTop:8}}>{status}</div>
|
<div className="grid-3">
|
||||||
</section>
|
<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>
|
||||||
|
|
||||||
{/* Destination details and subfolder management */}
|
{Boolean(sanitizeFolderName(newFolder)) && (
|
||||||
<section className="card">
|
<div className="center-actions">
|
||||||
<h3>Destination</h3>
|
<button
|
||||||
|
type="button"
|
||||||
{/* Current subfolder header & actions (one level only) */}
|
onClick={()=>{ const n = sanitizeFolderName(newFolder); if (n) { void createSubfolder(n); setNewFolder('') } }}
|
||||||
<div className="item" style={{display:'flex', gap:12, alignItems:'center', justifyContent:'space-between', flexWrap:'wrap'}}>
|
>
|
||||||
<div className="meta">
|
Create
|
||||||
Library: <b>{selectedLib || '(none)'}</b>
|
</button>
|
||||||
{' · '}
|
|
||||||
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. You’ll upload into <b>{destPath || '/(select a library)'}</b>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid">
|
|
||||||
{/* Only show "Open" when at library root to enforce one-level depth */}
|
|
||||||
{rowsSorted.map(f =>
|
|
||||||
<div key={f.path} className="item">
|
|
||||||
<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 ? (
|
|
||||||
<>
|
|
||||||
{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>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
<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. You’ll upload into <b>/{lib}</b>.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{display:'grid', gap:8}}>
|
||||||
|
{rootDirs.map(d=>(
|
||||||
|
<article key={d} style={{display:'flex', alignItems:'center', justifyContent:'space-between'}}>
|
||||||
|
<div>📁 {d}</div>
|
||||||
|
<div style={{display:'flex', gap:8}}>
|
||||||
|
<button onClick={()=>{ setSub(d); void refresh(lib, d) }}>Select</button>
|
||||||
|
<button onClick={()=> void renameFolder(d)}>Rename</button>
|
||||||
|
<button onClick={()=> void deleteFolder(d)} className="bad">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open style={{marginTop:8}}>
|
||||||
|
<summary>Contents of {destPath || '/(choose)'}</summary>
|
||||||
|
{sortedRows.length === 0 ? (
|
||||||
|
<p className="meta">Empty.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{display:'grid', gap:8}}>
|
||||||
|
{sortedRows.map(f =>
|
||||||
|
<article key={f.path} style={{display:'flex', alignItems:'center', justifyContent:'space-between'}}>
|
||||||
|
<div>
|
||||||
|
<div className="ellipsis">{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}</div>
|
||||||
|
<small className="meta">{f.is_dir?'folder':fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}</small>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', gap:8}}>
|
||||||
|
{f.is_dir ? (
|
||||||
|
<>
|
||||||
|
<button onClick={()=>{ setSub(f.name); void refresh(lib, f.name) }}>Open</button>
|
||||||
|
<button onClick={()=> void renameFolder(f.name)}>Rename</button>
|
||||||
|
<button onClick={()=> void deleteFolder(f.name)} className="bad">Delete</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{sub === '' && <button onClick={()=> void renamePath(f.path)}>Rename</button>}
|
||||||
|
<button onClick={()=> void deletePath(f.path, false)} className="bad">Delete</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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[];
|
||||||
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user