diff --git a/backend/main.go b/backend/main.go
index 36fdf5f..b6675ed 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -5,6 +5,7 @@ import (
"embed"
"encoding/json"
"fmt"
+ "io"
"io/fs"
"log"
"net/http"
@@ -86,7 +87,7 @@ func main() {
// === tusd setup (resumable uploads) ===
store := filestore.FileStore{Path: tusDir}
// 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)
}
locker := memorylocker.New()
@@ -116,56 +117,68 @@ func main() {
}
// read metadata set by the UI
- meta := ev.Upload.MetaData
- desc := strings.TrimSpace(meta["desc"])
- if desc == "" {
- internal.Logf("tus: missing desc; rejecting")
- continue
- }
- date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty
+ meta := ev.Upload.MetaData
+ desc := strings.TrimSpace(meta["desc"])
+ date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty
subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
- orig := meta["filename"]
- if orig == "" {
- orig = "upload.bin"
- }
+ lib := strings.Trim(strings.TrimSpace(meta["lib"]), "/")
+ orig := meta["filename"]
+ if orig == "" { orig = "upload.bin" }
- // resolve per-user root
- userRootRel, err := um.Resolve(claims.Username)
- if err != nil {
- internal.Logf("tus: user map missing: %v", err)
- continue
+ // Decide if description is required by extension (videos only)
+ ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
+ isVideo := map[string]bool{
+ "mp4": true, "mkv": true, "mov": true, "avi": true, "m4v": true,
+ "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
- finalName, err := internal.ComposeFinalName(date, desc, orig)
- if err != nil {
- internal.Logf("tus: bad target name: %v", err)
- continue
- }
-
- rootAbs, _ := internal.SafeJoin(mediaRoot, userRootRel)
- var targetAbs string
- if subdir == "" {
- targetAbs, err = internal.SafeJoin(rootAbs, finalName)
+ // Resolve destination root under /media
+ var libRoot string
+ if lib != "" {
+ lib = sanitizeSegment(lib)
+ libRoot = filepath.Join(mediaRoot, lib) // explicit library
} else {
- targetAbs, err = internal.SafeJoin(rootAbs, filepath.Join(subdir, finalName))
- }
- if err != nil {
- internal.Logf("tus: path escape prevented: %v", err)
- continue
+ 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
+ }
}
- srcPath := ev.Upload.Storage["Path"]
- _ = os.MkdirAll(filepath.Dir(targetAbs), 0o755)
- if internal.DryRun {
- internal.Logf("[DRY] move %s -> %s", srcPath, targetAbs)
- } else if err := os.Rename(srcPath, targetAbs); err != nil {
- internal.Logf("move failed: %v", err)
- continue
+ // 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
+ }
+
+ // 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)
}
}()
@@ -248,22 +261,29 @@ func main() {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- 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
+
+ // 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 {
+ 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"), "/")
- rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
+ // one-level subdir (optional)
+ q := sanitizeSegment(r.URL.Query().Get("path")) // clamp to single segment
- var dirAbs string
- if q == "" {
- dirAbs = rootAbs
- } else {
- dirAbs, err = internal.SafeJoin(rootAbs, q)
- if err != nil {
+ rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
+ dirAbs := rootAbs
+ if q != "" {
+ if dirAbs, err = internal.SafeJoin(rootAbs, q); err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
@@ -278,7 +298,6 @@ func main() {
out := make([]listEntry, 0, len(ents))
for _, d := range ents {
info, _ := d.Info()
-
var size int64
if info != nil && !d.IsDir() {
size = info.Size()
@@ -287,7 +306,6 @@ func main() {
if info != nil {
mtime = info.ModTime().Unix()
}
-
out = append(out, listEntry{
Name: d.Name(),
Path: filepath.Join(q, d.Name()),
@@ -309,41 +327,45 @@ func main() {
return
}
var p struct {
+ Lib string `json:"lib"`
From string `json:"from"`
To string `json:"to"`
}
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
}
- // 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)) {
http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest)
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)
- fromAbs, err := internal.SafeJoin(rootAbs, p.From)
- if err != nil {
- http.Error(w, "forbidden", http.StatusForbidden)
- return
- }
- toAbs, err := internal.SafeJoin(rootAbs, p.To)
- if err != nil {
- http.Error(w, "forbidden", http.StatusForbidden)
- return
- }
- _ = os.MkdirAll(filepath.Dir(toAbs), 0o755)
+ fromAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.From, "/"))
+ if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
+ toAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.To, "/"))
+ if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
+
+ _ = os.MkdirAll(filepath.Dir(toAbs), 0o2775)
if internal.DryRun {
internal.Logf("[DRY] mv %s -> %s", fromAbs, toAbs)
} else if err := os.Rename(fromAbs, toAbs); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
+ http.Error(w, err.Error(), http.StatusInternalServerError); return
}
jf.RefreshLibrary(cl.JFToken)
- writeJSON(w, map[string]any{"ok": true})
+ writeJSON(w, map[string]any{"ok": true})
})
// delete
@@ -353,16 +375,29 @@ func main() {
http.Error(w, "unauthorized", http.StatusUnauthorized)
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"
+
rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
abs, err := internal.SafeJoin(rootAbs, path)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
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)
} else {
err = os.Remove(abs)
@@ -372,28 +407,55 @@ func main() {
return
}
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)
r.Post("/api/mkdir", func(w http.ResponseWriter, r *http.Request) {
cl, err := internal.CurrentUser(r)
if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return }
- var p struct{ Path string `json:"path"` } // e.g., "Trips/2025-09"
+
+ 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 {
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)
- 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 internal.DryRun {
internal.Logf("[DRY] mkdir -p %s", abs)
- } else if err := os.MkdirAll(abs, 0o755); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError); return
+ } else if err := os.MkdirAll(abs, 0o2775); err != nil {
+ 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)
@@ -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 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
+}
diff --git a/frontend/index.html b/frontend/index.html
index 3d9a3ad..848493c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,8 +1,12 @@
-
+
+
Pegasus
-
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6e83725..2a075e6 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "pegasus-frontend",
"version": "1.0.0",
"dependencies": {
+ "@picocss/pico": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tus-js-client": "^4.3.1"
@@ -794,6 +795,12 @@
"@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": {
"version": "1.0.0-beta.34",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 8f8807d..97e5ddb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,6 +9,7 @@
"preview": "vite preview --port 5173"
},
"dependencies": {
+ "@picocss/pico": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tus-js-client": "^4.3.1"
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3778fdb..ef5bac9 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,17 +1,31 @@
// frontend/src/App.tsx
-import React, { useEffect, useState } from 'react'
-import { api } from './api'
-import Login from './Login'
+import React from 'react'
import Uploader from './Uploader'
-import './styles.css'
+import Login from './Login'
+import { api } from './api'
+
+export default function App() {
+ const [authed, setAuthed] = React.useState(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(false)
- useEffect(()=>{ api('/api/whoami').then(()=>setAuthed(true)).catch(()=>setAuthed(false)) }, [])
return (
- <>
- 🪽 Pegasus {authed && api('/api/logout',{method:'POST'}).then(()=>location.replace('/'))}>Logout }
- {authed ? : setAuthed(true)} /> }
- >
+
+
+
+ 🪽
+ Pegasus
+
+ {authed && Logout }
+
+
+ {authed ? : }
+
)
}
diff --git a/frontend/src/Uploader.tsx b/frontend/src/Uploader.tsx
index ef27929..39c49c6 100644
--- a/frontend/src/Uploader.tsx
+++ b/frontend/src/Uploader.tsx
@@ -1,90 +1,109 @@
// frontend/src/Uploader.tsx
-import React, { useEffect, useMemo, useRef, useState } from 'react'
+import React from 'react'
import { api, WhoAmI } from './api'
import * as tus from 'tus-js-client'
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string }
-const [mobile, setMobile] = useState(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())
-function isLikelyMobile(): boolean {
- if (typeof window === 'undefined') return false
- const ua = navigator.userAgent || ''
- const touch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
- // catch Android/iOS/iPadOS
- return touch || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
-}
-
+// ---------- helpers ----------
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'
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){
- 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)}`
}
+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 {
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any))
}
-// Normalize API list rows (works with old CamelCase or new snake_case)
-function normalizeRows(raw: any): FileRow[] {
- if (!Array.isArray(raw)) return []
- return raw.map((r: any) => ({
- name: r?.name ?? r?.Name ?? '',
- path: r?.path ?? r?.Path ?? '',
- is_dir: typeof r?.is_dir === 'boolean' ? r.is_dir
- : typeof r?.IsDir === 'boolean' ? r.IsDir
- : false,
- size: typeof r?.size === 'number' ? r.size : (typeof r?.Size === 'number' ? r.Size : 0),
- mtime: typeof r?.mtime === 'number' ? r.mtime : (typeof r?.Mtime === 'number' ? r.Mtime : 0),
- })).filter(r => r && typeof r.name === 'string' && typeof r.path === 'string')
+function isLikelyMobileUA(): boolean {
+ if (typeof window === 'undefined') return false
+ const ua = navigator.userAgent || ''
+ const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
+ return coarse || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
+}
+function useIsMobile(): boolean {
+ const [mobile, setMobile] = React.useState(false)
+ React.useEffect(()=>{ setMobile(isLikelyMobileUA()) }, [])
+ return mobile
}
-// simple mobile detection (no CSS change needed)
-function useIsMobile() {
- const [isMobile, setIsMobile] = useState(false)
- useEffect(() => {
- const mq = window.matchMedia('(max-width: 640px)')
- const handler = (e: MediaQueryListEvent | MediaQueryList) =>
- setIsMobile(('matches' in e ? e.matches : (e as MediaQueryList).matches))
- handler(mq)
- mq.addEventListener('change', handler as any)
- return () => mq.removeEventListener('change', handler as any)
- }, [])
- return isMobile
+// Disable tus resume completely (v2 API: addUpload/removeUpload/listUploads)
+const NoResumeUrlStorage: any = {
+ addUpload: async (_u: any) => {},
+ removeUpload: async (_u: any) => {},
+ listUploads: async () => [],
+ // compatibility:
+ findUploadsByFingerprint: async (_fp: string) => []
+}
+const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}`
+
+// ---------- thumbnail ----------
+function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) {
+ const [url, setUrl] = React.useState()
+ 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
+ if (isVideoFile(file)) return
+ return 📄
}
+// ---------- component ----------
export default function Uploader(){
- const [me, setMe] = useState()
- const [libraries, setLibraries] = useState([]) // from whoami.roots or [whoami.root]
- const [selectedLib, setSelectedLib] = useState('') // must be chosen to upload
+ const mobile = useIsMobile()
- const [subdir, setSubdir] = useState('') // one-level subfolder name (no '/')
- const [rows, setRows] = useState([])
- const destPath = `/${[selectedLib, subdir].filter(Boolean).join('/')}`
+ const [me, setMe] = React.useState()
+ const [libs, setLibs] = React.useState([])
+ const [lib, setLib] = React.useState('')
- const [status, setStatus] = useState('')
- const [globalDate, setGlobalDate] = useState(new Date().toISOString().slice(0,10))
- const [uploading, setUploading] = useState(false)
- const [sel, setSel] = useState([])
- const folderInputRef = useRef(null) // to set webkitdirectory
- const isMobile = useIsMobile()
+ const [sub, setSub] = React.useState('')
+ const [rootDirs, setRootDirs] = React.useState([])
+ const [rows, setRows] = React.useState([])
- // Enable directory selection on the folder picker (non-standard attr)
- useEffect(() => {
+ const [status, setStatus] = React.useState('')
+ const [globalDate, setGlobalDate] = React.useState(new Date().toISOString().slice(0,10))
+ const [uploading, setUploading] = React.useState(false)
+ const [sel, setSel] = React.useState([])
+ const [bulkDesc, setBulkDesc] = React.useState('') // helper: apply to all videos
+ const folderInputRef = React.useRef(null)
+
+ React.useEffect(() => {
const el = folderInputRef.current
if (!el) return
if (mobile) {
- // ensure we don't accidentally force folder mode on mobile
el.removeAttribute('webkitdirectory')
el.removeAttribute('directory')
} else {
@@ -93,68 +112,109 @@ export default function Uploader(){
}
}, [mobile])
- // Fetch whoami + list
- async function refresh(path=''){
- try {
- const m = await api('/api/whoami')
- setMe(m as WhoAmI)
-
- const libs: string[] =
- Array.isArray(m?.roots) && m.roots.length ? m.roots.slice()
- : m?.root ? [m.root] : []
-
- setLibraries(libs)
- // Auto-select if exactly one library
- setSelectedLib(prev => prev || (libs.length === 1 ? libs[0] : ''))
-
- const listRaw = await api('/api/list?path='+encodeURIComponent(path))
- const list = normalizeRows(listRaw)
- setRows(list); setSubdir(path)
- setStatus(`Ready · Destination: /${[libs[0] ?? '', path].filter(Boolean).join('/')}`)
- console.log('[Pegasus] list ok', { path, count: list.length })
- } catch (e:any) {
- const msg = String(e?.message || e || '')
- console.error('[Pegasus] list error', e)
- setStatus(`List error: ${msg}`)
- 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.')
- try { await api('/api/logout', { method:'POST' }) } catch {}
- location.replace('/') // back to login
+ // initial load
+ React.useEffect(()=>{
+ (async ()=>{
+ try{
+ setStatus('Loading profile…')
+ const m = await api('/api/whoami'); setMe(m as any)
+ const mm:any = m
+ const L: string[] =
+ Array.isArray(mm?.roots) ? mm.roots :
+ Array.isArray(mm?.libs) ? mm.libs :
+ (typeof mm?.root === 'string' && mm.root ? [mm.root] : [])
+ setLibs(L)
+ const def = L[0] || ''
+ setLib(def)
+ setSub('')
+ if (def) { await refresh(def, '') }
+ setStatus(def ? `Ready · Destination: /${def}` : 'Choose a library to start')
+ } catch(e:any) {
+ const msg = String(e?.message || e || '')
+ setStatus(`Profile error: ${msg}`)
+ 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.')
+ try { await api('/api/logout', { method:'POST' }) } catch {}
+ 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(`/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(`/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){
const arr = Array.from(files).map(f=>{
const base = (f as any).webkitRelativePath || 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 }
})
console.log('[Pegasus] selected files', arr.map(a=>a.file.name))
setSel(arr)
}
- // When the global date changes, recompute per-file finalName
- useEffect(()=>{
+ // recompute finalName when global date changes
+ React.useEffect(()=>{
setSel(old => old.map(x=> ({...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name)})))
}, [globalDate])
// Warn before closing mid-upload
- useEffect(()=>{
+ React.useEffect(()=>{
const handler = (e: BeforeUnloadEvent) => { if (uploading) { e.preventDefault(); e.returnValue = '' } }
window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler)
}, [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(){
if(!me) { setStatus('Not signed in'); return }
- if(!selectedLib){ alert('Please select a Library to upload to.'); return }
- if(sel.some(s => !s.desc.trim())){ alert('Please provide a brief description for every file.'); return }
+ if(!lib) { alert('Please select a Library to upload into.'); 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…')
setUploading(true)
-
try{
for(const s of sel){
// eslint-disable-next-line no-await-in-loop
@@ -163,18 +223,12 @@ export default function Uploader(){
endpoint: '/tus/',
chunkSize: 5*1024*1024,
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: {
filename: s.file.name,
- // Server treats this as a subdirectory under the user's mapped root.
- // When backend supports multiple libraries, it will also need the selected library.
- subdir: subdir || "",
- date: s.date, // per-file YYYY-MM-DD
- desc: s.desc // server composes final
+ lib: lib,
+ subdir: sub || "",
+ date: s.date,
+ desc: s.desc // server enforces: required for videos only
},
onError: (err: Error | tus.DetailedError)=>{
let msg = String(err)
@@ -201,198 +255,217 @@ export default function Uploader(){
resolve()
},
}
- // Ensure cookie is sent even if the tus typings don’t list this field
opts.withCredentials = true
+ ;(opts as any).urlStorage = NoResumeUrlStorage
+ ;(opts as any).fingerprint = NoResumeFingerprint
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()
})
}
setStatus('All uploads complete')
setSel([])
- await refresh(subdir)
+ await refresh(lib, sub)
} finally {
setUploading(false)
}
}
- async function rename(oldp:string){
- const name = prompt('New name (YYYY.MM.DD.description.ext for files, or folder name):', oldp.split('/').pop()||''); if(!name) return
+ // -------- one-level subfolder ops --------
+ 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{
await api('/api/rename', {
method:'POST',
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){
console.error('[Pegasus] rename error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
-
- async function del(p:string, recursive:boolean){
+ async function deletePath(p:string, recursive:boolean){
if(!confirm(`Delete ${p}${recursive?' (recursive)':''}?`)) return
try{
- await api('/api/file?path='+encodeURIComponent(p)+'&recursive='+(recursive?'true':'false'), { method:'DELETE' })
- await refresh(subdir)
+ await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive?'true':'false' })}`, { method:'DELETE' })
+ await refresh(lib, sub)
} catch(e:any){
console.error('[Pegasus] delete error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
- function goUp(){
- setSubdir(''); void refresh('')
- }
-
- // Precompute a safely sorted copy for rendering (no in-place mutation)
- const rowsSorted = useMemo(() => {
- const copy = Array.isArray(rows) ? [...rows] : []
- return copy
- .filter(r => r && typeof r.name === 'string')
- .sort((a, b) => (Number(b.is_dir) - Number(a.is_dir)) || a.name.localeCompare(b.name))
+ // sort rows
+ const sortedRows = React.useMemo(()=>{
+ const arr = Array.isArray(rows) ? rows.slice() : []
+ return arr.sort((a,b)=>{
+ const dirFirst = (Number(b?.is_dir ? 1:0) - Number(a?.is_dir ? 1:0))
+ if (dirFirst !== 0) return dirFirst
+ const an = (a?.name ?? '')
+ const bn = (b?.name ?? '')
+ return an.localeCompare(bn)
+ })
}, [rows])
- // Restrict to one-level: only allow "Open" when we're at library root (subdir === '')
- const canOpenDeeper = subdir === ''
+ const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
+ const videosNeedingDesc = sel.filter(s => isVideoFile(s.file) && !s.desc.trim()).length
- return (<>
-
-
-
Signed in: {me?.username}
-
Destination: {destPath || '/(select a library)'}
-
+ const [newFolder, setNewFolder] = React.useState('')
- {/* Destination (Library + Subfolder) */}
-
-
-
Library
- {libraries.length <= 1 ? (
-
- {libraries[0] || '(none)'}
-
- ) : (
-
setSelectedLib(e.target.value)}
- style={{border:'1px solid #2a2f45', background:'#1c2138', color:'var(--fg)', borderRadius:8, padding:10, width:'100%'}}
- >
- Choose a library…
- {libraries.map(lib => {lib} )}
-
- )}
+ return (
+ <>
+ {/* Header context */}
+
+
+ Signed in: {me?.username}
+ Destination: {destPath || '/(choose a library)'}
+
+
+
+ {/* Add files */}
+
+ Add files
+
+
+ Default date (applied to new selections)
+ setGlobalDate(e.target.value)} />
+
-
-
- Default date (applied to new selections)
- setGlobalDate(e.target.value)} />
-
-
- {/* Pickers: mobile vs desktop */}
-
+
+
- {(() => {
- 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.'
+ {/* Review & upload */}
+
+ Review & Upload → {destPath || '/(choose a library)'}
- return (
- <>
- {
- console.log('[Pegasus] Upload click', { files: sel.length, uploading, disabled: uploadDisabled })
- setStatus('Upload button clicked')
- if (!uploadDisabled) { void doUpload() }
- }}
- disabled={uploadDisabled}
- aria-disabled={uploadDisabled}
- >
- Upload {sel.length? `(${sel.length})` : ''}
-
- {disabledReason && {disabledReason}
}
- >
- )
- })()}
-
+ {sel.length === 0 ? (
+ Select at least one file.
+ ) : (
+ <>
+
+
+
+ Description for all videos (optional)
+ setBulkDesc(e.target.value)}
+ />
+
+
+ Apply to all videos
+
+ {videosNeedingDesc > 0
+ ? {videosNeedingDesc} video(s) need a description
+ : All videos have descriptions
+ }
+
+
- {/* Per-file editor */}
- {sel.length>0 && (
-
-
Ready to upload
-
{sel.map((s,i)=>(
-
-
{s.file.name}
-
{
- const v=e.target.value
- setSel(old => old.map((x,idx)=> idx===i
- ? ({...x, desc:v, finalName: composeName(globalDate, v, x.file.name)})
- : x ))
- }}
- />
-
-
Date
+
+
+
+ {s.file.name}
+
+
+ {isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
+ {
+ 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))
+ }}
+ />
+
+
+
+ Date
old.map((x,idx)=> idx===i ? ({...x, date:v, finalName: composeName(v, x.desc, x.file.name)}) : x))
}}
/>
-
-
→ {s.finalName}
- {typeof s.progress === 'number' && (
- <>
-
- {s.err &&
Error: {s.err}
}
- >
- )}
-
+
+
+
→ {s.finalName}
+ {typeof s.progress === 'number' &&
}
+ {s.err &&
Error: {s.err} }
+
))}
-
+ >
+ )}
+
+
+ {/* Folder (one-level manager) */}
+
+ Library
+
+
+ Library
+ { setLib(e.target.value); setSub('') }}>
+ — Select a library —
+ {libs.map(L => {L} )}
+
+ {libs.length<=1 && Auto-selected }
+
+
+
+ Subfolder (optional, 1 level)
+ { const v = clampOneLevel(e.target.value); setSub(v); void refresh(lib, v) }}
+ disabled={!lib}
+ >
+ (Library root)
+ {rootDirs.map(d => {d} )}
+
+
- )}
- {status}
-
+
+
+ New subfolder name (optional)
+ setNewFolder(sanitizeFolderName(e.target.value))}
+ disabled={!lib}
+ inputMode="text"
+ pattern="[A-Za-z0-9_.-]+"
+ title="Use letters, numbers, underscores, dashes or dots"
+ />
+
- {/* Destination details and subfolder management */}
-
- Destination
-
- {/* Current subfolder header & actions (one level only) */}
-
-
- Library: {selectedLib || '(none)'}
- {' · '}
- Subfolder: {subdir ? subdir : '(root)'}
-
-
-
-
- {/* Create subfolder (root only) */}
- {!subdir && (
- { setSubdir(p); void refresh(p) }}
- />
- )}
-
- {/* Listing */}
- {rowsSorted.length === 0 ? (
-
-
- No items to show here. You’ll upload into {destPath || '/(select a library)'} .
-
-
- ) : (
-
- {/* Only show "Open" when at library root to enforce one-level depth */}
- {rowsSorted.map(f =>
-
-
{f.is_dir?'📁':'🎞️'} {f.name}
-
{f.is_dir?'folder':fmt(f.size)} · {new Date(f.mtime*1000).toLocaleString()}
-
+ {Boolean(sanitizeFolderName(newFolder)) && (
+
+ { const n = sanitizeFolderName(newFolder); if (n) { void createSubfolder(n); setNewFolder('') } }}
+ >
+ Create
+
)}
- )}
-
- >)
+
+
+
+
Current: {sub || '(library root)'}
+ {sub && (
+
+ void renameFolder(sub)}>Rename
+ void deleteFolder(sub)} className="bad">Delete
+ { setSub(''); void refresh(lib, '') }}>Clear
+
+ )}
+
+
+
+
+ Subfolders
+ {rootDirs.length === 0 ? (
+ No subfolders yet. You’ll upload into /{lib} .
+ ) : (
+
+ {rootDirs.map(d=>(
+
+ 📁 {d}
+
+ { setSub(d); void refresh(lib, d) }}>Select
+ void renameFolder(d)}>Rename
+ void deleteFolder(d)} className="bad">Delete
+
+
+ ))}
+
+ )}
+
+
+
+ Contents of {destPath || '/(choose)'}
+ {sortedRows.length === 0 ? (
+ Empty.
+ ) : (
+
+ {sortedRows.map(f =>
+
+
+
{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}
+
{f.is_dir?'folder':fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}
+
+
+ {f.is_dir ? (
+ <>
+ { setSub(f.name); void refresh(lib, f.name) }}>Open
+ void renameFolder(f.name)}>Rename
+ void deleteFolder(f.name)} className="bad">Delete
+ >
+ ) : (
+ <>
+ {sub === '' && void renamePath(f.path)}>Rename }
+ void deletePath(f.path, false)} className="bad">Delete
+ >
+ )}
+
+
+ )}
+
+ )}
+
+
+
+
+ {status}
+ { const disabled = !lib || !sel.length || uploading || videosNeedingDesc>0; if (!disabled) void doUpload() }}
+ disabled={!lib || !sel.length || uploading || videosNeedingDesc>0}
+ >
+ Upload {sel.length? `(${sel.length})` : ''}
+
+
+ >
+ )
}
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
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 (
-
- setName(e.target.value)} />
- Create
-
- );
-}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 39833eb..503b7bc 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1,20 +1,9 @@
// frontend/src/api.ts
import 'tus-js-client'
-import type { UrlStorage } from 'tus-js-client'
-
declare module 'tus-js-client' {
interface UploadOptions {
- /** Send cookies on CORS requests */
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(path: string, init?: RequestInit): Promise {
if (!r.ok) throw new Error(await r.text());
const ct = r.headers.get('content-type') || '';
if (ct.includes('json')) return r.json() as Promise;
- // Fallback: try to parse body as JSON; otherwise return text
const txt = await r.text();
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[];
+};
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 2f47a0b..5e6023f 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -2,4 +2,15 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
-createRoot(document.getElementById('root')!).render( )
+import './styles.css'
+
+const el = document.getElementById('root')
+if (!el) {
+ throw new Error('Missing
in index.html')
+}
+
+createRoot(el).render(
+
+
+
+)
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 5794557..aceb560 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1,24 +1,118 @@
/* frontend/src/styles.css */
-:root{--bg:#0f1222;--fg:#e8e8f0;--muted:#9aa0a6;--card:#171a2e;--accent:#7aa2f7;--bad:#ef5350;--good:#66bb6a}
-*{box-sizing:border-box}
-html,body,#root{height:100%}
-body{margin:0;font:16px/1.45 system-ui;background:var(--bg);color:var(--fg)}
-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:16px}
-.card{background:var(--card);border:1px solid #242847;border-radius:12px;padding:14px;margin:10px 0}
-.btn{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px}
-input[type=file]::-webkit-file-upload-button{padding:10px;border-radius:8px}
-input[type=file]{padding:10px}
-.hide-on-mobile{display:block}
-input[type=file],input[type=text],input[type=password]{border:1px solid #2a2f45;background:#1c2138;color:var(--fg);border-radius:8px;padding:10px 12px;width:100%}
-.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
-.item{padding:12px;border:1px solid #2a2f45;border-radius:10px;background:#1a1e34}
-.meta{font-size:12px;color:var(--muted)}
-progress{width:100%;height:8px}
-.bad{color:var(--bad)} .good{color:var(--good)}
-@media (max-width:640px){ header{padding:12px}
- .grid{grid-template-columns:1fr}
- .hide-on-mobile{display:none}
- .btn{padding:14px 16px}
+@import '@picocss/pico/css/pico.classless.min.css';
+
+/* Small, framework-friendly helpers only */
+:root { --thumb: 112px; }
+
+main { max-width: 960px; margin: 0 auto; padding: 0 12px 24px; }
+section { margin-block: 1rem; }
+small.meta { color: var(--muted-color); }
+
+/* Responsive columns for form controls */
+.columns {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: 1fr; /* mobile: one column */
+ align-items: end;
}
+@media (min-width: 720px) {
+ .columns { grid-template-columns: repeat(3, minmax(0,1fr)); } /* desktop: 3 cols */
+}
+
+/* Two-up area (e.g., file pickers) */
+.file-picker {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: 1fr; /* mobile: stack */
+}
+@media (min-width: 720px) {
+ .file-picker { grid-template-columns: 1fr 1fr; } /* desktop: 2 cols */
+}
+
+/* File card: thumb above on mobile, side-by-side on wide screens */
+.file-card {
+ display: grid;
+ grid-template-columns: var(--thumb) 1fr;
+ gap: .75rem;
+ align-items: start;
+}
+.file-thumb {
+ width: var(--thumb);
+ height: var(--thumb);
+ object-fit: cover;
+ border-radius: .5rem;
+}
+@media (max-width: 600px) {
+ .file-card { grid-template-columns: 1fr; }
+ .file-thumb { width: 100%; height: auto; max-height: 240px; }
+}
+
+/* Keep the action visible while scrolling the list */
+.sticky-actions {
+ position: sticky;
+ bottom: 0;
+ padding: .75rem;
+ background: var(--card-background-color);
+ border-top: 1px solid var(--muted-border-color, #2e2e2e);
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ align-items: center;
+ z-index: 10;
+}
+
+/* Container */
+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; }