updated
This commit is contained in:
parent
05c9d04db9
commit
82cd87c92e
435
backend/main.go
435
backend/main.go
@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@ -12,12 +13,15 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
@ -38,14 +42,15 @@ const (
|
||||
)
|
||||
|
||||
type RecordJob struct {
|
||||
ID string `json:"id"`
|
||||
model string `json:"model"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
Output string `json:"output"`
|
||||
Status JobStatus `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ID string `json:"id"`
|
||||
model string `json:"model"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
Output string `json:"output"`
|
||||
Status JobStatus `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
PreviewDir string `json:"-"`
|
||||
PreviewImage string `json:"-"`
|
||||
@ -68,6 +73,55 @@ var (
|
||||
// ffmpeg-Binary suchen (env, neben EXE, oder PATH)
|
||||
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
|
||||
|
||||
type RecorderSettings struct {
|
||||
@ -423,6 +477,112 @@ func remuxTSToMP4(tsPath, mp4Path string) error {
|
||||
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) {
|
||||
cmd := exec.Command(
|
||||
ffmpegPath,
|
||||
@ -905,6 +1065,79 @@ func resolvePathRelativeToApp(p string) (string, error) {
|
||||
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)
|
||||
func registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/settings", recordSettingsHandler)
|
||||
@ -919,6 +1152,7 @@ func registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/record/done", recordDoneList)
|
||||
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
|
||||
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
|
||||
mux.HandleFunc("/api/record/keep", recordKeepVideo)
|
||||
|
||||
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||
|
||||
@ -932,6 +1166,9 @@ func registerRoutes(mux *http.ServeMux) {
|
||||
|
||||
// ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
|
||||
RegisterModelAPI(mux, store)
|
||||
|
||||
// ✅ Frontend (SPA) ausliefern
|
||||
registerFrontend(mux)
|
||||
}
|
||||
|
||||
// --- main ---
|
||||
@ -1313,14 +1550,17 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
t := fi.ModTime()
|
||||
|
||||
dur, _ := durationSecondsCached(full)
|
||||
|
||||
list = append(list, &RecordJob{
|
||||
ID: base,
|
||||
SourceURL: "",
|
||||
Output: full,
|
||||
Status: JobFinished,
|
||||
StartedAt: t,
|
||||
EndedAt: &t,
|
||||
ID: base,
|
||||
Output: full,
|
||||
Status: JobFinished,
|
||||
StartedAt: t,
|
||||
EndedAt: &t,
|
||||
DurationSeconds: dur,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
@ -1395,7 +1635,11 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur POST", http.StatusMethodNotAllowed)
|
||||
@ -1484,7 +1821,11 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
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) {
|
||||
outputPath = strings.TrimSpace(outputPath)
|
||||
if outputPath == "" {
|
||||
@ -1567,7 +1962,15 @@ func moveToDoneDir(outputPath string) (string, error) {
|
||||
if err := moveFile(outputPath, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// ✅ Streaming-Optimierung
|
||||
if strings.EqualFold(filepath.Ext(dst), ".mp4") {
|
||||
if err := ensureFastStartMP4(dst); err != nil {
|
||||
fmt.Println("⚠️ faststart:", err)
|
||||
}
|
||||
}
|
||||
return dst, nil
|
||||
|
||||
}
|
||||
|
||||
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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<script type="module" crossorigin src="/assets/index-iDPthw87.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-WtXLd9dH.css">
|
||||
<script type="module" crossorigin src="/assets/index-DFSqchi9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BsHW0Op2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -541,23 +541,41 @@ export default function App() {
|
||||
return startUrl(sourceUrl)
|
||||
}
|
||||
|
||||
const handlePlayerDelete = useCallback(async (job: RecordJob) => {
|
||||
// running => stop (macht mp4 remux etc)
|
||||
if (job.status === 'running') {
|
||||
await stopJob(job.id)
|
||||
setPlayerJob(null)
|
||||
return
|
||||
}
|
||||
|
||||
const handleDeleteJob = useCallback(async (job: RecordJob) => {
|
||||
const file = baseName(job.output || '')
|
||||
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
|
||||
setDoneJobs(prev => prev.filter(j => baseName(j.output || '') !== file))
|
||||
setJobs(prev => prev.filter(j => baseName(j.output || '') !== file))
|
||||
setPlayerJob(null)
|
||||
try {
|
||||
await apiJSON(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
||||
|
||||
// 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) => {
|
||||
@ -966,7 +984,7 @@ export default function App() {
|
||||
isFavorite={Boolean(playerModel?.favorite)}
|
||||
isLiked={playerModel?.liked === true}
|
||||
|
||||
onDelete={handlePlayerDelete}
|
||||
onDelete={handleDeleteJob}
|
||||
onToggleHot={handleToggleHot}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
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 { buildDownloadContextMenu } from './DownloadContextMenu'
|
||||
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 = {
|
||||
jobs: RecordJob[]
|
||||
@ -49,16 +52,22 @@ const httpCodeFromError = (err?: string) => {
|
||||
return m ? `HTTP ${m[1]}` : null
|
||||
}
|
||||
|
||||
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
||||
|
||||
const modelNameFromOutput = (output?: string) => {
|
||||
const file = baseName(output || '')
|
||||
const fileRaw = baseName(output || '')
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
if (!file) return '—'
|
||||
|
||||
const stem = file.replace(/\.[^.]+$/, '')
|
||||
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]
|
||||
|
||||
const i = stem.lastIndexOf('_')
|
||||
return i > 0 ? stem.slice(0, i) : stem
|
||||
}
|
||||
|
||||
|
||||
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
|
||||
const PAGE_SIZE = 50
|
||||
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)
|
||||
|
||||
// 🔄 globaler Tick für animierte Thumbnails der fertigen Videos
|
||||
const [thumbTick, setThumbTick] = React.useState(0)
|
||||
type ViewMode = 'table' | 'cards' | 'gallery'
|
||||
const VIEW_KEY = 'finishedDownloads_view'
|
||||
|
||||
const [view, setView] = React.useState<ViewMode>('table')
|
||||
|
||||
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setThumbTick((t) => t + 1)
|
||||
}, 3000) // alle 3 Sekunden
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
try {
|
||||
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
||||
if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
|
||||
setView(saved)
|
||||
} 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)
|
||||
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(
|
||||
async (job: RecordJob) => {
|
||||
async (job: RecordJob): Promise<boolean> => {
|
||||
const file = baseName(job.output || '')
|
||||
const key = keyFor(job)
|
||||
|
||||
if (!file) {
|
||||
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)
|
||||
try {
|
||||
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
markDeleted(key)
|
||||
animateRemove(key)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
window.alert(`Löschen fehlgeschlagen: ${String(e?.message || e)}`)
|
||||
return false
|
||||
} finally {
|
||||
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[]>(() => {
|
||||
@ -178,7 +270,11 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
|
||||
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
|
||||
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
|
||||
|
||||
const start = Date.parse(String(job.startedAt || ''))
|
||||
@ -187,6 +283,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
return (end - start) / 1000
|
||||
}, [durations])
|
||||
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const map = new Map<string, RecordJob>()
|
||||
|
||||
@ -212,18 +309,61 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
setVisibleCount(PAGE_SIZE)
|
||||
}, [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])
|
||||
|
||||
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
||||
const runtimeOf = (job: RecordJob): string => {
|
||||
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 formatDuration(sec * 1000)
|
||||
}
|
||||
return runtimeFromTimestamps(job)
|
||||
}
|
||||
|
||||
|
||||
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
|
||||
const handleDuration = React.useCallback((job: RecordJob, seconds: number) => {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return
|
||||
@ -247,7 +387,6 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[keyFor(j)]}
|
||||
onDuration={handleDuration}
|
||||
thumbTick={thumbTick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@ -335,61 +474,224 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ✅ Mobile: Cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{visibleRows.map((j) => {
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const file = baseName(j.output || '')
|
||||
const dur = runtimeOf(j)
|
||||
{/* Toolbar */}
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
<ButtonGroup
|
||||
value={view}
|
||||
onChange={(id) => setView(id as ViewMode)}
|
||||
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 =
|
||||
j.status === 'failed' ? (
|
||||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
||||
failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{j.status}</span>
|
||||
)
|
||||
{/* ✅ Cards */}
|
||||
{view === 'cards' && (
|
||||
<div className="space-y-3">
|
||||
{visibleRows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
|
||||
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-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{model}
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const file = baseName(j.output || '')
|
||||
const dur = runtimeOf(j)
|
||||
|
||||
const statusNode =
|
||||
j.status === 'failed' ? (
|
||||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
||||
failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{j.status}</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<SwipeCard
|
||||
ref={(h) => {
|
||||
if (h) swipeRefs.current.set(k, h)
|
||||
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 className="truncate text-xs text-gray-600 dark:text-gray-300">
|
||||
{file || '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
{/* 🗑️ Direkt-Löschen */}
|
||||
<Button
|
||||
aria-label="Video löschen"
|
||||
title="Video löschen"
|
||||
onClick={(e) => {
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void deleteVideo(j)
|
||||
openCtx(j, e)
|
||||
}}
|
||||
>
|
||||
🗑
|
||||
</Button>
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[k]}
|
||||
onDuration={handleDuration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ✅ Menü-Button für Touch/Small Devices */}
|
||||
<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
|
||||
type="button"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => {
|
||||
@ -426,40 +725,17 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
/>
|
||||
</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 className="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
Status: <span className="font-medium">{j.status}</span>
|
||||
<span className="mx-2 opacity-60">•</span>
|
||||
Dauer: <span className="font-medium">{runtimeOf(j)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContextMenu
|
||||
open={!!ctx}
|
||||
@ -471,15 +747,15 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
|
||||
{rows.length > visibleCount ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
<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"
|
||||
onClick={() => setVisibleCount((v) => Math.min(rows.length, v + PAGE_SIZE))}
|
||||
>
|
||||
Mehr laden ({Math.min(PAGE_SIZE, rows.length - visibleCount)} von {rows.length - visibleCount})
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@ -1,19 +1,17 @@
|
||||
// frontend/src/components/ui/FinishedVideoPreview.tsx
|
||||
// FinishedVideoPreview.tsx
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, type SyntheticEvent } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
|
||||
import type { RecordJob } from '../../types'
|
||||
import HoverPopover from './HoverPopover'
|
||||
|
||||
type Props = {
|
||||
job: RecordJob
|
||||
getFileName: (path: string) => string
|
||||
// 🔹 optional: bereits bekannte Dauer (Sekunden)
|
||||
durationSeconds?: number
|
||||
// 🔹 Callback nach oben, wenn wir die Dauer ermittelt haben
|
||||
onDuration?: (job: RecordJob, seconds: number) => void
|
||||
|
||||
thumbTick?: number
|
||||
animated?: boolean // ✅ neu
|
||||
autoTickMs?: number // ✅ neu
|
||||
}
|
||||
|
||||
export default function FinishedVideoPreview({
|
||||
@ -21,14 +19,39 @@ export default function FinishedVideoPreview({
|
||||
getFileName,
|
||||
durationSeconds,
|
||||
onDuration,
|
||||
thumbTick
|
||||
animated = false,
|
||||
autoTickMs = 15000,
|
||||
}: Props) {
|
||||
const file = getFileName(job.output || '')
|
||||
|
||||
const [thumbOk, setThumbOk] = useState(true)
|
||||
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(() => {
|
||||
if (!file) return ''
|
||||
const dot = file.lastIndexOf('.')
|
||||
@ -43,58 +66,39 @@ export default function FinishedVideoPreview({
|
||||
const hasDuration =
|
||||
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(() => {
|
||||
if (!durationSeconds || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
// Keine Dauer bekannt → einfach bei 0s (erster Frame) bleiben
|
||||
return 0
|
||||
}
|
||||
const step = 3 // Sekunden pro Schritt
|
||||
const steps = Math.max(0, Math.floor(tick))
|
||||
// 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])
|
||||
if (!animated) return null
|
||||
if (!hasDuration) return null
|
||||
const step = 3
|
||||
const total = Math.max(durationSeconds! - 0.1, step)
|
||||
return (localTick * step) % total
|
||||
}, [animated, hasDuration, durationSeconds, localTick])
|
||||
|
||||
// Thumbnail (immer mit t=..., auch wenn t=0 → erster Frame)
|
||||
// ✅ WICHTIG: t nur wenn animiert + Dauer bekannt!
|
||||
const thumbSrc = useMemo(() => {
|
||||
if (!previewId) return ''
|
||||
|
||||
const params: string[] = []
|
||||
|
||||
// ⬅️ 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))}`)
|
||||
if (thumbTimeSec == null) {
|
||||
// statisch -> nutzt Backend preview.jpg Cache (kein ffmpeg pro Request)
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}`
|
||||
}
|
||||
|
||||
const qs = params.length ? `&${params.join('&')}` : ''
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}${qs}`
|
||||
}, [previewId, thumbTimeSec, thumbTick])
|
||||
return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent(
|
||||
thumbTimeSec.toFixed(2)
|
||||
)}`
|
||||
}, [previewId, thumbTimeSec])
|
||||
|
||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||
setMetaLoaded(true)
|
||||
if (!onDuration) return
|
||||
|
||||
const secs = e.currentTarget.duration
|
||||
if (Number.isFinite(secs) && secs > 0) {
|
||||
onDuration(job, secs)
|
||||
}
|
||||
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
|
||||
}
|
||||
|
||||
if (!videoSrc) {
|
||||
return (
|
||||
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
|
||||
)
|
||||
return <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
// ⚠️ Großes Video nur rendern, wenn Popover offen ist
|
||||
content={(open) =>
|
||||
open && (
|
||||
<div className="w-[420px]">
|
||||
@ -116,8 +120,7 @@ export default function FinishedVideoPreview({
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* 🔹 Inline nur Thumbnail / Platzhalter */}
|
||||
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
|
||||
<div ref={rootRef} className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
|
||||
{thumbSrc && thumbOk ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
@ -130,9 +133,8 @@ export default function FinishedVideoPreview({
|
||||
<div className="w-full h-full bg-black" />
|
||||
)}
|
||||
|
||||
{/* 🔍 Unsichtbares Video nur zum Metadaten-Laden (Dauer),
|
||||
wird genau EINMAL pro Datei geladen */}
|
||||
{onDuration && !hasDuration && !metaLoaded && (
|
||||
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) */}
|
||||
{inView && onDuration && !hasDuration && !metaLoaded && (
|
||||
<video
|
||||
src={videoSrc}
|
||||
preload="metadata"
|
||||
|
||||
@ -112,7 +112,7 @@ export default function Player({
|
||||
autoplay: true,
|
||||
muted: true, // <- wichtig für Autoplay ohne Klick in vielen Browsern
|
||||
controls: true,
|
||||
preload: 'auto',
|
||||
preload: 'metadata',
|
||||
playsinline: true,
|
||||
responsive: true,
|
||||
fluid: false,
|
||||
@ -195,6 +195,18 @@ export default function Player({
|
||||
queueMicrotask(() => p.trigger('resize'))
|
||||
}, [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
|
||||
|
||||
const mini = !expanded
|
||||
@ -203,14 +215,33 @@ export default function Player({
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={isHot ? 'soft' : 'secondary'}
|
||||
size="xs"
|
||||
size="md"
|
||||
className={cn(
|
||||
'px-2 py-1',
|
||||
!isHot && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||
)}
|
||||
title={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}
|
||||
>
|
||||
<FireIcon className="h-4 w-4" />
|
||||
@ -218,7 +249,7 @@ export default function Player({
|
||||
|
||||
<Button
|
||||
variant={isFavorite ? 'soft' : 'secondary'}
|
||||
size="xs"
|
||||
size="md"
|
||||
className={cn(
|
||||
'px-2 py-1',
|
||||
!isFavorite && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||
@ -233,7 +264,7 @@ export default function Player({
|
||||
|
||||
<Button
|
||||
variant={isLiked ? 'soft' : 'secondary'}
|
||||
size="xs"
|
||||
size="md"
|
||||
className={cn(
|
||||
'px-2 py-1',
|
||||
!isLiked && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||
@ -248,11 +279,20 @@ export default function Player({
|
||||
|
||||
<Button
|
||||
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"
|
||||
title="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}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
@ -273,7 +313,9 @@ export default function Player({
|
||||
<Card
|
||||
className={cn(
|
||||
'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 ?? ''
|
||||
)}
|
||||
noBodyPadding
|
||||
@ -288,7 +330,7 @@ export default function Player({
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
size="md"
|
||||
className="px-2 py-1"
|
||||
onClick={onToggleExpand}
|
||||
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
|
||||
@ -303,7 +345,8 @@ export default function Player({
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
size="md"
|
||||
color='red'
|
||||
className="px-2 py-1"
|
||||
onClick={onClose}
|
||||
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
|
||||
|
||||
rowClassName?: (row: T, rowIndex: number) => string | undefined
|
||||
|
||||
onRowClick?: (row: T) => void
|
||||
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
|
||||
|
||||
@ -116,6 +118,7 @@ export default function Table<T>({
|
||||
isLoading = false,
|
||||
emptyLabel = 'Keine Daten vorhanden.',
|
||||
className,
|
||||
rowClassName,
|
||||
onRowClick,
|
||||
onRowContextMenu,
|
||||
sort,
|
||||
@ -309,7 +312,8 @@ export default function Table<T>({
|
||||
key={key}
|
||||
className={cn(
|
||||
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
onRowClick && 'cursor-pointer',
|
||||
rowClassName?.(row, rowIndex)
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
onContextMenu={
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user