updated
This commit is contained in:
parent
05c9d04db9
commit
82cd87c92e
435
backend/main.go
435
backend/main.go
@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -12,12 +13,15 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
@ -38,14 +42,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RecordJob struct {
|
type RecordJob struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
model string `json:"model"`
|
model string `json:"model"`
|
||||||
SourceURL string `json:"sourceUrl"`
|
SourceURL string `json:"sourceUrl"`
|
||||||
Output string `json:"output"`
|
Output string `json:"output"`
|
||||||
Status JobStatus `json:"status"`
|
Status JobStatus `json:"status"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
|
||||||
PreviewDir string `json:"-"`
|
PreviewDir string `json:"-"`
|
||||||
PreviewImage string `json:"-"`
|
PreviewImage string `json:"-"`
|
||||||
@ -68,6 +73,55 @@ var (
|
|||||||
// ffmpeg-Binary suchen (env, neben EXE, oder PATH)
|
// ffmpeg-Binary suchen (env, neben EXE, oder PATH)
|
||||||
var ffmpegPath = detectFFmpegPath()
|
var ffmpegPath = detectFFmpegPath()
|
||||||
|
|
||||||
|
type durEntry struct {
|
||||||
|
size int64
|
||||||
|
mod time.Time
|
||||||
|
sec float64
|
||||||
|
}
|
||||||
|
|
||||||
|
var durCache = struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]durEntry
|
||||||
|
}{m: map[string]durEntry{}}
|
||||||
|
|
||||||
|
func durationSecondsCached(path string) (float64, error) {
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
durCache.mu.Lock()
|
||||||
|
if e, ok := durCache.m[path]; ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
|
||||||
|
durCache.mu.Unlock()
|
||||||
|
return e.sec, nil
|
||||||
|
}
|
||||||
|
durCache.mu.Unlock()
|
||||||
|
|
||||||
|
// ffprobe (oder notfalls ffmpeg -i parsen)
|
||||||
|
cmd := exec.Command("ffprobe",
|
||||||
|
"-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := strings.TrimSpace(string(out))
|
||||||
|
sec, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || sec <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid duration: %q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
durCache.mu.Lock()
|
||||||
|
durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec}
|
||||||
|
durCache.mu.Unlock()
|
||||||
|
|
||||||
|
return sec, nil
|
||||||
|
}
|
||||||
|
|
||||||
// main.go
|
// main.go
|
||||||
|
|
||||||
type RecorderSettings struct {
|
type RecorderSettings struct {
|
||||||
@ -423,6 +477,112 @@ func remuxTSToMP4(tsPath, mp4Path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MP4 Streaming Optimierung (Fast Start) ---
|
||||||
|
// "Fast Start" bedeutet: moov vor mdat (Browser kann sofort Metadaten lesen)
|
||||||
|
func isFastStartMP4(path string) (bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
for i := 0; i < 256; i++ {
|
||||||
|
var hdr [8]byte
|
||||||
|
if _, err := io.ReadFull(f, hdr[:]); err != nil {
|
||||||
|
// unklar/kurz -> nicht anfassen
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sz32 := binary.BigEndian.Uint32(hdr[0:4])
|
||||||
|
typ := string(hdr[4:8])
|
||||||
|
|
||||||
|
var boxSize int64
|
||||||
|
headerSize := int64(8)
|
||||||
|
|
||||||
|
if sz32 == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if sz32 == 1 {
|
||||||
|
var ext [8]byte
|
||||||
|
if _, err := io.ReadFull(f, ext[:]); err != nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
boxSize = int64(binary.BigEndian.Uint64(ext[:]))
|
||||||
|
headerSize = 16
|
||||||
|
} else {
|
||||||
|
boxSize = int64(sz32)
|
||||||
|
}
|
||||||
|
|
||||||
|
if boxSize < headerSize {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch typ {
|
||||||
|
case "moov":
|
||||||
|
return true, nil
|
||||||
|
case "mdat":
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.Seek(boxSize-headerSize, io.SeekCurrent); err != nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureFastStartMP4(path string) error {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" || !strings.EqualFold(filepath.Ext(path), ".mp4") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ffmpegPath) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := isFastStartMP4(path)
|
||||||
|
if err == nil && ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
base := filepath.Base(path)
|
||||||
|
tmp := filepath.Join(dir, ".__faststart__"+base+".tmp")
|
||||||
|
bak := filepath.Join(dir, ".__faststart__"+base+".bak")
|
||||||
|
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
_ = os.Remove(bak)
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath,
|
||||||
|
"-y",
|
||||||
|
"-i", path,
|
||||||
|
"-c", "copy",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
tmp,
|
||||||
|
)
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return fmt.Errorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomar austauschen
|
||||||
|
if err := os.Rename(path, bak); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return fmt.Errorf("rename original to bak failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
_ = os.Rename(bak, path)
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return fmt.Errorf("rename tmp to original failed: %w", err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(bak)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func extractLastFrameJPEG(path string) ([]byte, error) {
|
func extractLastFrameJPEG(path string) ([]byte, error) {
|
||||||
cmd := exec.Command(
|
cmd := exec.Command(
|
||||||
ffmpegPath,
|
ffmpegPath,
|
||||||
@ -905,6 +1065,79 @@ func resolvePathRelativeToApp(p string) (string, error) {
|
|||||||
return filepath.Join(wd, p), nil
|
return filepath.Join(wd, p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frontend (Vite build) als SPA ausliefern: Dateien aus dist, sonst index.html
|
||||||
|
func registerFrontend(mux *http.ServeMux) {
|
||||||
|
// Kandidaten: zuerst ENV, dann typische Ordner
|
||||||
|
candidates := []string{
|
||||||
|
strings.TrimSpace(os.Getenv("FRONTEND_DIST")),
|
||||||
|
"web/dist",
|
||||||
|
"dist",
|
||||||
|
}
|
||||||
|
|
||||||
|
var distAbs string
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
abs, err := resolvePathRelativeToApp(c)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(filepath.Join(abs, "index.html")); err == nil && !fi.IsDir() {
|
||||||
|
distAbs = abs
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if distAbs == "" {
|
||||||
|
fmt.Println("⚠️ Frontend dist nicht gefunden (tried: FRONTEND_DIST, frontend/dist, dist) – API läuft trotzdem.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("🖼️ Frontend dist:", distAbs)
|
||||||
|
|
||||||
|
fileServer := http.FileServer(http.Dir(distAbs))
|
||||||
|
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// /api bleibt bei deinen API-Routen (längeres Pattern gewinnt),
|
||||||
|
// aber falls mal was durchrutscht:
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Wenn echte Datei existiert -> ausliefern
|
||||||
|
reqPath := r.URL.Path
|
||||||
|
if reqPath == "" || reqPath == "/" {
|
||||||
|
// index.html
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL-Pfad in Dateisystem-Pfad umwandeln (ohne Traversal)
|
||||||
|
clean := path.Clean("/" + reqPath) // path.Clean (für URL-Slashes)
|
||||||
|
rel := strings.TrimPrefix(clean, "/")
|
||||||
|
onDisk := filepath.Join(distAbs, filepath.FromSlash(rel))
|
||||||
|
|
||||||
|
if fi, err := os.Stat(onDisk); err == nil && !fi.IsDir() {
|
||||||
|
// Statische Assets ruhig cachen (Vite hashed assets)
|
||||||
|
ext := strings.ToLower(filepath.Ext(onDisk))
|
||||||
|
if ext != "" && ext != ".html" {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
}
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) SPA-Fallback: alle "Routen" ohne Datei -> index.html
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// routes.go (package main)
|
// routes.go (package main)
|
||||||
func registerRoutes(mux *http.ServeMux) {
|
func registerRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/api/settings", recordSettingsHandler)
|
mux.HandleFunc("/api/settings", recordSettingsHandler)
|
||||||
@ -919,6 +1152,7 @@ func registerRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/record/done", recordDoneList)
|
mux.HandleFunc("/api/record/done", recordDoneList)
|
||||||
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
|
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
|
||||||
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
|
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
|
||||||
|
mux.HandleFunc("/api/record/keep", recordKeepVideo)
|
||||||
|
|
||||||
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
|
|
||||||
@ -932,6 +1166,9 @@ func registerRoutes(mux *http.ServeMux) {
|
|||||||
|
|
||||||
// ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
|
// ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
|
||||||
RegisterModelAPI(mux, store)
|
RegisterModelAPI(mux, store)
|
||||||
|
|
||||||
|
// ✅ Frontend (SPA) ausliefern
|
||||||
|
registerFrontend(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- main ---
|
// --- main ---
|
||||||
@ -1313,14 +1550,17 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
|||||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
t := fi.ModTime()
|
t := fi.ModTime()
|
||||||
|
|
||||||
|
dur, _ := durationSecondsCached(full)
|
||||||
|
|
||||||
list = append(list, &RecordJob{
|
list = append(list, &RecordJob{
|
||||||
ID: base,
|
ID: base,
|
||||||
SourceURL: "",
|
Output: full,
|
||||||
Output: full,
|
Status: JobFinished,
|
||||||
Status: JobFinished,
|
StartedAt: t,
|
||||||
StartedAt: t,
|
EndedAt: &t,
|
||||||
EndedAt: &t,
|
DurationSeconds: dur,
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(list, func(i, j int) bool {
|
sort.Slice(list, func(i, j int) bool {
|
||||||
@ -1395,7 +1635,11 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Remove(target); err != nil {
|
if err := removeWithRetry(target); err != nil {
|
||||||
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
|
http.Error(w, "löschen fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1408,6 +1652,99 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := strings.TrimSpace(r.URL.Query().Get("file"))
|
||||||
|
if raw == "" {
|
||||||
|
http.Error(w, "file fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := url.QueryUnescape(raw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "ungültiger file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file = strings.TrimSpace(file)
|
||||||
|
|
||||||
|
// kein Pfad, keine Backslashes, kein Traversal
|
||||||
|
if file == "" ||
|
||||||
|
strings.Contains(file, "/") ||
|
||||||
|
strings.Contains(file, "\\") ||
|
||||||
|
filepath.Base(file) != file {
|
||||||
|
http.Error(w, "ungültiger file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(file))
|
||||||
|
if ext != ".mp4" && ext != ".ts" {
|
||||||
|
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s := getSettings()
|
||||||
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(doneAbs) == "" {
|
||||||
|
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src := filepath.Join(doneAbs, file)
|
||||||
|
fi, err := os.Stat(src)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "stat fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keepDir := filepath.Join(doneAbs, "keep")
|
||||||
|
if err := os.MkdirAll(keepDir, 0o755); err != nil {
|
||||||
|
http.Error(w, "keep dir anlegen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(keepDir, file)
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
http.Error(w, "ziel existiert bereits", http.StatusConflict)
|
||||||
|
return
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
http.Error(w, "stat ziel fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// rename mit retry (Windows file-lock)
|
||||||
|
if err := renameWithRetry(src, dst); err != nil {
|
||||||
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
|
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "keep fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"file": file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func recordToggleHot(w http.ResponseWriter, r *http.Request) {
|
func recordToggleHot(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Nur POST", http.StatusMethodNotAllowed)
|
http.Error(w, "Nur POST", http.StatusMethodNotAllowed)
|
||||||
@ -1484,7 +1821,11 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Rename(src, dst); err != nil {
|
if err := renameWithRetry(src, dst); err != nil {
|
||||||
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
|
http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, "rename fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "rename fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1546,6 +1887,60 @@ func moveFile(src, dst string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
|
||||||
|
|
||||||
|
func isSharingViolation(err error) bool {
|
||||||
|
var pe *os.PathError
|
||||||
|
if errors.As(err, &pe) {
|
||||||
|
if errno, ok := pe.Err.(syscall.Errno); ok {
|
||||||
|
return errno == windowsSharingViolation
|
||||||
|
}
|
||||||
|
return errors.Is(pe.Err, windowsSharingViolation)
|
||||||
|
}
|
||||||
|
|
||||||
|
var le *os.LinkError
|
||||||
|
if errors.As(err, &le) {
|
||||||
|
if errno, ok := le.Err.(syscall.Errno); ok {
|
||||||
|
return errno == windowsSharingViolation
|
||||||
|
}
|
||||||
|
return errors.Is(le.Err, windowsSharingViolation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Is(err, windowsSharingViolation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameWithRetry(src, dst string) error {
|
||||||
|
var err error
|
||||||
|
for i := 0; i < 15; i++ { // ~1.5s
|
||||||
|
err = os.Rename(src, dst)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeWithRetry(path string) error {
|
||||||
|
var err error
|
||||||
|
for i := 0; i < 15; i++ { // ~1.5s
|
||||||
|
err = os.Remove(path)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func moveToDoneDir(outputPath string) (string, error) {
|
func moveToDoneDir(outputPath string) (string, error) {
|
||||||
outputPath = strings.TrimSpace(outputPath)
|
outputPath = strings.TrimSpace(outputPath)
|
||||||
if outputPath == "" {
|
if outputPath == "" {
|
||||||
@ -1567,7 +1962,15 @@ func moveToDoneDir(outputPath string) (string, error) {
|
|||||||
if err := moveFile(outputPath, dst); err != nil {
|
if err := moveFile(outputPath, dst); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ Streaming-Optimierung
|
||||||
|
if strings.EqualFold(filepath.Ext(dst), ".mp4") {
|
||||||
|
if err := ensureFastStartMP4(dst); err != nil {
|
||||||
|
fmt.Println("⚠️ faststart:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return dst, nil
|
return dst, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordStatus(w http.ResponseWriter, r *http.Request) {
|
func recordStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Binary file not shown.
1
backend/web/dist/assets/index-BsHW0Op2.css
vendored
Normal file
1
backend/web/dist/assets/index-BsHW0Op2.css
vendored
Normal file
File diff suppressed because one or more lines are too long
257
backend/web/dist/assets/index-DFSqchi9.js
vendored
Normal file
257
backend/web/dist/assets/index-DFSqchi9.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-WtXLd9dH.css
vendored
1
backend/web/dist/assets/index-WtXLd9dH.css
vendored
File diff suppressed because one or more lines are too long
257
backend/web/dist/assets/index-iDPthw87.js
vendored
257
backend/web/dist/assets/index-iDPthw87.js
vendored
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>frontend</title>
|
||||||
<script type="module" crossorigin src="/assets/index-iDPthw87.js"></script>
|
<script type="module" crossorigin src="/assets/index-DFSqchi9.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-WtXLd9dH.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BsHW0Op2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -541,23 +541,41 @@ export default function App() {
|
|||||||
return startUrl(sourceUrl)
|
return startUrl(sourceUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePlayerDelete = useCallback(async (job: RecordJob) => {
|
const handleDeleteJob = useCallback(async (job: RecordJob) => {
|
||||||
// running => stop (macht mp4 remux etc)
|
|
||||||
if (job.status === 'running') {
|
|
||||||
await stopJob(job.id)
|
|
||||||
setPlayerJob(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
await apiJSON(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
// 1) Animation START im FinishedDownloads triggern
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:delete', {
|
||||||
|
detail: { file, phase: 'start' as const },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// UI sofort aktualisieren
|
try {
|
||||||
setDoneJobs(prev => prev.filter(j => baseName(j.output || '') !== file))
|
await apiJSON(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
||||||
setJobs(prev => prev.filter(j => baseName(j.output || '') !== file))
|
|
||||||
setPlayerJob(null)
|
// 2) Animation SUCCESS triggern (FinishedDownloads startet fade-out)
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:delete', {
|
||||||
|
detail: { file, phase: 'success' as const },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3) erst NACH der Animation wirklich aus den Arrays entfernen
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||||
|
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||||
|
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||||
|
}, 320)
|
||||||
|
} catch (e) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:delete', {
|
||||||
|
detail: { file, phase: 'error' as const },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleToggleHot = useCallback(async (job: RecordJob) => {
|
const handleToggleHot = useCallback(async (job: RecordJob) => {
|
||||||
@ -966,7 +984,7 @@ export default function App() {
|
|||||||
isFavorite={Boolean(playerModel?.favorite)}
|
isFavorite={Boolean(playerModel?.favorite)}
|
||||||
isLiked={playerModel?.liked === true}
|
isLiked={playerModel?.liked === true}
|
||||||
|
|
||||||
onDelete={handlePlayerDelete}
|
onDelete={handleDeleteJob}
|
||||||
onToggleHot={handleToggleHot}
|
onToggleHot={handleToggleHot}
|
||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onToggleLike={handleToggleLike}
|
onToggleLike={handleToggleLike}
|
||||||
|
|||||||
94
frontend/src/components/ui/ButtonGroup.tsx
Normal file
94
frontend/src/components/ui/ButtonGroup.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
// components/ui/ButtonGroup.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
type Size = 'sm' | 'md'
|
||||||
|
|
||||||
|
export type ButtonGroupItem = {
|
||||||
|
id: string
|
||||||
|
label?: React.ReactNode // optional (für icon-only)
|
||||||
|
icon?: React.ReactNode
|
||||||
|
srLabel?: string // für icon-only (Screenreader)
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ButtonGroupProps = {
|
||||||
|
items: ButtonGroupItem[]
|
||||||
|
value: string
|
||||||
|
onChange: (id: string) => void
|
||||||
|
size?: Size
|
||||||
|
className?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap: Record<Size, { btn: string; icon: string }> = {
|
||||||
|
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5' },
|
||||||
|
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ButtonGroup({
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
size = 'md',
|
||||||
|
className,
|
||||||
|
ariaLabel = 'Optionen',
|
||||||
|
}: ButtonGroupProps) {
|
||||||
|
const s = sizeMap[size]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('isolate inline-flex rounded-md shadow-xs dark:shadow-none', className)} role="group" aria-label={ariaLabel}>
|
||||||
|
{items.map((it, idx) => {
|
||||||
|
const active = it.id === value
|
||||||
|
const isFirst = idx === 0
|
||||||
|
const isLast = idx === items.length - 1
|
||||||
|
const iconOnly = !it.label && !!it.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={it.id}
|
||||||
|
type="button"
|
||||||
|
disabled={it.disabled}
|
||||||
|
onClick={() => onChange(it.id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center font-semibold focus:z-10',
|
||||||
|
!isFirst && '-ml-px',
|
||||||
|
isFirst && 'rounded-l-md',
|
||||||
|
isLast && 'rounded-r-md',
|
||||||
|
|
||||||
|
// Base (wie im TailwindUI Beispiel)
|
||||||
|
'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50',
|
||||||
|
'dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
|
||||||
|
|
||||||
|
// Active-Style (dezente Hervorhebung)
|
||||||
|
active && 'bg-gray-50 dark:bg-white/20',
|
||||||
|
|
||||||
|
// Disabled
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
|
||||||
|
// Padding / Größe
|
||||||
|
iconOnly ? 'px-2 py-2 text-gray-400 dark:text-gray-300' : s.btn
|
||||||
|
)}
|
||||||
|
title={typeof it.label === 'string' ? it.label : it.srLabel}
|
||||||
|
>
|
||||||
|
{iconOnly && it.srLabel ? <span className="sr-only">{it.srLabel}</span> : null}
|
||||||
|
|
||||||
|
{it.icon ? (
|
||||||
|
<span className={cn('shrink-0', iconOnly ? '' : '-ml-0.5 text-gray-400 dark:text-gray-500')}>
|
||||||
|
{it.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{it.label ? <span className={it.icon ? 'ml-1.5' : ''}>{it.label}</span> : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -10,6 +10,9 @@ import FinishedVideoPreview from './FinishedVideoPreview'
|
|||||||
import ContextMenu, { type ContextMenuItem } from './ContextMenu'
|
import ContextMenu, { type ContextMenuItem } from './ContextMenu'
|
||||||
import { buildDownloadContextMenu } from './DownloadContextMenu'
|
import { buildDownloadContextMenu } from './DownloadContextMenu'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import ButtonGroup from './ButtonGroup'
|
||||||
|
import { TableCellsIcon, RectangleStackIcon, Squares2X2Icon } from '@heroicons/react/24/outline'
|
||||||
|
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobs: RecordJob[]
|
jobs: RecordJob[]
|
||||||
@ -49,16 +52,22 @@ const httpCodeFromError = (err?: string) => {
|
|||||||
return m ? `HTTP ${m[1]}` : null
|
return m ? `HTTP ${m[1]}` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
||||||
|
|
||||||
const modelNameFromOutput = (output?: string) => {
|
const modelNameFromOutput = (output?: string) => {
|
||||||
const file = baseName(output || '')
|
const fileRaw = baseName(output || '')
|
||||||
|
const file = stripHotPrefix(fileRaw)
|
||||||
if (!file) return '—'
|
if (!file) return '—'
|
||||||
|
|
||||||
const stem = file.replace(/\.[^.]+$/, '')
|
const stem = file.replace(/\.[^.]+$/, '')
|
||||||
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
|
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
|
||||||
if (m?.[1]) return m[1]
|
if (m?.[1]) return m[1]
|
||||||
|
|
||||||
const i = stem.lastIndexOf('_')
|
const i = stem.lastIndexOf('_')
|
||||||
return i > 0 ? stem.slice(0, i) : stem
|
return i > 0 ? stem.slice(0, i) : stem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
|
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
|
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
|
||||||
@ -70,17 +79,33 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
|
|
||||||
const [sort, setSort] = React.useState<SortState>(null)
|
const [sort, setSort] = React.useState<SortState>(null)
|
||||||
|
|
||||||
// 🔄 globaler Tick für animierte Thumbnails der fertigen Videos
|
type ViewMode = 'table' | 'cards' | 'gallery'
|
||||||
const [thumbTick, setThumbTick] = React.useState(0)
|
const VIEW_KEY = 'finishedDownloads_view'
|
||||||
|
|
||||||
|
const [view, setView] = React.useState<ViewMode>('table')
|
||||||
|
|
||||||
|
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const id = window.setInterval(() => {
|
try {
|
||||||
setThumbTick((t) => t + 1)
|
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
||||||
}, 3000) // alle 3 Sekunden
|
if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
|
||||||
|
setView(saved)
|
||||||
return () => window.clearInterval(id)
|
} else {
|
||||||
|
// Default: Mobile -> Cards, sonst Tabelle
|
||||||
|
setView(window.matchMedia('(max-width: 639px)').matches ? 'cards' : 'table')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setView('table')
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VIEW_KEY, view)
|
||||||
|
} catch {}
|
||||||
|
}, [view])
|
||||||
|
|
||||||
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
|
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
|
||||||
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
||||||
|
|
||||||
@ -111,33 +136,100 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const [keepingKeys, setKeepingKeys] = React.useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const markKeeping = React.useCallback((key: string, value: boolean) => {
|
||||||
|
setKeepingKeys((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (value) next.add(key)
|
||||||
|
else next.delete(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// neben deletedKeys / deletingKeys
|
||||||
|
const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const markRemoving = React.useCallback((key: string, value: boolean) => {
|
||||||
|
setRemovingKeys((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (value) next.add(key)
|
||||||
|
else next.delete(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const animateRemove = React.useCallback((key: string) => {
|
||||||
|
// 1) rot + fade-out starten
|
||||||
|
markRemoving(key, true)
|
||||||
|
|
||||||
|
// 2) nach der Animation wirklich ausblenden
|
||||||
|
window.setTimeout(() => {
|
||||||
|
markDeleted(key)
|
||||||
|
markRemoving(key, false)
|
||||||
|
}, 320)
|
||||||
|
}, [markDeleted, markRemoving])
|
||||||
|
|
||||||
const deleteVideo = React.useCallback(
|
const deleteVideo = React.useCallback(
|
||||||
async (job: RecordJob) => {
|
async (job: RecordJob): Promise<boolean> => {
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
const key = keyFor(job)
|
const key = keyFor(job)
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
window.alert('Kein Dateiname gefunden – kann nicht löschen.')
|
window.alert('Kein Dateiname gefunden – kann nicht löschen.')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (deletingKeys.has(key)) return
|
if (deletingKeys.has(key)) return false
|
||||||
|
|
||||||
markDeleting(key, true)
|
markDeleting(key, true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, {
|
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
throw new Error(text || `HTTP ${res.status}`)
|
throw new Error(text || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
markDeleted(key)
|
animateRemove(key)
|
||||||
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
window.alert(`Löschen fehlgeschlagen: ${String(e?.message || e)}`)
|
window.alert(`Löschen fehlgeschlagen: ${String(e?.message || e)}`)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
markDeleting(key, false)
|
markDeleting(key, false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deletingKeys, markDeleted, markDeleting]
|
[deletingKeys, markDeleting, animateRemove]
|
||||||
|
)
|
||||||
|
|
||||||
|
const keepVideo = React.useCallback(
|
||||||
|
async (job: RecordJob) => {
|
||||||
|
const file = baseName(job.output || '')
|
||||||
|
const key = keyFor(job)
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
window.alert('Kein Dateiname gefunden – kann nicht behalten.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (keepingKeys.has(key) || deletingKeys.has(key)) return false
|
||||||
|
|
||||||
|
markKeeping(key, true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(text || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
|
||||||
|
animateRemove(key)
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
window.alert(`Behalten fehlgeschlagen: ${String(e?.message || e)}`)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
markKeeping(key, false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[keepingKeys, deletingKeys, markKeeping, animateRemove]
|
||||||
)
|
)
|
||||||
|
|
||||||
const items = React.useMemo<ContextMenuItem[]>(() => {
|
const items = React.useMemo<ContextMenuItem[]>(() => {
|
||||||
@ -175,10 +267,14 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [ctx, deleteVideo, onOpenPlayer])
|
}, [ctx, deleteVideo, onOpenPlayer])
|
||||||
|
|
||||||
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
|
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
|
||||||
const k = keyFor(job)
|
const k = keyFor(job)
|
||||||
const sec = durations[k]
|
const sec =
|
||||||
|
(typeof (job as any).durationSeconds === 'number' && (job as any).durationSeconds > 0)
|
||||||
|
? (job as any).durationSeconds
|
||||||
|
: durations[k]
|
||||||
|
|
||||||
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
|
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
|
||||||
|
|
||||||
const start = Date.parse(String(job.startedAt || ''))
|
const start = Date.parse(String(job.startedAt || ''))
|
||||||
@ -187,6 +283,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
return (end - start) / 1000
|
return (end - start) / 1000
|
||||||
}, [durations])
|
}, [durations])
|
||||||
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const map = new Map<string, RecordJob>()
|
const map = new Map<string, RecordJob>()
|
||||||
|
|
||||||
@ -212,18 +309,61 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
setVisibleCount(PAGE_SIZE)
|
setVisibleCount(PAGE_SIZE)
|
||||||
}, [rows.length])
|
}, [rows.length])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onExternalDelete = (ev: Event) => {
|
||||||
|
const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
|
||||||
|
if (!detail?.file) return
|
||||||
|
|
||||||
|
const key = detail.file
|
||||||
|
|
||||||
|
if (detail.phase === 'start') {
|
||||||
|
markDeleting(key, true)
|
||||||
|
|
||||||
|
// ✅ wenn Cards-View: Swipe schon beim Start raus (ohne Aktion, weil App die API schon macht)
|
||||||
|
if (view === 'cards') {
|
||||||
|
swipeRefs.current.get(key)?.swipeLeft({ runAction: false })
|
||||||
|
}
|
||||||
|
} else if (detail.phase === 'success') {
|
||||||
|
markDeleting(key, false)
|
||||||
|
|
||||||
|
if (view === 'cards') {
|
||||||
|
// ✅ nach Swipe-Animation wirklich aus der Liste entfernen
|
||||||
|
window.setTimeout(() => markDeleted(key), 320)
|
||||||
|
} else {
|
||||||
|
// table/gallery: wie bisher ausblenden
|
||||||
|
animateRemove(key)
|
||||||
|
}
|
||||||
|
} else if (detail.phase === 'error') {
|
||||||
|
markDeleting(key, false)
|
||||||
|
|
||||||
|
// ✅ Swipe zurück, falls Delete fehlgeschlagen
|
||||||
|
if (view === 'cards') {
|
||||||
|
swipeRefs.current.get(key)?.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
||||||
|
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
||||||
|
}, [animateRemove, markDeleting, markDeleted, view])
|
||||||
|
|
||||||
const visibleRows = React.useMemo(() => rows.slice(0, visibleCount), [rows, visibleCount])
|
const visibleRows = React.useMemo(() => rows.slice(0, visibleCount), [rows, visibleCount])
|
||||||
|
|
||||||
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
||||||
const runtimeOf = (job: RecordJob): string => {
|
const runtimeOf = (job: RecordJob): string => {
|
||||||
const k = keyFor(job)
|
const k = keyFor(job)
|
||||||
const sec = durations[k]
|
const sec =
|
||||||
|
(typeof (job as any).durationSeconds === 'number' && (job as any).durationSeconds > 0)
|
||||||
|
? (job as any).durationSeconds
|
||||||
|
: durations[k]
|
||||||
|
|
||||||
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) {
|
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) {
|
||||||
return formatDuration(sec * 1000)
|
return formatDuration(sec * 1000)
|
||||||
}
|
}
|
||||||
return runtimeFromTimestamps(job)
|
return runtimeFromTimestamps(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
|
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
|
||||||
const handleDuration = React.useCallback((job: RecordJob, seconds: number) => {
|
const handleDuration = React.useCallback((job: RecordJob, seconds: number) => {
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) return
|
if (!Number.isFinite(seconds) || seconds <= 0) return
|
||||||
@ -247,7 +387,6 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
getFileName={baseName}
|
getFileName={baseName}
|
||||||
durationSeconds={durations[keyFor(j)]}
|
durationSeconds={durations[keyFor(j)]}
|
||||||
onDuration={handleDuration}
|
onDuration={handleDuration}
|
||||||
thumbTick={thumbTick}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -335,61 +474,224 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ✅ Mobile: Cards */}
|
{/* Toolbar */}
|
||||||
<div className="sm:hidden space-y-3">
|
<div className="mb-3 flex items-center justify-end">
|
||||||
{visibleRows.map((j) => {
|
<ButtonGroup
|
||||||
const model = modelNameFromOutput(j.output)
|
value={view}
|
||||||
const file = baseName(j.output || '')
|
onChange={(id) => setView(id as ViewMode)}
|
||||||
const dur = runtimeOf(j)
|
size="sm"
|
||||||
|
ariaLabel="Ansicht"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: 'table',
|
||||||
|
icon: <TableCellsIcon className="size-5" />,
|
||||||
|
label: <span className="hidden sm:inline">Tabelle</span>,
|
||||||
|
srLabel: 'Tabelle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cards',
|
||||||
|
icon: <RectangleStackIcon className="size-5" />,
|
||||||
|
label: <span className="hidden sm:inline">Cards</span>,
|
||||||
|
srLabel: 'Cards',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gallery',
|
||||||
|
icon: <Squares2X2Icon className="size-5" />,
|
||||||
|
label: <span className="hidden sm:inline">Galerie</span>,
|
||||||
|
srLabel: 'Galerie',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
const statusNode =
|
{/* ✅ Cards */}
|
||||||
j.status === 'failed' ? (
|
{view === 'cards' && (
|
||||||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
<div className="space-y-3">
|
||||||
failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''}
|
{visibleRows.map((j) => {
|
||||||
</span>
|
const k = keyFor(j)
|
||||||
) : (
|
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||||
<span className="font-medium">{j.status}</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
const model = modelNameFromOutput(j.output)
|
||||||
<div
|
const file = baseName(j.output || '')
|
||||||
key={keyFor(j)}
|
const dur = runtimeOf(j)
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
const statusNode =
|
||||||
className="cursor-pointer"
|
j.status === 'failed' ? (
|
||||||
onClick={() => onOpenPlayer(j)}
|
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
||||||
onKeyDown={(e) => {
|
failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''}
|
||||||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
</span>
|
||||||
}}
|
) : (
|
||||||
onContextMenu={(e) => openCtx(j, e)}
|
<span className="font-medium">{j.status}</span>
|
||||||
>
|
)
|
||||||
<Card
|
|
||||||
header={
|
return (
|
||||||
<div className="flex items-start justify-between gap-3">
|
<SwipeCard
|
||||||
<div className="min-w-0">
|
ref={(h) => {
|
||||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
if (h) swipeRefs.current.set(k, h)
|
||||||
{model}
|
else swipeRefs.current.delete(k)
|
||||||
|
}}
|
||||||
|
key={k}
|
||||||
|
enabled
|
||||||
|
disabled={busy}
|
||||||
|
onTap={() => onOpenPlayer(j)}
|
||||||
|
onSwipeLeft={() => deleteVideo(j)}
|
||||||
|
onSwipeRight={() => keepVideo(j)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={[
|
||||||
|
'transition-all duration-300 ease-in-out',
|
||||||
|
busy && 'pointer-events-none',
|
||||||
|
deletingKeys.has(k) &&
|
||||||
|
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30 animate-pulse',
|
||||||
|
keepingKeys.has(k) &&
|
||||||
|
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30 animate-pulse',
|
||||||
|
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => openCtx(j, e)}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
header={
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div>
|
||||||
|
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
aria-label="Video löschen"
|
||||||
|
title="Video löschen"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const h = swipeRefs.current.get(k)
|
||||||
|
if (h) {
|
||||||
|
void h.swipeLeft() // ✅ führt Swipe + deleteVideo aus
|
||||||
|
} else {
|
||||||
|
void deleteVideo(j)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
aria-label="Aktionen"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
openCtxAt(j, r.left, r.bottom + 6)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
|
}
|
||||||
{file || '—'}
|
>
|
||||||
</div>
|
<div className="flex gap-3">
|
||||||
</div>
|
<div
|
||||||
|
className="shrink-0"
|
||||||
<div className="shrink-0 flex items-center gap-1">
|
onClick={(e) => e.stopPropagation()}
|
||||||
{/* 🗑️ Direkt-Löschen */}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
<Button
|
onContextMenu={(e) => {
|
||||||
aria-label="Video löschen"
|
|
||||||
title="Video löschen"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
void deleteVideo(j)
|
openCtx(j, e)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🗑
|
<FinishedVideoPreview
|
||||||
</Button>
|
job={j}
|
||||||
|
getFileName={baseName}
|
||||||
{/* ✅ Menü-Button für Touch/Small Devices */}
|
durationSeconds={durations[k]}
|
||||||
|
onDuration={handleDuration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
Status: {statusNode}
|
||||||
|
<span className="mx-2 opacity-60">•</span>
|
||||||
|
Dauer: <span className="font-medium">{dur}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{j.output ? (
|
||||||
|
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">{j.output}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</SwipeCard>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ✅ Tabelle */}
|
||||||
|
{view === 'table' && (
|
||||||
|
<Table
|
||||||
|
rows={visibleRows}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(j) => keyFor(j)}
|
||||||
|
striped
|
||||||
|
fullWidth
|
||||||
|
stickyHeader
|
||||||
|
sort={sort}
|
||||||
|
onSortChange={setSort}
|
||||||
|
onRowClick={onOpenPlayer}
|
||||||
|
onRowContextMenu={(job, e) => openCtx(job, e)}
|
||||||
|
rowClassName={(j) => {
|
||||||
|
const k = keyFor(j)
|
||||||
|
return [
|
||||||
|
'transition-opacity duration-300',
|
||||||
|
(deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
|
||||||
|
deletingKeys.has(k) && 'animate-pulse',
|
||||||
|
removingKeys.has(k) && 'opacity-0',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ✅ Galerie */}
|
||||||
|
{view === 'gallery' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{visibleRows.map((j) => {
|
||||||
|
const model = modelNameFromOutput(j.output)
|
||||||
|
const file = baseName(j.output || '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={keyFor(j)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onOpenPlayer(j)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => openCtx(j, e)}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
header={
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div>
|
||||||
|
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
|
className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
@ -404,12 +706,9 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
⋯
|
⋯
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
>
|
||||||
>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div
|
<div
|
||||||
className="shrink-0"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
@ -426,40 +725,17 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
Status: <span className="font-medium">{j.status}</span>
|
||||||
Status: {statusNode}
|
<span className="mx-2 opacity-60">•</span>
|
||||||
<span className="mx-2 opacity-60">•</span>
|
Dauer: <span className="font-medium">{runtimeOf(j)}</span>
|
||||||
Dauer: <span className="font-medium">{dur}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{j.output ? (
|
|
||||||
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{j.output}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* ✅ Desktop/Tablet: Tabelle */}
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<Table
|
|
||||||
rows={visibleRows}
|
|
||||||
columns={columns}
|
|
||||||
getRowKey={(j) => keyFor(j)}
|
|
||||||
striped
|
|
||||||
fullWidth
|
|
||||||
sort={sort}
|
|
||||||
onSortChange={setSort}
|
|
||||||
onRowClick={onOpenPlayer}
|
|
||||||
onRowContextMenu={(job, e) => openCtx(job, e)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
open={!!ctx}
|
open={!!ctx}
|
||||||
@ -471,15 +747,15 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
|||||||
|
|
||||||
{rows.length > visibleCount ? (
|
{rows.length > visibleCount ? (
|
||||||
<div className="mt-3 flex justify-center">
|
<div className="mt-3 flex justify-center">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
className="rounded-md bg-black/5 px-3 py-2 text-sm font-medium hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
|
className="rounded-md bg-black/5 px-3 py-2 text-sm font-medium hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
|
||||||
onClick={() => setVisibleCount((v) => Math.min(rows.length, v + PAGE_SIZE))}
|
onClick={() => setVisibleCount((v) => Math.min(rows.length, v + PAGE_SIZE))}
|
||||||
>
|
>
|
||||||
Mehr laden ({Math.min(PAGE_SIZE, rows.length - visibleCount)} von {rows.length - visibleCount})
|
Mehr laden ({Math.min(PAGE_SIZE, rows.length - visibleCount)} von {rows.length - visibleCount})
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
// frontend/src/components/ui/FinishedVideoPreview.tsx
|
// FinishedVideoPreview.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useState, type SyntheticEvent } from 'react'
|
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
|
||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
import HoverPopover from './HoverPopover'
|
import HoverPopover from './HoverPopover'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
job: RecordJob
|
job: RecordJob
|
||||||
getFileName: (path: string) => string
|
getFileName: (path: string) => string
|
||||||
// 🔹 optional: bereits bekannte Dauer (Sekunden)
|
|
||||||
durationSeconds?: number
|
durationSeconds?: number
|
||||||
// 🔹 Callback nach oben, wenn wir die Dauer ermittelt haben
|
|
||||||
onDuration?: (job: RecordJob, seconds: number) => void
|
onDuration?: (job: RecordJob, seconds: number) => void
|
||||||
|
animated?: boolean // ✅ neu
|
||||||
thumbTick?: number
|
autoTickMs?: number // ✅ neu
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinishedVideoPreview({
|
export default function FinishedVideoPreview({
|
||||||
@ -21,14 +19,39 @@ export default function FinishedVideoPreview({
|
|||||||
getFileName,
|
getFileName,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
onDuration,
|
onDuration,
|
||||||
thumbTick
|
animated = false,
|
||||||
|
autoTickMs = 15000,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const file = getFileName(job.output || '')
|
const file = getFileName(job.output || '')
|
||||||
|
|
||||||
const [thumbOk, setThumbOk] = useState(true)
|
const [thumbOk, setThumbOk] = useState(true)
|
||||||
const [metaLoaded, setMetaLoaded] = useState(false)
|
const [metaLoaded, setMetaLoaded] = useState(false)
|
||||||
|
|
||||||
// id für /api/record/preview: Dateiname ohne Extension
|
// ✅ nur animieren, wenn sichtbar (Viewport)
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [inView, setInView] = useState(false)
|
||||||
|
const [localTick, setLocalTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rootRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
)
|
||||||
|
obs.observe(el)
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!animated) return
|
||||||
|
if (!inView || document.hidden) return
|
||||||
|
|
||||||
|
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [animated, inView, autoTickMs])
|
||||||
|
|
||||||
const previewId = useMemo(() => {
|
const previewId = useMemo(() => {
|
||||||
if (!file) return ''
|
if (!file) return ''
|
||||||
const dot = file.lastIndexOf('.')
|
const dot = file.lastIndexOf('.')
|
||||||
@ -43,58 +66,39 @@ export default function FinishedVideoPreview({
|
|||||||
const hasDuration =
|
const hasDuration =
|
||||||
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||||
|
|
||||||
const tick = thumbTick ?? 0
|
|
||||||
|
|
||||||
// Zeitposition im Video: alle 3s ein Schritt, modulo Videolänge
|
|
||||||
const thumbTimeSec = useMemo(() => {
|
const thumbTimeSec = useMemo(() => {
|
||||||
if (!durationSeconds || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
if (!animated) return null
|
||||||
// Keine Dauer bekannt → einfach bei 0s (erster Frame) bleiben
|
if (!hasDuration) return null
|
||||||
return 0
|
const step = 3
|
||||||
}
|
const total = Math.max(durationSeconds! - 0.1, step)
|
||||||
const step = 3 // Sekunden pro Schritt
|
return (localTick * step) % total
|
||||||
const steps = Math.max(0, Math.floor(tick))
|
}, [animated, hasDuration, durationSeconds, localTick])
|
||||||
// kleine Reserve, damit wir nicht exakt auf das letzte Frame springen
|
|
||||||
const total = Math.max(durationSeconds - 0.1, step)
|
|
||||||
return (steps * step) % total
|
|
||||||
}, [durationSeconds, tick])
|
|
||||||
|
|
||||||
// Thumbnail (immer mit t=..., auch wenn t=0 → erster Frame)
|
// ✅ WICHTIG: t nur wenn animiert + Dauer bekannt!
|
||||||
const thumbSrc = useMemo(() => {
|
const thumbSrc = useMemo(() => {
|
||||||
if (!previewId) return ''
|
if (!previewId) return ''
|
||||||
|
if (thumbTimeSec == null) {
|
||||||
const params: string[] = []
|
// statisch -> nutzt Backend preview.jpg Cache (kein ffmpeg pro Request)
|
||||||
|
return `/api/record/preview?id=${encodeURIComponent(previewId)}`
|
||||||
// ⬅️ immer Zeitposition mitgeben, auch bei 0
|
|
||||||
params.push(`t=${encodeURIComponent(thumbTimeSec.toFixed(2))}`)
|
|
||||||
|
|
||||||
// Versionierung für den Browser-Cache / Animation
|
|
||||||
if (typeof thumbTick === 'number') {
|
|
||||||
params.push(`v=${encodeURIComponent(String(thumbTick))}`)
|
|
||||||
}
|
}
|
||||||
|
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent(
|
||||||
const qs = params.length ? `&${params.join('&')}` : ''
|
thumbTimeSec.toFixed(2)
|
||||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}${qs}`
|
)}`
|
||||||
}, [previewId, thumbTimeSec, thumbTick])
|
}, [previewId, thumbTimeSec])
|
||||||
|
|
||||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||||
setMetaLoaded(true)
|
setMetaLoaded(true)
|
||||||
if (!onDuration) return
|
if (!onDuration) return
|
||||||
|
|
||||||
const secs = e.currentTarget.duration
|
const secs = e.currentTarget.duration
|
||||||
if (Number.isFinite(secs) && secs > 0) {
|
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
|
||||||
onDuration(job, secs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!videoSrc) {
|
if (!videoSrc) {
|
||||||
return (
|
return <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
|
||||||
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverPopover
|
<HoverPopover
|
||||||
// ⚠️ Großes Video nur rendern, wenn Popover offen ist
|
|
||||||
content={(open) =>
|
content={(open) =>
|
||||||
open && (
|
open && (
|
||||||
<div className="w-[420px]">
|
<div className="w-[420px]">
|
||||||
@ -116,8 +120,7 @@ export default function FinishedVideoPreview({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* 🔹 Inline nur Thumbnail / Platzhalter */}
|
<div ref={rootRef} className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
|
||||||
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
|
|
||||||
{thumbSrc && thumbOk ? (
|
{thumbSrc && thumbOk ? (
|
||||||
<img
|
<img
|
||||||
src={thumbSrc}
|
src={thumbSrc}
|
||||||
@ -130,9 +133,8 @@ export default function FinishedVideoPreview({
|
|||||||
<div className="w-full h-full bg-black" />
|
<div className="w-full h-full bg-black" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 🔍 Unsichtbares Video nur zum Metadaten-Laden (Dauer),
|
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) */}
|
||||||
wird genau EINMAL pro Datei geladen */}
|
{inView && onDuration && !hasDuration && !metaLoaded && (
|
||||||
{onDuration && !hasDuration && !metaLoaded && (
|
|
||||||
<video
|
<video
|
||||||
src={videoSrc}
|
src={videoSrc}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
|||||||
@ -112,7 +112,7 @@ export default function Player({
|
|||||||
autoplay: true,
|
autoplay: true,
|
||||||
muted: true, // <- wichtig für Autoplay ohne Klick in vielen Browsern
|
muted: true, // <- wichtig für Autoplay ohne Klick in vielen Browsern
|
||||||
controls: true,
|
controls: true,
|
||||||
preload: 'auto',
|
preload: 'metadata',
|
||||||
playsinline: true,
|
playsinline: true,
|
||||||
responsive: true,
|
responsive: true,
|
||||||
fluid: false,
|
fluid: false,
|
||||||
@ -195,6 +195,18 @@ export default function Player({
|
|||||||
queueMicrotask(() => p.trigger('resize'))
|
queueMicrotask(() => p.trigger('resize'))
|
||||||
}, [expanded])
|
}, [expanded])
|
||||||
|
|
||||||
|
const releaseMedia = React.useCallback(() => {
|
||||||
|
const p = playerRef.current
|
||||||
|
if (!p || (p as any).isDisposed?.()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
p.pause()
|
||||||
|
// Source leeren, damit der Browser die HTTP-Verbindung abbricht
|
||||||
|
p.src({ src: '', type: 'video/mp4' } as any)
|
||||||
|
;(p as any).load?.()
|
||||||
|
} catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
const mini = !expanded
|
const mini = !expanded
|
||||||
@ -203,14 +215,33 @@ export default function Player({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant={isHot ? 'soft' : 'secondary'}
|
variant={isHot ? 'soft' : 'secondary'}
|
||||||
size="xs"
|
size="md"
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1',
|
'px-2 py-1',
|
||||||
!isHot && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
!isHot && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||||
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||||
onClick={() => onToggleHot?.(job)}
|
onClick={async () => {
|
||||||
|
// 1) Stream freigeben (wichtig für Windows Rename)
|
||||||
|
releaseMedia()
|
||||||
|
|
||||||
|
// 2) kurz warten, bis Browser/HTTP wirklich zu ist
|
||||||
|
await new Promise((r) => setTimeout(r, 150))
|
||||||
|
|
||||||
|
// 3) Rename (App aktualisiert danach job.output -> media.src ändert sich)
|
||||||
|
await onToggleHot?.(job)
|
||||||
|
|
||||||
|
// 4) Optional: nach Rename wieder starten (falls du willst)
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
const p = playerRef.current
|
||||||
|
if (p && !(p as any).isDisposed?.()) {
|
||||||
|
const ret = p.play?.()
|
||||||
|
if (ret && typeof (ret as any).catch === 'function') {
|
||||||
|
;(ret as Promise<void>).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={!onToggleHot}
|
disabled={!onToggleHot}
|
||||||
>
|
>
|
||||||
<FireIcon className="h-4 w-4" />
|
<FireIcon className="h-4 w-4" />
|
||||||
@ -218,7 +249,7 @@ export default function Player({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={isFavorite ? 'soft' : 'secondary'}
|
variant={isFavorite ? 'soft' : 'secondary'}
|
||||||
size="xs"
|
size="md"
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1',
|
'px-2 py-1',
|
||||||
!isFavorite && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
!isFavorite && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||||
@ -233,7 +264,7 @@ export default function Player({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={isLiked ? 'soft' : 'secondary'}
|
variant={isLiked ? 'soft' : 'secondary'}
|
||||||
size="xs"
|
size="md"
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1',
|
'px-2 py-1',
|
||||||
!isLiked && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
!isLiked && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||||
@ -248,11 +279,20 @@ export default function Player({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="xs"
|
size="md"
|
||||||
className="px-2 py-1 bg-transparent shadow-none hover:bg-red-50 text-red-600 dark:hover:bg-red-500/10 dark:text-red-400"
|
className="px-2 py-1 bg-transparent shadow-none hover:bg-red-50 text-red-600 dark:hover:bg-red-500/10 dark:text-red-400"
|
||||||
title="Löschen"
|
title="Löschen"
|
||||||
aria-label="Löschen"
|
aria-label="Löschen"
|
||||||
onClick={() => onDelete?.(job)}
|
onClick={async () => {
|
||||||
|
releaseMedia()
|
||||||
|
// optional: Player schließen -> dispose() läuft im Cleanup und gibt endgültig frei
|
||||||
|
onClose()
|
||||||
|
|
||||||
|
// kurzer Moment, bis der Browser den Stream wirklich abbricht
|
||||||
|
await new Promise((r) => setTimeout(r, 150))
|
||||||
|
|
||||||
|
await onDelete?.(job)
|
||||||
|
}}
|
||||||
disabled={!onDelete}
|
disabled={!onDelete}
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-4 w-4" />
|
<TrashIcon className="h-4 w-4" />
|
||||||
@ -273,7 +313,9 @@ export default function Player({
|
|||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed z-50 shadow-xl border flex flex-col',
|
'fixed z-50 shadow-xl border flex flex-col',
|
||||||
expanded ? 'inset-6' : 'bottom-4 right-4 w-[380px]',
|
expanded
|
||||||
|
? 'inset-6'
|
||||||
|
: 'left-0 right-0 bottom-0 w-full rounded-none sm:rounded-lg sm:left-auto sm:right-4 sm:bottom-4 sm:w-[380px]',
|
||||||
className ?? ''
|
className ?? ''
|
||||||
)}
|
)}
|
||||||
noBodyPadding
|
noBodyPadding
|
||||||
@ -288,7 +330,7 @@ export default function Player({
|
|||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="xs"
|
size="md"
|
||||||
className="px-2 py-1"
|
className="px-2 py-1"
|
||||||
onClick={onToggleExpand}
|
onClick={onToggleExpand}
|
||||||
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
|
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
|
||||||
@ -303,7 +345,8 @@ export default function Player({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="xs"
|
size="md"
|
||||||
|
color='red'
|
||||||
className="px-2 py-1"
|
className="px-2 py-1"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Schließen"
|
title="Schließen"
|
||||||
|
|||||||
224
frontend/src/components/ui/SwipeCard.tsx
Normal file
224
frontend/src/components/ui/SwipeCard.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SwipeAction = {
|
||||||
|
label: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SwipeCardProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
|
||||||
|
/** Swipe an/aus (z.B. nur mobile view) */
|
||||||
|
enabled?: boolean
|
||||||
|
/** blockiert Swipe + Tap */
|
||||||
|
disabled?: boolean
|
||||||
|
|
||||||
|
/** Tap ohne Swipe (z.B. Player öffnen) */
|
||||||
|
onTap?: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückgabe:
|
||||||
|
* - true/void => Aktion erfolgreich, Karte fliegt raus (translate offscreen)
|
||||||
|
* - false => Aktion fehlgeschlagen => Karte snappt zurück
|
||||||
|
*/
|
||||||
|
onSwipeLeft: () => boolean | void | Promise<boolean | void>
|
||||||
|
onSwipeRight: () => boolean | void | Promise<boolean | void>
|
||||||
|
|
||||||
|
/** optionales Styling am äußeren Wrapper */
|
||||||
|
className?: string
|
||||||
|
|
||||||
|
/** Action-Bereiche */
|
||||||
|
leftAction?: SwipeAction // standard: Behalten
|
||||||
|
rightAction?: SwipeAction // standard: Löschen
|
||||||
|
|
||||||
|
/** Ab welcher Strecke wird ausgelöst? */
|
||||||
|
thresholdPx?: number
|
||||||
|
thresholdRatio?: number // Anteil der Kartenbreite, z.B. 0.35
|
||||||
|
|
||||||
|
/** Animation timings */
|
||||||
|
snapMs?: number
|
||||||
|
commitMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SwipeCardHandle = {
|
||||||
|
swipeLeft: (opts?: { runAction?: boolean }) => Promise<boolean>
|
||||||
|
swipeRight: (opts?: { runAction?: boolean }) => Promise<boolean>
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
enabled = true,
|
||||||
|
disabled = false,
|
||||||
|
onTap,
|
||||||
|
onSwipeLeft,
|
||||||
|
onSwipeRight,
|
||||||
|
className,
|
||||||
|
leftAction = {
|
||||||
|
label: <span className="inline-flex items-center gap-2 font-semibold">✓ Behalten</span>,
|
||||||
|
className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
|
||||||
|
},
|
||||||
|
rightAction = {
|
||||||
|
label: <span className="inline-flex items-center gap-2 font-semibold">✕ Löschen</span>,
|
||||||
|
className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
|
||||||
|
},
|
||||||
|
thresholdPx = 120,
|
||||||
|
thresholdRatio = 0.35,
|
||||||
|
snapMs = 180,
|
||||||
|
commitMs = 180,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
|
||||||
|
const cardRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const pointer = React.useRef<{
|
||||||
|
id: number | null
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
dragging: boolean
|
||||||
|
}>({ id: null, x: 0, y: 0, dragging: false })
|
||||||
|
|
||||||
|
const [dx, setDx] = React.useState(0)
|
||||||
|
const [animMs, setAnimMs] = React.useState<number>(0)
|
||||||
|
|
||||||
|
const reset = React.useCallback(() => {
|
||||||
|
setAnimMs(snapMs)
|
||||||
|
setDx(0)
|
||||||
|
window.setTimeout(() => setAnimMs(0), snapMs)
|
||||||
|
}, [snapMs])
|
||||||
|
|
||||||
|
const commit = React.useCallback(
|
||||||
|
async (dir: 'left' | 'right', runAction: boolean) => {
|
||||||
|
const el = cardRef.current
|
||||||
|
const w = el?.offsetWidth || 360
|
||||||
|
|
||||||
|
// rausfliegen lassen
|
||||||
|
setAnimMs(commitMs)
|
||||||
|
setDx(dir === 'right' ? w + 40 : -(w + 40))
|
||||||
|
|
||||||
|
let ok: boolean | void = true
|
||||||
|
if (runAction) {
|
||||||
|
try {
|
||||||
|
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
|
||||||
|
} catch {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wenn Aktion fehlschlägt => zurücksnappen
|
||||||
|
if (ok === false) {
|
||||||
|
setAnimMs(snapMs)
|
||||||
|
setDx(0)
|
||||||
|
window.setTimeout(() => setAnimMs(0), snapMs)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
||||||
|
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
|
||||||
|
reset: () => reset(),
|
||||||
|
}),
|
||||||
|
[commit, reset]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative overflow-hidden rounded-lg', className)}>
|
||||||
|
{/* Background actions (100% je Richtung) */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
{dx !== 0 ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full flex items-center',
|
||||||
|
dx > 0 ? leftAction.className : rightAction.className,
|
||||||
|
dx > 0 ? 'justify-start pl-4' : 'justify-end pr-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dx > 0 ? leftAction.label : rightAction.label}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Foreground (moves) */}
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${dx}px)`,
|
||||||
|
transition: animMs ? `transform ${animMs}ms ease` : undefined,
|
||||||
|
touchAction: 'pan-y', // wichtig: vertikales Scrollen zulassen
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (!enabled || disabled) return
|
||||||
|
pointer.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dragging: false }
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (!enabled || disabled) return
|
||||||
|
if (pointer.current.id !== e.pointerId) return
|
||||||
|
|
||||||
|
const ddx = e.clientX - pointer.current.x
|
||||||
|
const ddy = e.clientY - pointer.current.y
|
||||||
|
|
||||||
|
// Erst entscheiden ob wir überhaupt "draggen"
|
||||||
|
if (!pointer.current.dragging) {
|
||||||
|
// wenn Nutzer vertikal scrollt, nicht hijacken
|
||||||
|
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) return
|
||||||
|
if (Math.abs(ddx) < 6) return
|
||||||
|
pointer.current.dragging = true
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnimMs(0)
|
||||||
|
setDx(ddx)
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
if (!enabled || disabled) return
|
||||||
|
if (pointer.current.id !== e.pointerId) return
|
||||||
|
|
||||||
|
const el = cardRef.current
|
||||||
|
const w = el?.offsetWidth || 360
|
||||||
|
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
||||||
|
|
||||||
|
const wasDragging = pointer.current.dragging
|
||||||
|
pointer.current.id = null
|
||||||
|
|
||||||
|
if (!wasDragging) {
|
||||||
|
reset()
|
||||||
|
onTap?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dx > threshold) {
|
||||||
|
void commit('right', true) // keep
|
||||||
|
} else if (dx < -threshold) {
|
||||||
|
void commit('left', true) // delete
|
||||||
|
} else {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
if (!enabled || disabled) return
|
||||||
|
reset()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default SwipeCard
|
||||||
@ -57,6 +57,8 @@ export type TableProps<T> = {
|
|||||||
|
|
||||||
className?: string
|
className?: string
|
||||||
|
|
||||||
|
rowClassName?: (row: T, rowIndex: number) => string | undefined
|
||||||
|
|
||||||
onRowClick?: (row: T) => void
|
onRowClick?: (row: T) => void
|
||||||
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
|
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
|
||||||
|
|
||||||
@ -116,6 +118,7 @@ export default function Table<T>({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
emptyLabel = 'Keine Daten vorhanden.',
|
emptyLabel = 'Keine Daten vorhanden.',
|
||||||
className,
|
className,
|
||||||
|
rowClassName,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
onRowContextMenu,
|
onRowContextMenu,
|
||||||
sort,
|
sort,
|
||||||
@ -309,7 +312,8 @@ export default function Table<T>({
|
|||||||
key={key}
|
key={key}
|
||||||
className={cn(
|
className={cn(
|
||||||
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||||
onRowClick && 'cursor-pointer'
|
onRowClick && 'cursor-pointer',
|
||||||
|
rowClassName?.(row, rowIndex)
|
||||||
)}
|
)}
|
||||||
onClick={() => onRowClick?.(row)}
|
onClick={() => onRowClick?.(row)}
|
||||||
onContextMenu={
|
onContextMenu={
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user