From 2e856c0b3ebff7944d304bc4c6161abc90271c7c Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 16 Sep 2025 23:06:40 -0500 Subject: [PATCH] it works again --- backend/main.go | 368 +++++++++++++---- frontend/index.html | 8 +- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/App.tsx | 36 +- frontend/src/Uploader.tsx | 802 +++++++++++++++++++++---------------- frontend/src/api.ts | 19 +- frontend/src/main.tsx | 13 +- frontend/src/styles.css | 138 ++++++- 9 files changed, 907 insertions(+), 485 deletions(-) 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 && }
-
{authed ? : setAuthed(true)} /> }
- +
+
+
+ 🪽 + Pegasus +
+ {authed && } +
+ + {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