From 82f8b5269dafb9126a222516a889c17ba315e72f Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:08:39 +0100 Subject: [PATCH] updates --- backend/assets_generate.go | 123 +- backend/postwork.go | 36 +- backend/record.go | 506 +++++++ backend/routes.go | 3 + backend/tasks_assets.go | 4 +- backend/tasks_status.go | 65 +- frontend/src/App.tsx | 2 +- frontend/src/components/ui/Checkbox.tsx | 71 + .../src/components/ui/FinishedDownloads.tsx | 1197 ++++++++++++++--- .../ui/FinishedDownloadsCardsView.tsx | 342 ++--- .../ui/FinishedDownloadsGalleryView.tsx | 251 ++-- .../ui/FinishedDownloadsTableView.tsx | 107 +- frontend/src/components/ui/LabeledSwitch.tsx | 2 +- frontend/src/components/ui/ModelDetails.tsx | 2 - frontend/src/components/ui/Pagination.tsx | 275 ++-- .../src/components/ui/RecorderSettings.tsx | 77 +- frontend/src/components/ui/Table.tsx | 25 +- 17 files changed, 2263 insertions(+), 825 deletions(-) create mode 100644 frontend/src/components/ui/Checkbox.tsx diff --git a/backend/assets_generate.go b/backend/assets_generate.go index 295da9b..c56dbe1 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -164,10 +164,9 @@ type EnsureAssetsResult struct { } type EnsurePrimaryAssetsResult struct { - Skipped bool - ThumbGenerated bool - PreviewGenerated bool - MetaOK bool + Skipped bool + ThumbGenerated bool + MetaOK bool } // Public wrappers (kompatibel zu deinem bisherigen API) @@ -231,7 +230,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( return out, nil } - _, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id) + _, thumbPath, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { return out, perr } @@ -256,27 +255,17 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( return false }() - previewBefore := func() bool { - if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 { - return true - } - return false - }() - // Basis-Meta nur für Dauer/Props sicherstellen meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) out.MetaOK = meta.ok - if thumbBefore && previewBefore { + if thumbBefore { out.Skipped = true progress(1) return out, nil } - const ( - thumbsW = 0.25 - previewW = 0.75 - ) + const thumbsW = 1.0 progress(0) @@ -330,60 +319,6 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( progress(thumbsW) } - // ---------------- - // Preview.mp4 - // ---------------- - const ( - previewClipLenSec = 0.75 - previewMaxClips = 12 - ) - - if previewBefore { - progress(1) - } else { - func() { - genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) - defer cancel() - - progress(thumbsW + 0.02) - - if err := genSem.Acquire(genCtx); err != nil { - return - } - defer genSem.Release() - - progress(thumbsW + 0.05) - - if err := generateTeaserClipsMP4WithProgress( - genCtx, - videoPath, - previewPath, - previewClipLenSec, - previewMaxClips, - func(r float64) { - if r < 0 { - r = 0 - } - if r > 1 { - r = 1 - } - progress(thumbsW + r*previewW) - }, - ); err != nil { - if isFFmpegInputInvalidError(err) { - sourceInputInvalid = true - fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath) - return - } - - fmt.Println("⚠️ preview clips:", err) - return - } - - out.PreviewGenerated = true - }() - } - _ = sourceInputInvalid progress(1) return out, nil @@ -405,13 +340,51 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) return nil } - _, _, _, spritePath, metaPath, perr := assetPathsForID(id) + _, _, previewPath, spritePath, metaPath, perr := assetPathsForID(id) if perr != nil { return perr } meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + // Preview erzeugen, wenn noch nicht vorhanden + previewExists := false + if pfi, err := os.Stat(previewPath); err == nil && pfi != nil && !pfi.IsDir() && pfi.Size() > 0 { + previewExists = true + } + + if !previewExists { + const ( + previewClipLenSec = 0.75 + previewMaxClips = 12 + ) + + genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + + if err := genSem.Acquire(genCtx); err == nil { + func() { + defer genSem.Release() + + if err := generateTeaserClipsMP4WithProgress( + genCtx, + videoPath, + previewPath, + previewClipLenSec, + previewMaxClips, + nil, + ); err != nil { + if isFFmpegInputInvalidError(err) { + fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath) + return + } + fmt.Printf("⚠️ deferred preview failed for %s: %v\n", videoPath, err) + return + } + }() + } + } + // vorhandene previewClips / spriteMeta aus bestehender meta.json übernehmen var computedPreviewClips []previewClip var spriteMeta *previewSpriteMeta @@ -582,7 +555,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo "enrich", st.State, "assets", - "Ergänze Sprite / AI / Meta…", + "Wird verarbeitet…", st.Position, st.Waiting, st.Running, @@ -611,7 +584,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo "enrich", "done", "", - "Sprite / AI / Meta fertig", + "Fertig", 0, 0, 0, @@ -631,7 +604,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo "enrich", st.State, "assets", - "Warte auf Sprite / AI / Meta…", + "Wartet", st.Position, st.Waiting, st.Running, diff --git a/backend/postwork.go b/backend/postwork.go index 7dd6aa5..de3db20 100644 --- a/backend/postwork.go +++ b/backend/postwork.go @@ -27,6 +27,7 @@ type PostWorkQueue struct { // ✅ für UI: Warteschlange + Running-Keys tracken waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten) + cancelByKey map[string]context.CancelFunc } func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue { @@ -45,6 +46,7 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue { waitingKeys: make([]string, 0, queueSize), runningKeys: make(map[string]struct{}), + cancelByKey: make(map[string]context.CancelFunc), } } @@ -80,6 +82,23 @@ func (pq *PostWorkQueue) Enqueue(task PostWorkTask) bool { } } +func (pq *PostWorkQueue) Cancel(key string) bool { + if strings.TrimSpace(key) == "" { + return false + } + + pq.mu.Lock() + cancel := pq.cancelByKey[key] + pq.mu.Unlock() + + if cancel != nil { + cancel() + return true + } + + return false +} + func (pq *PostWorkQueue) removeWaitingKeyLocked(key string) { for i, k := range pq.waitingKeys { if k == key { @@ -171,7 +190,18 @@ func (pq *PostWorkQueue) workerLoop(id int) { // 3) Optional: Task timeout (gegen hängende ffmpeg) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) - defer cancel() + + pq.mu.Lock() + pq.cancelByKey[task.Key] = cancel + pq.mu.Unlock() + + defer func() { + cancel() + + pq.mu.Lock() + delete(pq.cancelByKey, task.Key) + pq.mu.Unlock() + }() // 4) Run if task.Run != nil { @@ -291,9 +321,9 @@ func startEnrichStatusRefresher() { continue } - label := "Warte auf Sprite / AI / Meta…" + label := "Wartet" if st.State == "running" { - label = "Ergänze Sprite / AI / Meta…" + label = "Wird verarbeitet…" } publishFinishedPostworkStateForJob( diff --git a/backend/record.go b/backend/record.go index cd8dcff..2475ed1 100644 --- a/backend/record.go +++ b/backend/record.go @@ -90,6 +90,10 @@ type undoDeleteToken struct { File string `json:"file"` // original basename, z.B. "HOT xyz.mp4" } +type bulkFilesRequest struct { + Files []string `json:"files"` +} + func encodeUndoDeleteToken(t undoDeleteToken) (string, error) { b, err := json.Marshal(t) if err != nil { @@ -160,6 +164,440 @@ func recordPrepareSplit(w http.ResponseWriter, r *http.Request) { }) } +type regenerateAssetsRequest struct { + File string `json:"file"` +} + +func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req regenerateAssetsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + + file := strings.TrimSpace(req.File) + if file == "" { + http.Error(w, "missing file", http.StatusBadRequest) + return + } + if !isAllowedVideoExt(file) { + 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 + } + + videoPath, _, fi, err := resolveDoneFileByName(doneAbs, file) + if err != nil { + http.Error(w, "datei nicht gefunden", http.StatusNotFound) + return + } + if fi == nil || fi.IsDir() || fi.Size() <= 0 { + http.Error(w, "invalid output path", http.StatusBadRequest) + return + } + + setRegenerateAssetsTaskRunning(file, file) + + go func(videoPath string, file string) { + id := assetIDFromVideoPath(videoPath) + + // 1) Vorher evtl. laufende enrich-Jobs abbrechen + cancelDeferredEnrichForFile(file) + + // 2) SOFORT running an UI senden + startEv := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: id, + Queue: "enrich", + State: "running", + Phase: "assets", + Label: "Wird verarbeitet", + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(startEv); err == nil { + publishSSE("finishedPostwork", b) + } + + notifyDoneChanged() + + // 3) Alte Assets löschen + if id != "" { + if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil { + _ = os.Remove(filepath.Join(assetDir, "preview.jpg")) + _ = os.Remove(filepath.Join(assetDir, "preview.mp4")) + _ = os.Remove(filepath.Join(assetDir, "preview-sprite.jpg")) + _ = os.Remove(metaPath) + } + } + + // 4) Regeneration + _, errAssets := ensureAssetsForVideoWithProgressCtx(context.Background(), videoPath, "", nil) + errAI := ensureDeferredAssetsAndAI(context.Background(), videoPath, "") + + // 5) Ergebnis zurück an UI + if errAssets != nil || errAI != nil { + errMsg := "" + if errAssets != nil { + errMsg = errAssets.Error() + } else { + errMsg = errAI.Error() + } + + errEv := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: id, + Queue: "enrich", + State: "error", + Phase: "assets", + Label: "Fehler", + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(errEv); err == nil { + publishSSE("finishedPostwork", b) + } + + fmt.Println("[regenerate-assets] failed:", file, errMsg) + return + } + + doneEv := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: id, + Queue: "enrich", + State: "done", + Phase: "assets", + Label: "Fertig", + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(doneEv); err == nil { + publishSSE("finishedPostwork", b) + } + + notifyDoneChanged() + }(videoPath, file) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "file": file, + }) +} + +func handleDeleteMany(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req bulkFilesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + + if len(req.Files) == 0 { + http.Error(w, "missing files", http.StatusBadRequest) + 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 + } + + keepRoot := filepath.Join(doneAbs, "keep") + + type itemResult struct { + File string `json:"file"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + From string `json:"from,omitempty"` + } + + results := make([]itemResult, 0, len(req.Files)) + deletedCount := 0 + seen := make(map[string]struct{}, len(req.Files)) + + for _, raw := range req.Files { + file := strings.TrimSpace(raw) + if file == "" { + continue + } + if _, ok := seen[file]; ok { + continue + } + seen[file] = struct{}{} + + if !isAllowedVideoExt(file) { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "nicht erlaubt", + }) + continue + } + + // laufende/queued Nacharbeit abbrechen + cancelDeferredEnrichForFile(file) + + target, from, fi, err := resolveDoneFileByName(doneAbs, file) + if err != nil { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "datei nicht gefunden", + }) + continue + } + if fi != nil && fi.IsDir() { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "ist ein verzeichnis", + From: from, + }) + continue + } + + if err := removeWithRetry(target); err != nil { + if runtime.GOOS == "windows" && isSharingViolation(err) { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "datei wird gerade verwendet", + From: from, + }) + continue + } + + results = append(results, itemResult{ + File: file, + OK: false, + Error: err.Error(), + From: from, + }) + continue + } + + purgeDurationCacheForPath(target) + removeJobsByOutputBasename(file) + + // generated assets aufräumen + id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) + if strings.TrimSpace(id) != "" { + removeGeneratedForID(id) + } + + // optional: leeren Keep-Unterordner nicht hart notwendig, aber sauber + if from == "keep" { + dir := filepath.Dir(target) + if strings.HasPrefix(strings.ToLower(filepath.Clean(dir)), strings.ToLower(filepath.Clean(keepRoot))) { + _ = os.Remove(dir) + } + } + + deletedCount++ + results = append(results, itemResult{ + File: file, + OK: true, + From: from, + }) + } + + if deletedCount > 0 { + notifyDoneChanged() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": deletedCount > 0, + "deleted": deletedCount, + "results": results, + }) +} + +func handleKeepMany(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req bulkFilesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + + if len(req.Files) == 0 { + http.Error(w, "missing files", http.StatusBadRequest) + 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 + } + + keepRoot := filepath.Join(doneAbs, "keep") + if err := os.MkdirAll(keepRoot, 0o755); err != nil { + http.Error(w, "keep dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + + type itemResult struct { + File string `json:"file"` + NewFile string `json:"newFile,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + } + + results := make([]itemResult, 0, len(req.Files)) + keptCount := 0 + seen := make(map[string]struct{}, len(req.Files)) + + for _, raw := range req.Files { + file := strings.TrimSpace(raw) + if file == "" { + continue + } + if _, ok := seen[file]; ok { + continue + } + seen[file] = struct{}{} + + if !isAllowedVideoExt(file) { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "nicht erlaubt", + }) + continue + } + + cancelDeferredEnrichForFile(file) + + src, from, fi, err := resolveDoneFileByName(doneAbs, file) + if err != nil { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "datei nicht gefunden", + }) + continue + } + + // Bereits in keep -> nichts tun, aber als ok zurückgeben + if from == "keep" { + results = append(results, itemResult{ + File: file, + NewFile: file, + OK: true, + }) + continue + } + + if fi == nil || fi.IsDir() { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "ist ein verzeichnis", + }) + continue + } + + modelKey := modelKeyFromFilenameOrPath(file, src, doneAbs) + dstDir := keepRoot + if modelKey != "" { + dstDir = filepath.Join(keepRoot, modelKey) + } + + if err := os.MkdirAll(dstDir, 0o755); err != nil { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "keep subdir erstellen fehlgeschlagen: " + err.Error(), + }) + continue + } + + dst, err := uniqueDestPath(dstDir, file) + if err != nil { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "zielname nicht verfügbar: " + err.Error(), + }) + continue + } + + if err := renameWithRetryAggressive(src, dst); err != nil { + if runtime.GOOS == "windows" && isSharingViolation(err) { + results = append(results, itemResult{ + File: file, + OK: false, + Error: "datei wird gerade verwendet", + }) + continue + } + + results = append(results, itemResult{ + File: file, + OK: false, + Error: err.Error(), + }) + continue + } + + keptCount++ + results = append(results, itemResult{ + File: file, + NewFile: filepath.Base(dst), + OK: true, + }) + } + + if keptCount > 0 { + notifyDoneChanged() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": keptCount > 0, + "kept": keptCount, + "results": results, + }) +} + // ---------------- Preview sprite truth (shared) ---------------- type previewSpriteMetaFileInfo struct { @@ -1763,6 +2201,62 @@ func renameWithRetryAggressive(src, dst string) error { return lastErr } +func cancelDeferredEnrichForFile(file string) { + file = strings.TrimSpace(file) + if file == "" { + return + } + + id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) + id = strings.TrimSpace(id) + if id == "" { + return + } + + enrichKey := "enrich:" + id + st := enrichQ.StatusForKey(enrichKey) + + switch st.State { + case "queued": + if enrichQ.RemoveQueued(enrichKey) { + // optional: stale badge sofort entfernen + ev := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: id, + Queue: "enrich", + State: "missing", + Phase: "assets", + Label: "", + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(ev); err == nil { + publishSSE("finishedPostwork", b) + } + } + + case "running": + if enrichQ.Cancel(enrichKey) { + // ffmpeg / reads / handles kurz auslaufen lassen + time.Sleep(350 * time.Millisecond) + + ev := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: id, + Queue: "enrich", + State: "missing", + Phase: "assets", + Label: "", + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(ev); err == nil { + publishSSE("finishedPostwork", b) + } + } + } +} + func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost && r.Method != http.MethodDelete { http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed) @@ -1779,6 +2273,9 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { return } + // ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen + cancelDeferredEnrichForFile(file) + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { @@ -2046,6 +2543,9 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) { return } + // ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird + cancelDeferredEnrichForFile(file) + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { @@ -2116,6 +2616,9 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) { return } + // ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird + cancelDeferredEnrichForFile(file) + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { @@ -2222,6 +2725,9 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) { return } + // optional, aber sinnvoll + cancelDeferredEnrichForFile(file) + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { diff --git a/backend/routes.go b/backend/routes.go index 9588a4a..5d918d4 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -63,6 +63,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/record/unkeep", recordUnkeepVideo) api.HandleFunc("/api/record/restore", recordRestoreVideo) api.HandleFunc("/api/record/prepare-split", recordPrepareSplit) + api.HandleFunc("/api/record/regenerate-assets", handleRegenerateAssets) + api.HandleFunc("/api/record/delete-many", handleDeleteMany) + api.HandleFunc("/api/record/keep-many", handleKeepMany) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index f248ffc..d3608c5 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -322,7 +322,7 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskFinishedPostworkState( it.name, "running", - "Erstelle Vorschau / Thumbnails…", + "Erstelle Teaser / Vorschau / Thumbnails…", ) // ID aus Dateiname @@ -397,7 +397,7 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskFinishedPostworkState( it.name, "done", - "Vorschau / Thumbnails fertig", + "Teaser / Vorschau / Thumbnails fertig", ) } diff --git a/backend/tasks_status.go b/backend/tasks_status.go index e4aefd6..1a21e2d 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -15,17 +15,68 @@ type CleanupTaskState struct { Error string `json:"error,omitempty"` } +type RegenerateAssetsTaskState struct { + Running bool `json:"running"` + File string `json:"file,omitempty"` + Text string `json:"text,omitempty"` + Error string `json:"error,omitempty"` +} + type TasksStatusResponse struct { - GenerateAssets AssetsTaskState `json:"generateAssets"` - Cleanup CleanupTaskState `json:"cleanup"` - AnyRunning bool `json:"anyRunning"` + GenerateAssets AssetsTaskState `json:"generateAssets"` + RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"` + Cleanup CleanupTaskState `json:"cleanup"` + AnyRunning bool `json:"anyRunning"` } var ( cleanupTaskMu sync.Mutex cleanupTaskState CleanupTaskState + + regenerateAssetsTaskMu sync.Mutex + regenerateAssetsTaskState RegenerateAssetsTaskState ) +func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState { + regenerateAssetsTaskMu.Lock() + st := regenerateAssetsTaskState + regenerateAssetsTaskMu.Unlock() + return st +} + +func setRegenerateAssetsTaskRunning(file, text string) { + regenerateAssetsTaskMu.Lock() + regenerateAssetsTaskState = RegenerateAssetsTaskState{ + Running: true, + File: strings.TrimSpace(file), + Text: strings.TrimSpace(text), + Error: "", + } + regenerateAssetsTaskMu.Unlock() +} + +func setRegenerateAssetsTaskDone(file, text string) { + regenerateAssetsTaskMu.Lock() + regenerateAssetsTaskState = RegenerateAssetsTaskState{ + Running: false, + File: strings.TrimSpace(file), + Text: strings.TrimSpace(text), + Error: "", + } + regenerateAssetsTaskMu.Unlock() +} + +func setRegenerateAssetsTaskError(file, errText string) { + regenerateAssetsTaskMu.Lock() + regenerateAssetsTaskState = RegenerateAssetsTaskState{ + Running: false, + File: strings.TrimSpace(file), + Text: "", + Error: strings.TrimSpace(errText), + } + regenerateAssetsTaskMu.Unlock() +} + func snapshotCleanupTaskState() CleanupTaskState { cleanupTaskMu.Lock() st := cleanupTaskState @@ -70,12 +121,14 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { } assets := getGenerateAssetsTaskStatus() + regenerate := snapshotRegenerateAssetsTaskState() cleanup := snapshotCleanupTaskState() resp := TasksStatusResponse{ - GenerateAssets: assets, - Cleanup: cleanup, - AnyRunning: assets.Running || cleanup.Running, + GenerateAssets: assets, + RegenerateAssets: regenerate, + Cleanup: cleanup, + AnyRunning: assets.Running || regenerate.Running || cleanup.Running, } w.Header().Set("Content-Type", "application/json") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 40e88f2..1c7f790 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3070,7 +3070,7 @@ export default function App() { return res } catch (e: any) { - notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e)) + notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output)) return } }, diff --git a/frontend/src/components/ui/Checkbox.tsx b/frontend/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..2411a83 --- /dev/null +++ b/frontend/src/components/ui/Checkbox.tsx @@ -0,0 +1,71 @@ +// frontend\src\components\ui\Checkbox.tsx + +'use client' + +import * as React from 'react' + +type Props = { + checked: boolean + indeterminate?: boolean + disabled?: boolean + onChange: (checked: boolean) => void + ariaLabel?: string + id?: string + name?: string + className?: string +} + +export default function Checkbox({ + checked, + indeterminate = false, + disabled = false, + onChange, + ariaLabel, + id, + name, + className = '', +}: Props) { + const ref = React.useRef(null) + + React.useEffect(() => { + if (ref.current) { + ref.current.indeterminate = indeterminate && !checked + } + }, [indeterminate, checked]) + + return ( +
+ onChange(e.target.checked)} + className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/20 dark:bg-gray-900 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 dark:disabled:border-white/10 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto" + /> + + + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 0378540..6e1b373 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -12,6 +12,13 @@ import { RectangleStackIcon, Squares2X2Icon, AdjustmentsHorizontalIcon, + CheckCircleIcon, + ExclamationTriangleIcon, + SparklesIcon, + CheckIcon, + XMarkIcon, + ArchiveBoxIcon, + TrashIcon, } from '@heroicons/react/24/outline' import { type SwipeCardHandle } from './SwipeCard' import { flushSync } from 'react-dom' @@ -276,6 +283,144 @@ async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit) return res } +function fileStemFromOutput(output?: string): string { + const file = baseName(output || '') + if (!file) return '' + return stripHotPrefix(file.replace(/\.[^.]+$/, '')) +} + +function jobHasFinishedAssets(job: RecordJob): boolean { + const anyJob = job as any + const meta = anyJob?.meta + + const previewSpriteExists = + meta?.previewSprite?.exists === true || + typeof meta?.previewSprite?.path === 'string' + + const hasDuration = + typeof anyJob?.durationSeconds === 'number' && + Number.isFinite(anyJob.durationSeconds) && + anyJob.durationSeconds > 0 + + const hasResolution = + (typeof anyJob?.videoWidth === 'number' && + Number.isFinite(anyJob.videoWidth) && + anyJob.videoWidth > 0 && + typeof anyJob?.videoHeight === 'number' && + Number.isFinite(anyJob.videoHeight) && + anyJob.videoHeight > 0) || + (typeof anyJob?.width === 'number' && + Number.isFinite(anyJob.width) && + anyJob.width > 0 && + typeof anyJob?.height === 'number' && + Number.isFinite(anyJob.height) && + anyJob.height > 0) + + // pragmatisch: + // - duration + resolution => meta/probe war da + // - previewSprite => späte Assets waren da + // Das ist ein guter Indikator für "alles Wesentliche fertig" + return hasDuration && hasResolution && previewSpriteExists +} + +function postworkBadgeTone(badge?: FinishedPostworkBadge): string { + const state = String(badge?.state ?? '').toLowerCase() + + switch (state) { + case 'running': + return 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/30' + case 'queued': + return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30' + case 'done': + return 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30' + case 'error': + return 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30' + default: + return 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10' + } +} + +export function renderFinishedPostworkBadge( + badge?: FinishedPostworkBadge, + opts?: { + onClick?: () => void + clickable?: boolean + } +): React.ReactNode { + if (!badge || badge.state === 'missing') return null + + const label = + String(badge.label ?? '').trim() || + (badge.state === 'running' + ? 'Wird verarbeitet' + : badge.state === 'queued' + ? 'Wartet' + : badge.state === 'done' + ? 'Fertig' + : badge.state === 'error' + ? 'Fehler' + : 'Status') + + const clickable = Boolean(opts?.clickable && opts?.onClick) + + const className = [ + 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', + postworkBadgeTone(badge), + clickable ? 'cursor-pointer transition-opacity hover:opacity-80' : '', + ].join(' ') + + if (clickable) { + return ( + + ) + } + + return ( + + {badge.state === 'running' ? ( + + ) : badge.state === 'queued' ? ( + + ) : badge.state === 'done' ? ( + + ) : badge.state === 'error' ? ( + + ) : null} + + {label} + + {badge.state === 'queued' && typeof badge.position === 'number' && badge.position > 0 ? ( + #{badge.position} + ) : null} + + ) +} + export default function FinishedDownloads({ jobs, doneJobs, @@ -301,6 +446,12 @@ export default function FinishedDownloads({ modelsByKey, loadMode = 'paged', }: Props) { + + type ViewMode = 'table' | 'cards' | 'gallery' + const VIEW_KEY = 'finishedDownloads_view' + const KEEP_KEY = 'finishedDownloads_includeKeep_v2' + const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1' + const allMode = loadMode === 'all' const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover' @@ -321,6 +472,8 @@ export default function FinishedDownloads({ const notify = useNotify() + const pointerPosRef = React.useRef<{ x: number; y: number } | null>(null) + const teaserHostsRef = React.useRef>(new Map()) const [teaserKey, setTeaserKey] = React.useState(null) const [hoverTeaserKey, setHoverTeaserKey] = React.useState(null) @@ -343,6 +496,39 @@ export default function FinishedDownloads({ const refillSessionRef = React.useRef(0) const [postworkByFile, setPostworkByFile] = React.useState>({}) + + const [undoing, setUndoing] = React.useState(false) + + type SelectedItem = { + key: string + file: string + } + + const [selectedItems, setSelectedItems] = React.useState>({}) + const [bulkBusy, setBulkBusy] = React.useState(false) + + // 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln + const [renamedFiles, setRenamedFiles] = React.useState>({}) + + // 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken" + const [overrideDoneJobs, setOverrideDoneJobs] = React.useState(null) + const [overrideDoneTotal, setOverrideDoneTotal] = React.useState(null) + const [refillTick, setRefillTick] = React.useState(0) + const refillTimerRef = React.useRef(null) + + const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any }) + + const [view, setView] = React.useState('table') + const [includeKeep, setIncludeKeep] = React.useState(false) + + const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false) + const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false) + + const swipeRefs = React.useRef>(new Map()) + + // 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab) + const [tagFilter, setTagFilter] = React.useState([]) + const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter]) const teaserState = React.useMemo( () => ({ @@ -367,6 +553,51 @@ export default function FinishedDownloads({ const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1' + const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems]) + + const toggleSelected = useCallback((job: RecordJob) => { + const k = keyFor(job) + const file = baseName(job.output || '') + if (!file) return + + setSelectedItems((prev) => { + const next = { ...prev } + if (next[k]) { + delete next[k] + } else { + next[k] = { key: k, file } + } + return next + }) + }, [keyFor]) + + const clearSelection = useCallback(() => { + setSelectedItems({}) + }, []) + + const selectPageRows = useCallback((rows: RecordJob[]) => { + setSelectedItems((prev) => { + const next = { ...prev } + for (const j of rows) { + const k = keyFor(j) + const file = baseName(j.output || '') + if (!file) continue + next[k] = { key: k, file } + } + return next + }) + }, [keyFor]) + + const deselectPageRows = useCallback((rows: RecordJob[]) => { + setSelectedItems((prev) => { + const next = { ...prev } + for (const j of rows) { + delete next[keyFor(j)] + } + return next + }) + }, [keyFor]) + const [lastAction, setLastAction] = React.useState(() => { if (typeof window === 'undefined') return null try { @@ -388,7 +619,111 @@ export default function FinishedDownloads({ return null } }) - const [undoing, setUndoing] = React.useState(false) + + const refreshHoverPreviewFromPointer = useCallback(() => { + if (!canHover) return + + const pos = pointerPosRef.current + if (!pos) { + setHoverTeaserKey(null) + return + } + + const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null + if (!el) { + setHoverTeaserKey(null) + return + } + + const hoverHost = el.closest('[data-finished-hover-key]') + const nextKey = hoverHost?.dataset.finishedHoverKey ?? null + + setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey)) + }, [canHover]) + + useEffect(() => { + let alive = true + + const loadInitialPostworkState = async () => { + try { + const res = await fetch('/api/tasks/status', { cache: 'no-store' }) + if (!res.ok) return + + const data = await res.json().catch(() => null) + if (!alive || !data) return + + const regen = data?.regenerateAssets + const file = String(regen?.file ?? '').trim() + + if (!file) return + + if (regen?.running) { + setPostworkByFile((prev) => ({ + ...prev, + [file]: { + file, + assetId: fileStemFromOutput(file), + queue: 'enrich', + state: 'running', + phase: 'assets', + label: String(regen?.text || 'Wird verarbeitet'), + ts: Date.now(), + }, + })) + return + } + + if (regen?.error) { + setPostworkByFile((prev) => ({ + ...prev, + [file]: { + file, + assetId: fileStemFromOutput(file), + queue: 'enrich', + state: 'error', + phase: 'assets', + label: 'Fehler', + ts: Date.now(), + }, + })) + return + } + + // optional: wenn du "done" kurz nach Reload noch sichtbar haben willst + if (regen?.text) { + setPostworkByFile((prev) => ({ + ...prev, + [file]: { + file, + assetId: fileStemFromOutput(file), + queue: 'enrich', + state: 'done', + phase: 'assets', + label: 'Fertig', + ts: Date.now(), + }, + })) + } + } catch { + // ignore + } + } + + void loadInitialPostworkState() + + return () => { + alive = false + } + }, []) + + useEffect(() => { + const onPointerMove = (e: PointerEvent) => { + pointerPosRef.current = { x: e.clientX, y: e.clientY } + } + + window.addEventListener('pointermove', onPointerMove, { passive: true }) + return () => window.removeEventListener('pointermove', onPointerMove) + }, []) useEffect(() => { try { @@ -409,17 +744,6 @@ export default function FinishedDownloads({ } }, [lastAction]) - // 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln - const [renamedFiles, setRenamedFiles] = React.useState>({}) - - // 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken" - const [overrideDoneJobs, setOverrideDoneJobs] = React.useState(null) - const [overrideDoneTotal, setOverrideDoneTotal] = React.useState(null) - const [refillTick, setRefillTick] = React.useState(0) - const refillTimerRef = React.useRef(null) - - const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any }) - const emitCountHint = React.useCallback((delta: number) => { if (!delta || !Number.isFinite(delta)) return @@ -440,22 +764,6 @@ export default function FinishedDownloads({ }, 120) }, []) - type ViewMode = 'table' | 'cards' | 'gallery' - const VIEW_KEY = 'finishedDownloads_view' - const KEEP_KEY = 'finishedDownloads_includeKeep_v2' - const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1' - - const [view, setView] = React.useState('table') - const [includeKeep, setIncludeKeep] = React.useState(false) - - const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false) - - const swipeRefs = React.useRef>(new Map()) - - // 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab) - const [tagFilter, setTagFilter] = React.useState([]) - const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter]) - const modelTags = useMemo(() => { const tagsByModelKey: Record = {} const tagSetByModelKey: Record> = {} @@ -1118,11 +1426,17 @@ export default function FinishedDownloads({ markDeleted(key) markRemoving(key, false) + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + refreshHoverPreviewFromPointer() + }) + }) }, 320) removeTimersRef.current.set(key, t) }, - [markDeleted, markRemoving, cancelRemoveTimer] + [markDeleted, markRemoving, cancelRemoveTimer, refreshHoverPreviewFromPointer] ) const releasePlayingFile = useCallback( @@ -1433,6 +1747,21 @@ export default function FinishedDownloads({ next[oldFile] = newFile return next }) + setSelectedItems((prev) => { + let changed = false + const next: Record = {} + + for (const [k, item] of Object.entries(prev)) { + if (item.file === oldFile) { + next[k] = { ...item, file: newFile } + changed = true + } else { + next[k] = item + } + } + + return changed ? next : prev + }) }, []) const clearRenamePair = useCallback((a: string, b: string) => { @@ -1658,6 +1987,31 @@ export default function FinishedDownloads({ ] ) + const regenerateAssetsForFile = useCallback(async (file: string) => { + const cleanFile = String(file || '').trim() + if (!cleanFile) return false + + try { + const res = await fetch('/api/record/regenerate-assets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file: cleanFile }), + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + return true + } catch (e: any) { + notify.error( + 'Assets neu generieren fehlgeschlagen', + e?.message ? String(e.message) : String(e) + ) + return false + } + }, [notify]) + const applyRenamedOutput = useCallback( (job: RecordJob): RecordJob => { const out = norm(job.output || '') @@ -1749,6 +2103,40 @@ export default function FinishedDownloads({ return list }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutput, keyFor, baseName]) + + + const effectivePostworkByFile = useMemo>(() => { + const next: Record = { ...postworkByFile } + + for (const job of rows) { + const file = baseName(job.output || '') + if (!file) continue + + const existing = next[file] + + // Echte Live-/Queue-Badges haben Vorrang + if (existing && existing.state !== 'missing') { + continue + } + + if (!jobHasFinishedAssets(job)) { + continue + } + + next[file] = { + file, + assetId: fileStemFromOutput(job.output), + queue: 'enrich', + state: 'done', + phase: 'assets', + label: 'Fertig', + ts: Date.now(), + } + } + + return next + }, [postworkByFile, rows]) + const viewRows = rows const fileToKeyRef = React.useRef>(new Map()) @@ -1896,6 +2284,12 @@ export default function FinishedDownloads({ }) }, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens]) + const selectedFiles = useMemo(() => { + return Object.values(selectedItems) + .map((x) => String(x.file || '').trim()) + .filter(Boolean) + }, [selectedItems]) + const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage const pageRows = useMemo(() => { @@ -2041,6 +2435,131 @@ export default function FinishedDownloads({ flushResolutionsSoon() }, [flushResolutionsSoon]) + const bulkDeleteSelected = useCallback(async () => { + if (bulkBusy) return + if (selectedFiles.length === 0) return + + const ok = window.confirm(`${selectedFiles.length} Downloads löschen?`) + if (!ok) return + + setBulkBusy(true) + try { + const res = await fetch('/api/record/delete-many', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: selectedFiles }), + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + + const data = await res.json().catch(() => null) + const results = Array.isArray(data?.results) ? data.results : [] + + for (const item of results) { + if (!item?.ok) continue + + const file = String(item.file || '').trim() + if (!file) continue + + const key = + Object.values(selectedItems).find((x) => x.file === file)?.key || + fileToKeyRef.current.get(file) || + file + + animateRemove(key, file) + } + + const deletedCount = Number(data?.deleted ?? results.filter((x: any) => x?.ok).length ?? 0) + + clearSelection() + queueRefill() + emitCountHint(-deletedCount) + + if (deletedCount > 0) { + notify.success?.('Löschen erfolgreich', `${deletedCount} Downloads gelöscht.`) + } + } catch (e: any) { + notify.error('Bulk-Löschen fehlgeschlagen', String(e?.message || e)) + } finally { + setBulkBusy(false) + } + }, [ + bulkBusy, + selectedFiles, + selectedItems, + animateRemove, + clearSelection, + queueRefill, + emitCountHint, + notify, + ]) + + const bulkKeepSelected = useCallback(async () => { + if (bulkBusy) return + if (selectedFiles.length === 0) return + + const ok = window.confirm(`${selectedFiles.length} Downloads behalten?`) + if (!ok) return + + setBulkBusy(true) + try { + const res = await fetch('/api/record/keep-many', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: selectedFiles }), + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + + const data = await res.json().catch(() => null) + const results = Array.isArray(data?.results) ? data.results : [] + + for (const item of results) { + if (!item?.ok) continue + + const file = String(item.file || '').trim() + if (!file) continue + + const key = + Object.values(selectedItems).find((x) => x.file === file)?.key || + fileToKeyRef.current.get(file) || + file + + animateRemove(key, file) + } + + const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0) + + clearSelection() + queueRefill() + emitCountHint(includeKeep ? 0 : -keptCount) + + if (keptCount > 0) { + notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`) + } + } catch (e: any) { + notify.error('Bulk-Keep fehlgeschlagen', String(e?.message || e)) + } finally { + setBulkBusy(false) + } + }, [ + bulkBusy, + selectedFiles, + selectedItems, + animateRemove, + clearSelection, + queueRefill, + emitCountHint, + includeKeep, + notify, + ]) + // ✅ Hooks immer zuerst – unabhängig von rows const isSmall = useMediaQuery('(max-width: 639px)') @@ -2079,6 +2598,17 @@ export default function FinishedDownloads({ if (emptyFolder && page !== 1) onPageChange(1) }, [emptyFolder, isLoading, page, onPageChange]) + const selectedCount = selectedFiles.length + const allPageSelected = + pageRows.length > 0 && + pageRows.every((j) => selectedKeys.has(keyFor(j))) + + useEffect(() => { + if (selectedCount === 0 && selectionActionsOpen) { + setSelectionActionsOpen(false) + } + }, [selectedCount, selectionActionsOpen]) + return ( <> {/* Toolbar (sticky) */} @@ -2090,41 +2620,40 @@ export default function FinishedDownloads({ dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40 " > - {/* Header row */} -
- {/* Left: Title + Count */} -
-
- Abgeschlossene Downloads + {/* Desktop */} +
+ {/* Zeile 1: Titel */} +
+
+
+ Abgeschlossene Downloads +
+ + {totalItemsForPagination} + +
+ +
+
- - {totalItemsForPagination} -
- {/* Mobile title (bleibt wie gehabt, aber kompakter) */} -
-
- Abgeschlossene Downloads -
- - {totalItemsForPagination} - -
- - {/* Right: Controls */} -
- {isLoading ? ( - - ) : null} - {/* Desktop: Suche soll den Platz füllen */} -
+ {/* Zeile 2: Controls */} +
+
setSearchQuery(e.target.value)} placeholder="Suchen…" className=" - h-9 w-full max-w-[420px] rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm + h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark] " @@ -2136,96 +2665,372 @@ export default function FinishedDownloads({ ) : null}
- {/* Desktop: Keep Toggle */} -
- { - if (page !== 1) onPageChange(1) - setIncludeKeep(checked) - }} + {selectedCount > 0 ? ( + + ) : null} + +
+
+ { + if (page !== 1) onPageChange(1) + setIncludeKeep(checked) + }} + /> +
+ + {view !== 'table' ? ( +
+ + +
+ ) : null} + + + + setView(id as ViewMode)} + size="md" + ariaLabel="Ansicht" + items={[ + { + id: 'table', + icon: , + label: 'Tabelle', + srLabel: 'Tabelle', + }, + { + id: 'cards', + icon: , + label: 'Cards', + srLabel: 'Cards', + }, + { + id: 'gallery', + icon: , + label: 'Galerie', + srLabel: 'Galerie', + }, + ]} />
- - {/* Desktop: Sort (nur wenn nicht Tabelle) */} - {view !== 'table' && ( -
- - -
- )} - - {/* Views */} - - - - setView(id as ViewMode)} - size={isSmall ? 'sm' : 'md'} - ariaLabel="Ansicht" - items={[ - { id: 'table', icon: , label: isSmall ? undefined : 'Tabelle', srLabel: 'Tabelle' }, - { id: 'cards', icon: , label: isSmall ? undefined : 'Cards', srLabel: 'Cards' }, - { id: 'gallery', icon: , label: isSmall ? undefined : 'Galerie', srLabel: 'Galerie' }, - ]} - /> - - {/* Mobile: Optionen Button (unverändert) */} -
+ {/* Mobile */} +
+
+
+
+ Abgeschlossene Downloads +
+ + {totalItemsForPagination} + +
+ +
+ +
+
+ +
+
+ + +
+ setView(id as ViewMode)} + size="sm" + ariaLabel="Ansicht" + items={[ + { + id: 'table', + icon: , + label: undefined, + srLabel: 'Tabelle', + }, + { + id: 'cards', + icon: , + label: undefined, + srLabel: 'Cards', + }, + { + id: 'gallery', + icon: , + label: undefined, + srLabel: 'Galerie', + }, + ]} + /> +
+ + {selectedCount > 0 ? ( + + ) : null} + + +
+
+
+ + {/* Auswahl-Aktionen */} + {selectedCount > 0 ? ( +
+
+ {/* Mobile */} +
+ {bulkBusy ? ( +
+ + Wird verarbeitet… +
+ ) : null} + +
+ + + + + + + +
+
+ + {/* Desktop */} +
+ {bulkBusy ? ( +
+ + Wird verarbeitet… +
+ ) : null} + + + + + +
+ + + + +
+
+
+ ) : null} + {/* Desktop: aktive Tag-Filter anzeigen */} {tagFilter.length > 0 ? (
@@ -2253,7 +3058,7 @@ export default function FinishedDownloads({
) : null} - {/* Mobile Optionen (einklappbar): Suche + Keep + Sort */} + {/* Mobile Optionen */}
- {/* “Sheet”-Body */}
- {/* Suche */}
- {/* Keep – als Setting Row */}
@@ -2309,7 +3111,6 @@ export default function FinishedDownloads({
- {/* Sort */} {view !== 'table' ? (
@@ -2342,11 +3143,12 @@ export default function FinishedDownloads({
- {/* Tag-Filter */} {tagFilter.length > 0 ? (
- Tag-Filter + + Tag-Filter +
{tagFilter.map((t) => ( @@ -2438,11 +3240,17 @@ export default function FinishedDownloads({ ) : null} ) : ( - <> +
{view === 'cards' && (
{ + if (checked) selectPageRows(pageRows) + else deselectPageRows(pageRows) + }} isSmall={isSmall} jobForDetails={jobForDetails} isLoading={isLoading} @@ -2484,8 +3292,17 @@ export default function FinishedDownloads({ onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} assetNonce={assetNonce ?? 0} - postworkByFile={postworkByFile} + postworkByFile={effectivePostworkByFile} forcePreviewMuted={forcePreviewMuted} + renderPostworkBadge={(badge) => + renderFinishedPostworkBadge(badge, { + clickable: true, + onClick: () => { + if (!badge?.file) return + void regenerateAssetsForFile(badge.file) + }, + }) + } />
)} @@ -2494,6 +3311,12 @@ export default function FinishedDownloads({ { + if (checked) selectPageRows(pageRows) + else deselectPageRows(pageRows) + }} jobForDetails={jobForDetails} keyFor={keyFor} baseName={baseName} @@ -2531,14 +3354,29 @@ export default function FinishedDownloads({ onAddToDownloads={onAddToDownloads} deleteVideo={deleteVideo} keepVideo={keepVideo} - postworkByFile={postworkByFile} + postworkByFile={effectivePostworkByFile} forcePreviewMuted={forcePreviewMuted} + renderPostworkBadge={(badge) => + renderFinishedPostworkBadge(badge, { + clickable: true, + onClick: () => { + if (!badge?.file) return + void regenerateAssetsForFile(badge.file) + }, + }) + } /> )} {view === 'gallery' && ( { + if (checked) selectPageRows(pageRows) + else deselectPageRows(pageRows) + }} isLoading={isLoading} jobForDetails={jobForDetails} blurPreviews={blurPreviews} @@ -2571,31 +3409,46 @@ export default function FinishedDownloads({ activeTagSet={activeTagSet} onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} - postworkByFile={postworkByFile} + postworkByFile={effectivePostworkByFile} forcePreviewMuted={forcePreviewMuted} + renderPostworkBadge={(badge) => + renderFinishedPostworkBadge(badge, { + clickable: true, + onClick: () => { + if (!badge?.file) return + void regenerateAssetsForFile(badge.file) + }, + }) + } /> )} - - { - flushSync(() => { - setInlinePlay(null) - setTeaserKey(null) - setHoverTeaserKey(null) - }) - window.scrollTo({ top: 0, behavior: 'auto' }) - onPageChange(p) - }} - showSummary={false} - prevLabel="Zurück" - nextLabel="Weiter" - className="mt-4" - /> - +
)} + +
+
+
+ { + flushSync(() => { + setInlinePlay(null) + setTeaserKey(null) + setHoverTeaserKey(null) + }) + window.scrollTo({ top: 0, behavior: 'auto' }) + onPageChange(p) + }} + showSummary={false} + prevLabel="Zurück" + nextLabel="Weiter" + className="mt-0" + /> +
+
+
) diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index fdb2d7c..df9dffe 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -16,6 +16,7 @@ import { shouldAnimateTeaser, shouldEnableTeaserAudio, } from './teaserPlayback' +import Checkbox from './Checkbox' type InlinePlayState = { key: string; nonce: number } | null @@ -23,6 +24,9 @@ type Props = { rows: RecordJob[] isLoading?: boolean isSmall: boolean + selectedKeys: Set + onToggleSelected: (job: RecordJob) => void + onToggleSelectAllPage: (checked: boolean) => void blurPreviews?: boolean durations: Record @@ -98,16 +102,23 @@ type Props = { ts: number }> + renderPostworkBadge?: (badge?: { + file: string + assetId?: string + queue: 'postwork' | 'enrich' + state: 'queued' | 'running' | 'done' | 'error' | 'missing' + phase?: string + label?: string + position?: number + waiting?: number + running?: number + maxParallel?: number + ts: number + }) => React.ReactNode + forcePreviewMuted?: boolean } -type PostworkBadgeTone = 'idle' | 'running' | 'done' | 'error' - -type PostworkBadge = { - label: string - tone: PostworkBadgeTone -} - const parseTags = (raw?: string): string[] => { const s = String(raw ?? '').trim() if (!s) return [] @@ -179,189 +190,6 @@ function chooseSpriteGrid(count: number): [number, number] { return [bestCols, bestRows] } -function postworkBadgeClass(tone: PostworkBadgeTone) { - if (tone === 'running') { - return 'bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-400/20' - } - if (tone === 'done') { - return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-400/20' - } - if (tone === 'error') { - return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-400/20' - } - return 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10' -} - -function isPostworkJob(job: RecordJob): boolean { - const anyJ = job as any - const phase = String(anyJ.phase ?? '').trim().toLowerCase() - const pw = anyJ.postWork - const pwKey = String(anyJ.postWorkKey ?? '').trim() - - if (pwKey) return true - if (pw && (pw.state === 'queued' || pw.state === 'running')) return true - if (job.endedAt && phase) return true - if (phase === 'postwork') return true - - return false -} - -function getEffectivePostworkState(job: RecordJob): 'running' | 'queued' | 'none' { - const anyJ = job as any - const phase = String(anyJ.phase ?? '').trim().toLowerCase() - const pw = anyJ.postWork - const pwState = String(pw?.state ?? '').trim().toLowerCase() - const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== '' - - if (!isPostworkJob(job)) return 'none' - - // ✅ Für Finished-Tab: echte Postwork-Phasen/Queue-Hinweise dürfen auch - // bei status=finished weiter sichtbar bleiben. - - if (pwState === 'queued') return 'queued' - if (pwState === 'running') return 'running' - - if ( - phase === 'postwork' || - phase === 'probe' || - phase === 'remuxing' || - phase === 'moving' || - phase === 'assets' - ) { - return 'running' - } - - if ( - typeof pw?.position === 'number' && - Number.isFinite(pw.position) && - pw.position > 0 - ) { - return 'queued' - } - - if ( - typeof pw?.running === 'number' && - Number.isFinite(pw.running) && - pw.running > 0 && - (!Number.isFinite(pw?.position) || pw.position <= 0) - ) { - return 'running' - } - - if (hasPwKey) return 'queued' - if (anyJ.endedAt || job.endedAt) return 'queued' - - return 'none' -} - -function phaseLabel(phaseRaw?: string): string { - switch (String(phaseRaw ?? '').trim().toLowerCase()) { - case 'probe': - return 'Analysiere Datei (Dauer/Streams)…' - case 'remuxing': - return 'Konvertiere Container zu MP4…' - case 'moving': - return 'Verschiebe nach Done…' - case 'assets': - return 'Erstelle Vorschau/Thumbnails…' - case 'postwork': - return 'Nacharbeiten laufen…' - default: - return '' - } -} - -function postWorkLabel(job: RecordJob): string { - const anyJ = job as any - const pw = anyJ.postWork - const effectiveState = getEffectivePostworkState(job) - - if (effectiveState === 'running') { - const running = typeof pw?.running === 'number' && pw.running > 0 ? pw.running : 1 - const maxP = typeof pw?.maxParallel === 'number' ? pw.maxParallel : 0 - - return maxP > 0 - ? `Nacharbeiten laufen… (${running}/${maxP} parallel)` - : 'Nacharbeiten laufen…' - } - - if (effectiveState === 'queued') { - const posServer = typeof pw?.position === 'number' ? pw.position : 0 - const waitingServer = typeof pw?.waiting === 'number' ? pw.waiting : 0 - const totalServer = Math.max(waitingServer, posServer) - - return posServer > 0 && totalServer > 0 - ? `Warte auf Nacharbeiten… ${posServer} / ${totalServer}` - : 'Warte auf Nacharbeiten…' - } - - return '' -} - -function finishedPostworkBadge(job: RecordJob): { - label: string - tone: 'idle' | 'running' | 'done' | 'error' -} | null { - const anyJ = job as any - const phaseRaw = String(anyJ?.phase ?? '').trim() - const phase = phaseRaw.toLowerCase() - const effectiveState = getEffectivePostworkState(job) - - if (effectiveState === 'none') return null - - if ( - phase === 'probe' || - phase === 'remuxing' || - phase === 'moving' || - phase === 'assets' - ) { - return { - label: phaseLabel(phaseRaw) || 'Nacharbeiten laufen…', - tone: 'running', - } - } - - if (effectiveState === 'queued') { - return { - label: postWorkLabel(job) || 'Warte auf Nacharbeiten…', - tone: 'idle', - } - } - - if (effectiveState === 'running') { - return { - label: phaseLabel(phaseRaw) || postWorkLabel(job) || 'Nacharbeiten laufen…', - tone: 'running', - } - } - - const rawStatus = - String(anyJ?.postworkStatus ?? anyJ?.postprocessingStatus ?? '') - .trim() - .toLowerCase() - - if (rawStatus === 'failed' || rawStatus === 'error') { - return { - label: 'Nacharbeiten fehlgeschlagen', - tone: 'error', - } - } - - if ( - rawStatus === 'done' || - rawStatus === 'finished' || - rawStatus === 'completed' || - rawStatus === 'success' - ) { - return { - label: 'Nacharbeiten fertig', - tone: 'done', - } - } - - return null -} - function CardBlurWrapper({ blurred, animateUnblurOnMount, @@ -545,6 +373,8 @@ function MobileLoadingStack({ export default function FinishedDownloadsCardsView({ rows, isSmall, + selectedKeys, + onToggleSelected, isLoading, blurPreviews, durations, @@ -590,9 +420,13 @@ export default function FinishedDownloadsCardsView({ onSplit, onAddToDownloads, postworkByFile, + renderPostworkBadge, forcePreviewMuted, }: Props) { + const hasAnySelection = selectedKeys.size > 0 + const footerTapRef = React.useRef>({}) + const parseMeta = React.useCallback((j: RecordJob): any | null => { const raw: any = (j as any)?.meta if (!raw) return null @@ -757,6 +591,7 @@ export default function FinishedDownloadsCardsView({ }) const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) + const checked = selectedKeys.has(k) const model = modelNameFromOutput(j.output) const fileRaw = baseName(j.output || '') @@ -892,30 +727,6 @@ export default function FinishedDownloadsCardsView({ const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) - const postworkStatus: PostworkBadge | null = - livePostwork && livePostwork.state !== 'missing' - ? { - label: - livePostwork.label || - (livePostwork.state === 'queued' - ? 'Warte auf Nachbearbeitung…' - : livePostwork.state === 'running' - ? 'Nachbearbeitung läuft…' - : livePostwork.state === 'error' - ? 'Nachbearbeitung fehlgeschlagen' - : livePostwork.state === 'done' - ? 'Nachbearbeitung fertig' - : ''), - tone: - livePostwork.state === 'running' - ? 'running' - : livePostwork.state === 'queued' - ? 'idle' - : livePostwork.state === 'error' - ? 'error' - : 'done', - } - : finishedPostworkBadge(j) const tags = parseTags(flags?.tags) @@ -955,8 +766,37 @@ export default function FinishedDownloadsCardsView({
openPlayer(j)} + onClick={(e) => { + if (hasAnySelection) { + e.preventDefault() + e.stopPropagation() + onToggleSelected(j) + return + } + + if (!isSmall) { + openPlayer(j) + } + }} + onMouseEnter={ + isSmall + ? undefined + : () => { + onHoverPreviewKeyChange?.(k) + } + } + onMouseLeave={ + isSmall + ? undefined + : () => { + onHoverPreviewKeyChange?.(null) + setHoveredThumbKey((prev) => (prev === k ? null : prev)) + clearScrubActiveIndex(k) + setScrubHovering(k, false) + } + } onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() @@ -968,6 +808,27 @@ export default function FinishedDownloadsCardsView({ blurred={opts?.blur} animateUnblurOnMount={opts?.animateUnblurOnMount} > + {!isSmall ? ( +
{ + e.preventDefault() + e.stopPropagation() + }} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > + onToggleSelected(j)} + /> +
+ ) : null} + {/* Card shell keeps backgrounds consistent */} {/* Preview */} @@ -980,15 +841,13 @@ export default function FinishedDownloadsCardsView({ } className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5" onMouseEnter={ - isSmall || opts?.disablePreviewHover + isSmall ? undefined : () => { setHoveredThumbKey(k) - onHoverPreviewKeyChange?.(k) } } onMouseLeave={() => { - if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null) setHoveredThumbKey((prev) => (prev === k ? null : prev)) clearScrubActiveIndex(k) setScrubHovering(k, false) @@ -996,6 +855,12 @@ export default function FinishedDownloadsCardsView({ onClick={(e) => { e.preventDefault() e.stopPropagation() + + if (hasAnySelection) { + onToggleSelected(j) + return + } + if (isSmall || opts?.disableInline) return startInline(k) }} @@ -1144,24 +1009,28 @@ export default function FinishedDownloadsCardsView({
{/* Footer / Meta (wie Gallery strukturiert) */} -
+
{ + footerTapRef.current[k] = true + }} + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + openPlayer(j) + }} + >
-
-
+
+
{model}
- {postworkStatus ? ( - - {postworkStatus.label} - + {renderPostworkBadge && livePostwork ? ( +
+ {renderPostworkBadge(livePostwork)} +
) : null}
@@ -1465,6 +1334,17 @@ export default function FinishedDownloadsCardsView({ onTap={() => { if (!mobileTopTapEnabled) return + if (footerTapRef.current[k]) { + delete footerTapRef.current[k] + openPlayer(j) + return + } + + if (hasAnySelection) { + onToggleSelected(j) + return + } + startInline(k) requestAnimationFrame(() => { if (!tryAutoplayInline(inlineDomId)) { diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index b943f2d..c6f0ccb 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -4,11 +4,6 @@ import * as React from 'react' import type { RecordJob } from '../../types' import FinishedVideoPreview from './FinishedVideoPreview' -import { - StarIcon as StarSolidIcon, - HeartIcon as HeartSolidIcon, - EyeIcon as EyeSolidIcon, -} from '@heroicons/react/24/solid' import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' import { isHotName, stripHotPrefix } from './hotName' @@ -20,6 +15,7 @@ import { shouldEnableTeaserAudio, shouldObserveTeasers, } from './teaserPlayback' +import Checkbox from './Checkbox' type ModelFlags = { favorite?: boolean @@ -52,11 +48,15 @@ type FinishedPostworkBadge = { type Props = { rows: RecordJob[] + selectedKeys: Set + onToggleSelected: (job: RecordJob) => void + onToggleSelectAllPage: (checked: boolean) => void isLoading?: boolean blurPreviews?: boolean durations: Record postworkByFile: Record teaserState: FinishedDownloadsTeaserState + renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode handleDuration: (job: RecordJob, seconds: number) => void @@ -169,6 +169,9 @@ export default function FinishedDownloadsGalleryView({ durations, postworkByFile, teaserState, + renderPostworkBadge, + selectedKeys, + onToggleSelected, handleDuration, handleScrubberClickIndex, @@ -204,8 +207,7 @@ export default function FinishedDownloadsGalleryView({ }: Props) { // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll const observeTeasers = shouldObserveTeasers(teaserState.mode) - - + const hasAnySelection = selectedKeys.size > 0 // ✅ Wrapper: bei still unregistrieren / nicht registrieren const registerTeaserHostIfNeeded = React.useCallback( @@ -290,6 +292,7 @@ export default function FinishedDownloadsGalleryView({
{rows.map((j) => { const k = keyFor(j) + const checked = selectedKeys.has(k) const isPreviewActive = teaserState.activeKey === k || teaserState.hoverKey === k @@ -479,12 +482,48 @@ export default function FinishedDownloadsGalleryView({
onOpenPlayer(j)} + onClick={(e) => { + if (hasAnySelection) { + e.preventDefault() + e.stopPropagation() + onToggleSelected(j) + return + } + + onOpenPlayer(j) + }} + onMouseEnter={() => { + onHoverPreviewKeyChange?.(k) + }} + onMouseLeave={() => { + onHoverPreviewKeyChange?.(null) + setHoveredThumbKey((prev) => (prev === k ? null : prev)) + clearScrubIndex(k) + setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) + }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) }} > +
{ + e.preventDefault() + e.stopPropagation() + }} + onMouseDown={(e) => e.stopPropagation()} + > + onToggleSelected(j)} + /> +
{ setHoveredThumbKey(k) - onHoverPreviewKeyChange?.(k) }} onMouseLeave={() => { setHoveredThumbKey((prev) => (prev === k ? null : prev)) - onHoverPreviewKeyChange?.(null) clearScrubIndex(k) setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) }} @@ -626,9 +663,46 @@ export default function FinishedDownloadsGalleryView({
{/* Footer / Meta */} -
- {/* ✅ stashapp-like: Dateiname zuerst */} +
{ + e.preventDefault() + e.stopPropagation() + onOpenPlayer(j) + }} + > + {/* Model oben + Badge direkt dahinter */}
+
+ { + e.stopPropagation() + if (modelImageSrc) setHoveredModelPreviewKey(k) + }} + onMouseLeave={(e) => { + e.stopPropagation() + setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) + }} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {model || '—'} + + + {showPostworkBadge && renderPostworkBadge ? ( +
+ {renderPostworkBadge(postworkBadge)} +
+ ) : null} +
+
{isHot ? ( @@ -637,7 +711,7 @@ export default function FinishedDownloadsGalleryView({ ) : null} {stripHotPrefix(file) || '—'} @@ -645,123 +719,54 @@ export default function FinishedDownloadsGalleryView({
- {/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */} -
-
- { - e.stopPropagation() - if (modelImageSrc) setHoveredModelPreviewKey(k) - }} - onMouseLeave={(e) => { - e.stopPropagation() - setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) - }} - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - > - {model || '—'} - - - {showPostworkBadge ? ( - - - {postworkBadge.label || - (postworkBadge.state === 'running' - ? 'Nacharbeiten laufen…' - : postworkBadge.state === 'queued' - ? 'Warte auf Nacharbeiten…' - : postworkBadge.state === 'done' - ? 'Nacharbeiten abgeschlossen' - : 'Nacharbeiten fehlgeschlagen')} - - - ) : null} -
- -
- {isWatching ? : null} - {isLiked ? : null} - {isFav ? : null} + {/* Actions (wie CardView: im Footer statt im Video) */} +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > +
+
+
+
- {/* Actions (wie CardView: im Footer statt im Video) */} -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > -
-
- -
-
-
- - {/* Tags */} -
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> - -
+ {/* Tags */} +
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> +
- ) +
+ ) })}
diff --git a/frontend/src/components/ui/FinishedDownloadsTableView.tsx b/frontend/src/components/ui/FinishedDownloadsTableView.tsx index 040a20b..e1e3737 100644 --- a/frontend/src/components/ui/FinishedDownloadsTableView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsTableView.tsx @@ -15,6 +15,7 @@ import { shouldAnimateTeaser, shouldEnableTeaserAudio, } from './teaserPlayback' +import Checkbox from './Checkbox' type SortMode = | 'completed_desc' @@ -43,6 +44,9 @@ type FinishedPostworkBadge = { type Props = { rows: RecordJob[] isLoading?: boolean + selectedKeys: Set + onToggleSelected: (job: RecordJob) => void + onToggleSelectAllPage: (checked: boolean) => void // helpers keyFor: (j: RecordJob) => string @@ -56,6 +60,7 @@ type Props = { durations: Record postworkByFile: Record jobForDetails: (job: RecordJob) => RecordJob + renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode // teaser/preview canHover: boolean @@ -98,6 +103,9 @@ type Props = { export default function FinishedDownloadsTableView({ rows, isLoading, + selectedKeys, + onToggleSelected, + onToggleSelectAllPage, keyFor, baseName, lower, @@ -109,6 +117,7 @@ export default function FinishedDownloadsTableView({ durations, postworkByFile, jobForDetails, + renderPostworkBadge, canHover, teaserState, @@ -229,8 +238,54 @@ export default function FinishedDownloadsTableView({ }) }, []) + const allSelectedOnPage = + rows.length > 0 && rows.every((j) => selectedKeys.has(keyFor(j))) + + const someSelectedOnPage = + rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage + const columns = React.useMemo[]>(() => { return [ + { + key: 'select', + header: ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + onToggleSelectAllPage(checked)} + /> +
+ ), + widthClassName: 'w-[44px]', + align: 'center', + cell: (j) => { + const checked = selectedKeys.has(keyFor(j)) + + return ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + onToggleSelected(j)} + /> +
+ ) + }, + }, { key: 'preview', header: 'Vorschau', @@ -266,11 +321,7 @@ export default function FinishedDownloadsTableView({ className="group relative py-1" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} - onMouseEnter={() => { - if (canHover) setHoverTeaserKey(k) - }} onMouseLeave={() => { - if (canHover) setHoverTeaserKey(null) setScrubActiveIndex(k, undefined) }} > @@ -348,32 +399,9 @@ export default function FinishedDownloadsTableView({ {model}
- {showPostworkBadge ? ( - - - {postworkBadge.label || - (postworkBadge.state === 'running' - ? 'Nacharbeiten laufen…' - : postworkBadge.state === 'queued' - ? 'Warte auf Nacharbeiten…' - : postworkBadge.state === 'done' - ? 'Nacharbeiten abgeschlossen' - : 'Nacharbeiten fehlgeschlagen')} - - - ) : null} + {showPostworkBadge && renderPostworkBadge + ? renderPostworkBadge(postworkBadge) + : null}
@@ -550,6 +578,13 @@ export default function FinishedDownloadsTableView({ parseTags, runtimeSecondsForSort, forcePreviewMuted, + renderPostworkBadge, + rows, + selectedKeys, + onToggleSelected, + onToggleSelectAllPage, + allSelectedOnPage, + someSelectedOnPage, ]) const sortStateToMode = React.useCallback((s: SortState): SortMode => { @@ -607,6 +642,18 @@ export default function FinishedDownloadsTableView({ sort={sort} onSortChange={handleSortChange} onRowClick={onOpenPlayer} + onRowMouseEnter={(j) => { + if (canHover) setHoverTeaserKey(keyFor(j)) + }} + onRowMouseLeave={(j) => { + if (canHover) { + setHoverTeaserKey((prev) => (prev === keyFor(j) ? null : prev)) + } + setScrubActiveIndex(keyFor(j), undefined) + }} + rowAttrs={(j) => ({ + 'data-finished-hover-key': keyFor(j), + })} rowClassName={rowClassName} /> diff --git a/frontend/src/components/ui/LabeledSwitch.tsx b/frontend/src/components/ui/LabeledSwitch.tsx index a372f9c..366ed88 100644 --- a/frontend/src/components/ui/LabeledSwitch.tsx +++ b/frontend/src/components/ui/LabeledSwitch.tsx @@ -56,7 +56,7 @@ export default function LabeledSwitch({ // With left label and description Beispiel return (
- + diff --git a/frontend/src/components/ui/ModelDetails.tsx b/frontend/src/components/ui/ModelDetails.tsx index 095f0d3..8142f10 100644 --- a/frontend/src/components/ui/ModelDetails.tsx +++ b/frontend/src/components/ui/ModelDetails.tsx @@ -2000,8 +2000,6 @@ export default function ModelDetails({ pageSize={DONE_PAGE_SIZE} totalItems={doneTotalCount} onPageChange={(p) => setDonePage(p)} - siblingCount={1} - boundaryCount={1} showSummary={true} prevLabel="Zurück" nextLabel="Weiter" diff --git a/frontend/src/components/ui/Pagination.tsx b/frontend/src/components/ui/Pagination.tsx index 2ae25d1..2e6aecc 100644 --- a/frontend/src/components/ui/Pagination.tsx +++ b/frontend/src/components/ui/Pagination.tsx @@ -1,10 +1,16 @@ +// frontend\src\components\ui\Pagination.tsx + 'use client' import * as React from 'react' import clsx from 'clsx' -import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid' - -type PageItem = number | 'ellipsis' +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronDoubleLeftIcon, + ChevronDoubleRightIcon, + ChevronDownIcon, +} from '@heroicons/react/20/solid' export type PaginationProps = { page: number // 1-based @@ -12,106 +18,27 @@ export type PaginationProps = { totalItems: number onPageChange: (page: number) => void - /** wie viele Seiten links/rechts neben aktueller Seite */ - siblingCount?: number - /** wie viele Seiten am Anfang/Ende immer gezeigt werden */ - boundaryCount?: number - - /** Summary "Showing x to y of z" anzeigen */ showSummary?: boolean - /** Wrapper Klassen */ className?: string - /** Labels (optional) */ ariaLabel?: string prevLabel?: string nextLabel?: string + firstLabel?: string + lastLabel?: string } function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)) } -function range(start: number, end: number): number[] { - const out: number[] = [] - for (let i = start; i <= end; i++) out.push(i) - return out -} - -function getPageItems( - totalPages: number, - current: number, - boundaryCount: number, - siblingCount: number -): PageItem[] { - if (totalPages <= 1) return [1] - - const first = 1 - const last = totalPages - - const startPages = range(first, Math.min(boundaryCount, last)) - const endPages = range(Math.max(last - boundaryCount + 1, boundaryCount + 1), last) - - const siblingsStart = Math.max( - Math.min( - current - siblingCount, - last - boundaryCount - siblingCount * 2 - 1 - ), - boundaryCount + 1 - ) - - const siblingsEnd = Math.min( - Math.max( - current + siblingCount, - boundaryCount + siblingCount * 2 + 2 - ), - last - boundaryCount - ) - - const items: PageItem[] = [] - - // start - items.push(...startPages) - - // left gap - if (siblingsStart > boundaryCount + 1) { - items.push('ellipsis') - } else if (boundaryCount + 1 < last - boundaryCount) { - items.push(boundaryCount + 1) - } - - // siblings - items.push(...range(siblingsStart, siblingsEnd)) - - // right gap - if (siblingsEnd < last - boundaryCount) { - items.push('ellipsis') - } else if (last - boundaryCount > boundaryCount) { - items.push(last - boundaryCount) - } - - // end - items.push(...endPages) - - // dedupe + keep order - const seen = new Set() - return items.filter((x) => { - const k = String(x) - if (seen.has(k)) return false - seen.add(k) - return true - }) -} - function PageButton({ - active, disabled, rounded, onClick, children, title, }: { - active?: boolean disabled?: boolean rounded?: 'l' | 'r' | 'none' onClick?: () => void @@ -128,14 +55,12 @@ function PageButton({ onClick={disabled ? undefined : onClick} title={title} className={clsx( - 'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0', + 'relative inline-flex h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0', roundedCls, - disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer', - active - ? 'z-10 !bg-indigo-600 !text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500' - : 'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:inset-ring-gray-700 dark:hover:bg-white/5' + disabled ? 'opacity-100 cursor-not-allowed' : 'cursor-pointer', + 'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50', + 'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700' )} - aria-current={active ? 'page' : undefined} > {children} @@ -147,15 +72,18 @@ export default function Pagination({ pageSize, totalItems, onPageChange, - siblingCount = 1, - boundaryCount = 1, - showSummary = true, + showSummary = false, className, ariaLabel = 'Pagination', - prevLabel = 'Previous', - nextLabel = 'Next', + prevLabel = 'Vorherige Seite', + nextLabel = 'Nächste Seite', + firstLabel = 'Erste Seite', + lastLabel = 'Letzte Seite', }: PaginationProps) { - const totalPages = Math.max(1, Math.ceil((totalItems || 0) / Math.max(1, pageSize || 1))) + const totalPages = Math.max( + 1, + Math.ceil((totalItems || 0) / Math.max(1, pageSize || 1)) + ) const current = clamp(page || 1, 1, totalPages) if (totalPages <= 1) return null @@ -163,100 +91,97 @@ export default function Pagination({ const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1 const to = Math.min(current * pageSize, totalItems) - const items = getPageItems(totalPages, current, boundaryCount, siblingCount) - const go = (p: number) => onPageChange(clamp(p, 1, totalPages)) return (
- {/* Mobile: nur Previous/Next */} -
- - -
+
+ {showSummary ? ( +

+ Showing {from} to{' '} + {to} of{' '} + {totalItems} results +

+ ) : null} - {/* Desktop: Summary + Zahlen */} -
-
- {showSummary ? ( -

- Showing {from} to{' '} - {to} of{' '} - {totalItems} results -

- ) : null} -
- -
- -
+ go(current + 1)} + disabled={current >= totalPages} + title={nextLabel} + > + {nextLabel} + + + go(totalPages)} + disabled={current >= totalPages} + title={lastLabel} + > + {lastLabel} + +
) -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 29e6013..50e3878 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -142,14 +142,24 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang fading: false, }) + const [regenerateAssetsTask, setRegenerateAssetsTask] = useState({ + id: 'regenerate-assets', + status: 'idle', + title: 'Assets neu generieren', + text: '', + cancellable: false, + fading: false, + }) + useEffect(() => { const running = assetsTask.status === 'running' || + regenerateAssetsTask.status === 'running' || cleanupTask.status === 'running' || cleaning onTaskRunningChange?.(running) - }, [assetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange]) + }, [assetsTask.status, regenerateAssetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange]) const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5) const uiPauseGB = diskStatus?.pauseGB ?? pauseGB @@ -205,6 +215,69 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang } }, []) + useEffect(() => { + let alive = true + + const loadTasksStatus = async () => { + try { + const res = await fetch('/api/tasks/status', { cache: 'no-store' }) + if (!res.ok) return + + const data = await res.json().catch(() => null) + if (!alive || !data) return + + const regen = data.regenerateAssets + if (regen?.running) { + setRegenerateAssetsTask((t) => ({ + ...t, + status: 'running', + title: 'Assets neu generieren', + text: String(regen.text || regen.file || ''), + err: undefined, + fading: false, + })) + } else if (regen?.error) { + setRegenerateAssetsTask((t) => ({ + ...t, + status: 'error', + title: 'Assets neu generieren', + text: String(regen.file || ''), + err: String(regen.error), + fading: false, + })) + } else if (regen?.text || regen?.file) { + setRegenerateAssetsTask((t) => ({ + ...t, + status: 'done', + title: 'Assets neu generieren', + text: String(regen.text || regen.file || ''), + err: undefined, + fading: false, + })) + } else { + setRegenerateAssetsTask((t) => ({ + ...t, + status: 'idle', + title: 'Assets neu generieren', + text: '', + err: undefined, + fading: false, + })) + } + } catch { + // ignore + } + } + + void loadTasksStatus() + const timer = window.setInterval(loadTasksStatus, 1500) + + return () => { + alive = false + window.clearInterval(timer) + } + }, []) + useEffect(() => { let alive = true const load = async () => { @@ -553,7 +626,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang >
{ if (id === 'generate-assets') cancelAssetsTask() }} diff --git a/frontend/src/components/ui/Table.tsx b/frontend/src/components/ui/Table.tsx index 8474f05..53f7eb3 100644 --- a/frontend/src/components/ui/Table.tsx +++ b/frontend/src/components/ui/Table.tsx @@ -35,6 +35,10 @@ export type Column = { sortFn?: (a: T, b: T) => number } +type TableRowAttrs = React.HTMLAttributes & { + [key: `data-${string}`]: string | number | undefined +} + export type TableProps = { columns: Array> rows: T[] @@ -61,6 +65,9 @@ export type TableProps = { onRowClick?: (row: T) => void onRowContextMenu?: (row: T, e: React.MouseEvent) => void + onRowMouseEnter?: (row: T, e: React.MouseEvent) => void + onRowMouseLeave?: (row: T, e: React.MouseEvent) => void + rowAttrs?: (row: T, rowIndex: number) => TableRowAttrs /** * Controlled Sorting: @@ -130,6 +137,9 @@ export default function Table({ rowClassName, onRowClick, onRowContextMenu, + onRowMouseEnter, + onRowMouseLeave, + rowAttrs, sort, onSortChange, defaultSort = null, @@ -318,15 +328,18 @@ export default function Table({ ) : ( sortedRows.map((row, rowIndex) => { const key = getRowKey ? getRowKey(row, rowIndex) : String(rowIndex) + const extraRowAttrs = rowAttrs?.(row, rowIndex) return ( onRowClick?.(row)} onContextMenu={ @@ -335,8 +348,16 @@ export default function Table({ e.preventDefault() onRowContextMenu(row, e) } - : undefined + : extraRowAttrs?.onContextMenu } + onMouseEnter={(e) => { + extraRowAttrs?.onMouseEnter?.(e) + onRowMouseEnter?.(row, e) + }} + onMouseLeave={(e) => { + extraRowAttrs?.onMouseLeave?.(e) + onRowMouseLeave?.(row, e) + }} > {columns.map((col, colIndex) => { const xPad = xPadForColumn(colIndex, columns.length)