This commit is contained in:
Linrador 2026-03-23 13:08:39 +01:00
parent beff6e75ba
commit 82f8b5269d
17 changed files with 2263 additions and 825 deletions

View File

@ -164,10 +164,9 @@ type EnsureAssetsResult struct {
} }
type EnsurePrimaryAssetsResult struct { type EnsurePrimaryAssetsResult struct {
Skipped bool Skipped bool
ThumbGenerated bool ThumbGenerated bool
PreviewGenerated bool MetaOK bool
MetaOK bool
} }
// Public wrappers (kompatibel zu deinem bisherigen API) // Public wrappers (kompatibel zu deinem bisherigen API)
@ -231,7 +230,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
return out, nil return out, nil
} }
_, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id) _, thumbPath, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil { if perr != nil {
return out, perr return out, perr
} }
@ -256,27 +255,17 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
return false 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 // Basis-Meta nur für Dauer/Props sicherstellen
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok out.MetaOK = meta.ok
if thumbBefore && previewBefore { if thumbBefore {
out.Skipped = true out.Skipped = true
progress(1) progress(1)
return out, nil return out, nil
} }
const ( const thumbsW = 1.0
thumbsW = 0.25
previewW = 0.75
)
progress(0) progress(0)
@ -330,60 +319,6 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
progress(thumbsW) 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 _ = sourceInputInvalid
progress(1) progress(1)
return out, nil return out, nil
@ -405,13 +340,51 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
return nil return nil
} }
_, _, _, spritePath, metaPath, perr := assetPathsForID(id) _, _, previewPath, spritePath, metaPath, perr := assetPathsForID(id)
if perr != nil { if perr != nil {
return perr return perr
} }
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) 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 // vorhandene previewClips / spriteMeta aus bestehender meta.json übernehmen
var computedPreviewClips []previewClip var computedPreviewClips []previewClip
var spriteMeta *previewSpriteMeta var spriteMeta *previewSpriteMeta
@ -582,7 +555,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
"enrich", "enrich",
st.State, st.State,
"assets", "assets",
"Ergänze Sprite / AI / Meta…", "Wird verarbeitet…",
st.Position, st.Position,
st.Waiting, st.Waiting,
st.Running, st.Running,
@ -611,7 +584,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
"enrich", "enrich",
"done", "done",
"", "",
"Sprite / AI / Meta fertig", "Fertig",
0, 0,
0, 0,
0, 0,
@ -631,7 +604,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
"enrich", "enrich",
st.State, st.State,
"assets", "assets",
"Warte auf Sprite / AI / Meta…", "Wartet",
st.Position, st.Position,
st.Waiting, st.Waiting,
st.Running, st.Running,

View File

@ -27,6 +27,7 @@ type PostWorkQueue struct {
// ✅ für UI: Warteschlange + Running-Keys tracken // ✅ für UI: Warteschlange + Running-Keys tracken
waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind
runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten) runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten)
cancelByKey map[string]context.CancelFunc
} }
func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue { func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
@ -45,6 +46,7 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
waitingKeys: make([]string, 0, queueSize), waitingKeys: make([]string, 0, queueSize),
runningKeys: make(map[string]struct{}), 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) { func (pq *PostWorkQueue) removeWaitingKeyLocked(key string) {
for i, k := range pq.waitingKeys { for i, k := range pq.waitingKeys {
if k == key { if k == key {
@ -171,7 +190,18 @@ func (pq *PostWorkQueue) workerLoop(id int) {
// 3) Optional: Task timeout (gegen hängende ffmpeg) // 3) Optional: Task timeout (gegen hängende ffmpeg)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) 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 // 4) Run
if task.Run != nil { if task.Run != nil {
@ -291,9 +321,9 @@ func startEnrichStatusRefresher() {
continue continue
} }
label := "Warte auf Sprite / AI / Meta…" label := "Wartet"
if st.State == "running" { if st.State == "running" {
label = "Ergänze Sprite / AI / Meta…" label = "Wird verarbeitet…"
} }
publishFinishedPostworkStateForJob( publishFinishedPostworkStateForJob(

View File

@ -90,6 +90,10 @@ type undoDeleteToken struct {
File string `json:"file"` // original basename, z.B. "HOT xyz.mp4" File string `json:"file"` // original basename, z.B. "HOT xyz.mp4"
} }
type bulkFilesRequest struct {
Files []string `json:"files"`
}
func encodeUndoDeleteToken(t undoDeleteToken) (string, error) { func encodeUndoDeleteToken(t undoDeleteToken) (string, error) {
b, err := json.Marshal(t) b, err := json.Marshal(t)
if err != nil { 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) ---------------- // ---------------- Preview sprite truth (shared) ----------------
type previewSpriteMetaFileInfo struct { type previewSpriteMetaFileInfo struct {
@ -1763,6 +2201,62 @@ func renameWithRetryAggressive(src, dst string) error {
return lastErr 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) { func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete { if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed) http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
@ -1779,6 +2273,9 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {
@ -2046,6 +2543,9 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {
@ -2116,6 +2616,9 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {
@ -2222,6 +2725,9 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
return return
} }
// optional, aber sinnvoll
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {

View File

@ -63,6 +63,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/record/unkeep", recordUnkeepVideo) api.HandleFunc("/api/record/unkeep", recordUnkeepVideo)
api.HandleFunc("/api/record/restore", recordRestoreVideo) api.HandleFunc("/api/record/restore", recordRestoreVideo)
api.HandleFunc("/api/record/prepare-split", recordPrepareSplit) 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/online", chaturbateOnlineHandler)
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)

View File

@ -322,7 +322,7 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskFinishedPostworkState( publishAssetsTaskFinishedPostworkState(
it.name, it.name,
"running", "running",
"Erstelle Vorschau / Thumbnails…", "Erstelle Teaser / Vorschau / Thumbnails…",
) )
// ID aus Dateiname // ID aus Dateiname
@ -397,7 +397,7 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskFinishedPostworkState( publishAssetsTaskFinishedPostworkState(
it.name, it.name,
"done", "done",
"Vorschau / Thumbnails fertig", "Teaser / Vorschau / Thumbnails fertig",
) )
} }

View File

@ -15,17 +15,68 @@ type CleanupTaskState struct {
Error string `json:"error,omitempty"` 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 { type TasksStatusResponse struct {
GenerateAssets AssetsTaskState `json:"generateAssets"` GenerateAssets AssetsTaskState `json:"generateAssets"`
Cleanup CleanupTaskState `json:"cleanup"` RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"`
AnyRunning bool `json:"anyRunning"` Cleanup CleanupTaskState `json:"cleanup"`
AnyRunning bool `json:"anyRunning"`
} }
var ( var (
cleanupTaskMu sync.Mutex cleanupTaskMu sync.Mutex
cleanupTaskState CleanupTaskState 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 { func snapshotCleanupTaskState() CleanupTaskState {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
st := cleanupTaskState st := cleanupTaskState
@ -70,12 +121,14 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
} }
assets := getGenerateAssetsTaskStatus() assets := getGenerateAssetsTaskStatus()
regenerate := snapshotRegenerateAssetsTaskState()
cleanup := snapshotCleanupTaskState() cleanup := snapshotCleanupTaskState()
resp := TasksStatusResponse{ resp := TasksStatusResponse{
GenerateAssets: assets, GenerateAssets: assets,
Cleanup: cleanup, RegenerateAssets: regenerate,
AnyRunning: assets.Running || cleanup.Running, Cleanup: cleanup,
AnyRunning: assets.Running || regenerate.Running || cleanup.Running,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@ -3070,7 +3070,7 @@ export default function App() {
return res return res
} catch (e: any) { } catch (e: any) {
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e)) notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
return return
} }
}, },

View File

@ -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<HTMLInputElement | null>(null)
React.useEffect(() => {
if (ref.current) {
ref.current.indeterminate = indeterminate && !checked
}
}, [indeterminate, checked])
return (
<div className={['group grid size-4 grid-cols-1', className].filter(Boolean).join(' ')}>
<input
ref={ref}
id={id}
name={name}
type="checkbox"
checked={checked}
disabled={disabled}
aria-label={ariaLabel}
onChange={(e) => 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"
/>
<svg
fill="none"
viewBox="0 0 14 14"
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
>
<path
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-checked:opacity-100"
/>
<path
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-indeterminate:opacity-100"
/>
</svg>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ import {
shouldAnimateTeaser, shouldAnimateTeaser,
shouldEnableTeaserAudio, shouldEnableTeaserAudio,
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox'
type InlinePlayState = { key: string; nonce: number } | null type InlinePlayState = { key: string; nonce: number } | null
@ -23,6 +24,9 @@ type Props = {
rows: RecordJob[] rows: RecordJob[]
isLoading?: boolean isLoading?: boolean
isSmall: boolean isSmall: boolean
selectedKeys: Set<string>
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
@ -98,16 +102,23 @@ type Props = {
ts: number 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 forcePreviewMuted?: boolean
} }
type PostworkBadgeTone = 'idle' | 'running' | 'done' | 'error'
type PostworkBadge = {
label: string
tone: PostworkBadgeTone
}
const parseTags = (raw?: string): string[] => { const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim() const s = String(raw ?? '').trim()
if (!s) return [] if (!s) return []
@ -179,189 +190,6 @@ function chooseSpriteGrid(count: number): [number, number] {
return [bestCols, bestRows] 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({ function CardBlurWrapper({
blurred, blurred,
animateUnblurOnMount, animateUnblurOnMount,
@ -545,6 +373,8 @@ function MobileLoadingStack({
export default function FinishedDownloadsCardsView({ export default function FinishedDownloadsCardsView({
rows, rows,
isSmall, isSmall,
selectedKeys,
onToggleSelected,
isLoading, isLoading,
blurPreviews, blurPreviews,
durations, durations,
@ -590,9 +420,13 @@ export default function FinishedDownloadsCardsView({
onSplit, onSplit,
onAddToDownloads, onAddToDownloads,
postworkByFile, postworkByFile,
renderPostworkBadge,
forcePreviewMuted, forcePreviewMuted,
}: Props) { }: Props) {
const hasAnySelection = selectedKeys.size > 0
const footerTapRef = React.useRef<Record<string, boolean>>({})
const parseMeta = React.useCallback((j: RecordJob): any | null => { const parseMeta = React.useCallback((j: RecordJob): any | null => {
const raw: any = (j as any)?.meta const raw: any = (j as any)?.meta
if (!raw) return null if (!raw) return null
@ -757,6 +591,7 @@ export default function FinishedDownloadsCardsView({
}) })
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const checked = selectedKeys.has(k)
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
@ -892,30 +727,6 @@ export default function FinishedDownloadsCardsView({
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching) 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) const tags = parseTags(flags?.tags)
@ -955,8 +766,37 @@ export default function FinishedDownloadsCardsView({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
data-finished-hover-key={k}
className={shellCls} className={shellCls}
onClick={isSmall ? undefined : () => 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) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
@ -968,6 +808,27 @@ export default function FinishedDownloadsCardsView({
blurred={opts?.blur} blurred={opts?.blur}
animateUnblurOnMount={opts?.animateUnblurOnMount} animateUnblurOnMount={opts?.animateUnblurOnMount}
> >
{!isSmall ? (
<div
className={[
'absolute left-3 top-3 z-30 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
) : null}
{/* Card shell keeps backgrounds consistent */} {/* Card shell keeps backgrounds consistent */}
<Card noBodyPadding className="overflow-hidden bg-transparent"> <Card noBodyPadding className="overflow-hidden bg-transparent">
{/* Preview */} {/* 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" className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={ onMouseEnter={
isSmall || opts?.disablePreviewHover isSmall
? undefined ? undefined
: () => { : () => {
setHoveredThumbKey(k) setHoveredThumbKey(k)
onHoverPreviewKeyChange?.(k)
} }
} }
onMouseLeave={() => { onMouseLeave={() => {
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
setHoveredThumbKey((prev) => (prev === k ? null : prev)) setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubActiveIndex(k) clearScrubActiveIndex(k)
setScrubHovering(k, false) setScrubHovering(k, false)
@ -996,6 +855,12 @@ export default function FinishedDownloadsCardsView({
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (hasAnySelection) {
onToggleSelected(j)
return
}
if (isSmall || opts?.disableInline) return if (isSmall || opts?.disableInline) return
startInline(k) startInline(k)
}} }}
@ -1144,24 +1009,28 @@ export default function FinishedDownloadsCardsView({
</div> </div>
{/* Footer / Meta (wie Gallery strukturiert) */} {/* Footer / Meta (wie Gallery strukturiert) */}
<div className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"> <div
className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
onPointerDownCapture={() => {
footerTapRef.current[k] = true
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openPlayer(j)
}}
>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"> <div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model} {model}
</div> </div>
{postworkStatus ? ( {renderPostworkBadge && livePostwork ? (
<span <div className="shrink-0">
className={[ {renderPostworkBadge(livePostwork)}
'shrink-0 inline-flex max-w-[220px] items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1', </div>
postworkBadgeClass(postworkStatus.tone),
].join(' ')}
title={postworkStatus.label}
>
<span className="truncate">{postworkStatus.label}</span>
</span>
) : null} ) : null}
</div> </div>
@ -1465,6 +1334,17 @@ export default function FinishedDownloadsCardsView({
onTap={() => { onTap={() => {
if (!mobileTopTapEnabled) return if (!mobileTopTapEnabled) return
if (footerTapRef.current[k]) {
delete footerTapRef.current[k]
openPlayer(j)
return
}
if (hasAnySelection) {
onToggleSelected(j)
return
}
startInline(k) startInline(k)
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) { if (!tryAutoplayInline(inlineDomId)) {

View File

@ -4,11 +4,6 @@
import * as React from 'react' import * as React from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import {
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
@ -20,6 +15,7 @@ import {
shouldEnableTeaserAudio, shouldEnableTeaserAudio,
shouldObserveTeasers, shouldObserveTeasers,
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox'
type ModelFlags = { type ModelFlags = {
favorite?: boolean favorite?: boolean
@ -52,11 +48,15 @@ type FinishedPostworkBadge = {
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
selectedKeys: Set<string>
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
isLoading?: boolean isLoading?: boolean
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkBadge> postworkByFile: Record<string, FinishedPostworkBadge>
teaserState: FinishedDownloadsTeaserState teaserState: FinishedDownloadsTeaserState
renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
@ -169,6 +169,9 @@ export default function FinishedDownloadsGalleryView({
durations, durations,
postworkByFile, postworkByFile,
teaserState, teaserState,
renderPostworkBadge,
selectedKeys,
onToggleSelected,
handleDuration, handleDuration,
handleScrubberClickIndex, handleScrubberClickIndex,
@ -204,8 +207,7 @@ export default function FinishedDownloadsGalleryView({
}: Props) { }: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const observeTeasers = shouldObserveTeasers(teaserState.mode) const observeTeasers = shouldObserveTeasers(teaserState.mode)
const hasAnySelection = selectedKeys.size > 0
// ✅ Wrapper: bei still unregistrieren / nicht registrieren // ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback( const registerTeaserHostIfNeeded = React.useCallback(
@ -290,6 +292,7 @@ export default function FinishedDownloadsGalleryView({
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{rows.map((j) => { {rows.map((j) => {
const k = keyFor(j) const k = keyFor(j)
const checked = selectedKeys.has(k)
const isPreviewActive = const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k teaserState.activeKey === k || teaserState.hoverKey === k
@ -479,12 +482,48 @@ export default function FinishedDownloadsGalleryView({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
data-finished-hover-key={k}
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500" className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
onClick={() => 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) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}} }}
> >
<div
className={[
'absolute left-3 top-3 z-30 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
<div <div
className={[ className={[
'relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10', 'relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
@ -510,11 +549,9 @@ export default function FinishedDownloadsGalleryView({
ref={registerTeaserHostIfNeeded(k)} ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => { onMouseEnter={() => {
setHoveredThumbKey(k) setHoveredThumbKey(k)
onHoverPreviewKeyChange?.(k)
}} }}
onMouseLeave={() => { onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev)) setHoveredThumbKey((prev) => (prev === k ? null : prev))
onHoverPreviewKeyChange?.(null)
clearScrubIndex(k) clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}} }}
@ -626,9 +663,46 @@ export default function FinishedDownloadsGalleryView({
</div> </div>
{/* Footer / Meta */} {/* Footer / Meta */}
<div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"> <div
{/* ✅ stashapp-like: Dateiname zuerst */} className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onOpenPlayer(j)
}}
>
{/* Model oben + Badge direkt dahinter */}
<div className="min-w-0"> <div className="min-w-0">
<div className="mt-0.5 flex items-start justify-between gap-2 min-w-0">
<span
className={[
'block min-w-0 flex-1 truncate text-sm font-semibold text-gray-900 dark:text-white',
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
]
.filter(Boolean)
.join(' ')}
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
onMouseEnter={(e) => {
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 || '—'}
</span>
{showPostworkBadge && renderPostworkBadge ? (
<div className="shrink-0">
{renderPostworkBadge(postworkBadge)}
</div>
) : null}
</div>
<div className="mt-0.5 flex items-start gap-2 min-w-0"> <div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? ( {isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300"> <span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
@ -637,7 +711,7 @@ export default function FinishedDownloadsGalleryView({
) : null} ) : null}
<span <span
className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white" className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"
title={stripHotPrefix(file) || '—'} title={stripHotPrefix(file) || '—'}
> >
{stripHotPrefix(file) || '—'} {stripHotPrefix(file) || '—'}
@ -645,123 +719,54 @@ export default function FinishedDownloadsGalleryView({
</div> </div>
</div> </div>
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */} {/* Actions (wie CardView: im Footer statt im Video) */}
<div className="mt-1 flex items-center justify-between gap-2"> <div
<div className="min-w-0 flex items-center gap-2"> className="mt-2 shrink-0"
<span onClick={(e) => e.stopPropagation()}
className={[ onMouseDown={(e) => e.stopPropagation()}
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400', >
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '', <div className="w-full">
] <div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
.filter(Boolean) <RecordJobActions
.join(' ')} job={jobForDetails(j)}
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model} variant="table"
onMouseEnter={(e) => { busy={busy}
e.stopPropagation() collapseToMenu
if (modelImageSrc) setHoveredModelPreviewKey(k) compact={true}
}} isHot={isHot}
onMouseLeave={(e) => { isFavorite={isFav}
e.stopPropagation() isLiked={isLiked}
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) isWatching={isWatching}
}} onToggleWatch={onToggleWatch}
onMouseDown={(e) => e.stopPropagation()} onToggleFavorite={onToggleFavorite}
onClick={(e) => e.stopPropagation()} onToggleLike={onToggleLike}
> onToggleHot={onToggleHot}
{model || '—'} onKeep={keepVideo}
</span> onDelete={deleteVideo}
onSplit={onSplit}
{showPostworkBadge ? ( onAddToDownloads={onAddToDownloads}
<span order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className={[ className="w-full gap-1.5"
'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1', />
postworkBadge.state === 'running'
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
: postworkBadge.state === 'queued'
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
: postworkBadge.state === 'done'
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
].join(' ')}
title={
postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')
}
>
<span className="truncate">
{postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen…'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten…'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')}
</span>
</span>
) : null}
</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div> </div>
</div> </div>
</div>
{/* Actions (wie CardView: im Footer statt im Video) */} {/* Tags */}
<div <div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
className="mt-2 shrink-0" <TagOverflowRow
onClick={(e) => e.stopPropagation()} rowKey={k}
onMouseDown={(e) => e.stopPropagation()} tags={tags}
> activeTagSet={activeTagSet}
<div className="w-full"> lower={lower}
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> onToggleTagFilter={onToggleTagFilter}
<RecordJobActions />
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={true}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) </div>
)
})} })}
</div> </div>

View File

@ -15,6 +15,7 @@ import {
shouldAnimateTeaser, shouldAnimateTeaser,
shouldEnableTeaserAudio, shouldEnableTeaserAudio,
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox'
type SortMode = type SortMode =
| 'completed_desc' | 'completed_desc'
@ -43,6 +44,9 @@ type FinishedPostworkBadge = {
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
isLoading?: boolean isLoading?: boolean
selectedKeys: Set<string>
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
// helpers // helpers
keyFor: (j: RecordJob) => string keyFor: (j: RecordJob) => string
@ -56,6 +60,7 @@ type Props = {
durations: Record<string, number> durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkBadge> postworkByFile: Record<string, FinishedPostworkBadge>
jobForDetails: (job: RecordJob) => RecordJob jobForDetails: (job: RecordJob) => RecordJob
renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode
// teaser/preview // teaser/preview
canHover: boolean canHover: boolean
@ -98,6 +103,9 @@ type Props = {
export default function FinishedDownloadsTableView({ export default function FinishedDownloadsTableView({
rows, rows,
isLoading, isLoading,
selectedKeys,
onToggleSelected,
onToggleSelectAllPage,
keyFor, keyFor,
baseName, baseName,
lower, lower,
@ -109,6 +117,7 @@ export default function FinishedDownloadsTableView({
durations, durations,
postworkByFile, postworkByFile,
jobForDetails, jobForDetails,
renderPostworkBadge,
canHover, canHover,
teaserState, 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<Column<RecordJob>[]>(() => { const columns = React.useMemo<Column<RecordJob>[]>(() => {
return [ return [
{
key: 'select',
header: (
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={allSelectedOnPage}
indeterminate={someSelectedOnPage}
ariaLabel={
allSelectedOnPage
? 'Auswahl aufheben'
: 'Alle Downloads auf dieser Seite auswählen'
}
onChange={(checked) => onToggleSelectAllPage(checked)}
/>
</div>
),
widthClassName: 'w-[44px]',
align: 'center',
cell: (j) => {
const checked = selectedKeys.has(keyFor(j))
return (
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
)
},
},
{ {
key: 'preview', key: 'preview',
header: 'Vorschau', header: 'Vorschau',
@ -266,11 +321,7 @@ export default function FinishedDownloadsTableView({
className="group relative py-1" className="group relative py-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => {
if (canHover) setHoverTeaserKey(k)
}}
onMouseLeave={() => { onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null)
setScrubActiveIndex(k, undefined) setScrubActiveIndex(k, undefined)
}} }}
> >
@ -348,32 +399,9 @@ export default function FinishedDownloadsTableView({
{model} {model}
</div> </div>
{showPostworkBadge ? ( {showPostworkBadge && renderPostworkBadge
<span ? renderPostworkBadge(postworkBadge)
className={[ : null}
'shrink-0 inline-flex max-w-[220px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkBadge.state === 'running'
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
: postworkBadge.state === 'queued'
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
: postworkBadge.state === 'done'
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
].join(' ')}
title={postworkBadge.label || 'Nacharbeiten laufen'}
>
<span className="truncate">
{postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen…'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten…'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')}
</span>
</span>
) : null}
</div> </div>
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400">
@ -550,6 +578,13 @@ export default function FinishedDownloadsTableView({
parseTags, parseTags,
runtimeSecondsForSort, runtimeSecondsForSort,
forcePreviewMuted, forcePreviewMuted,
renderPostworkBadge,
rows,
selectedKeys,
onToggleSelected,
onToggleSelectAllPage,
allSelectedOnPage,
someSelectedOnPage,
]) ])
const sortStateToMode = React.useCallback((s: SortState): SortMode => { const sortStateToMode = React.useCallback((s: SortState): SortMode => {
@ -607,6 +642,18 @@ export default function FinishedDownloadsTableView({
sort={sort} sort={sort}
onSortChange={handleSortChange} onSortChange={handleSortChange}
onRowClick={onOpenPlayer} 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} rowClassName={rowClassName}
/> />

View File

@ -56,7 +56,7 @@ export default function LabeledSwitch({
// With left label and description Beispiel // With left label and description Beispiel
return ( return (
<div className={clsx('flex items-center justify-between', className)}> <div className={clsx('flex items-center justify-between', className)}>
<span className="flex grow flex-col"> <span className="flex grow flex-col pr-2">
<label id={labelId} htmlFor={switchId} className="text-sm/6 font-medium text-gray-900 dark:text-white"> <label id={labelId} htmlFor={switchId} className="text-sm/6 font-medium text-gray-900 dark:text-white">
{label} {label}
</label> </label>

View File

@ -2000,8 +2000,6 @@ export default function ModelDetails({
pageSize={DONE_PAGE_SIZE} pageSize={DONE_PAGE_SIZE}
totalItems={doneTotalCount} totalItems={doneTotalCount}
onPageChange={(p) => setDonePage(p)} onPageChange={(p) => setDonePage(p)}
siblingCount={1}
boundaryCount={1}
showSummary={true} showSummary={true}
prevLabel="Zurück" prevLabel="Zurück"
nextLabel="Weiter" nextLabel="Weiter"

View File

@ -1,10 +1,16 @@
// frontend\src\components\ui\Pagination.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid' import {
ChevronLeftIcon,
type PageItem = number | 'ellipsis' ChevronRightIcon,
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronDownIcon,
} from '@heroicons/react/20/solid'
export type PaginationProps = { export type PaginationProps = {
page: number // 1-based page: number // 1-based
@ -12,106 +18,27 @@ export type PaginationProps = {
totalItems: number totalItems: number
onPageChange: (page: number) => void 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 showSummary?: boolean
/** Wrapper Klassen */
className?: string className?: string
/** Labels (optional) */
ariaLabel?: string ariaLabel?: string
prevLabel?: string prevLabel?: string
nextLabel?: string nextLabel?: string
firstLabel?: string
lastLabel?: string
} }
function clamp(n: number, min: number, max: number) { function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n)) 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<string>()
return items.filter((x) => {
const k = String(x)
if (seen.has(k)) return false
seen.add(k)
return true
})
}
function PageButton({ function PageButton({
active,
disabled, disabled,
rounded, rounded,
onClick, onClick,
children, children,
title, title,
}: { }: {
active?: boolean
disabled?: boolean disabled?: boolean
rounded?: 'l' | 'r' | 'none' rounded?: 'l' | 'r' | 'none'
onClick?: () => void onClick?: () => void
@ -128,14 +55,12 @@ function PageButton({
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
title={title} title={title}
className={clsx( 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, roundedCls,
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer', disabled ? 'opacity-100 cursor-not-allowed' : 'cursor-pointer',
active 'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50',
? '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' 'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700'
: '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'
)} )}
aria-current={active ? 'page' : undefined}
> >
{children} {children}
</button> </button>
@ -147,15 +72,18 @@ export default function Pagination({
pageSize, pageSize,
totalItems, totalItems,
onPageChange, onPageChange,
siblingCount = 1, showSummary = false,
boundaryCount = 1,
showSummary = true,
className, className,
ariaLabel = 'Pagination', ariaLabel = 'Pagination',
prevLabel = 'Previous', prevLabel = 'Vorherige Seite',
nextLabel = 'Next', nextLabel = 'Nächste Seite',
firstLabel = 'Erste Seite',
lastLabel = 'Letzte Seite',
}: PaginationProps) { }: 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) const current = clamp(page || 1, 1, totalPages)
if (totalPages <= 1) return null if (totalPages <= 1) return null
@ -163,100 +91,97 @@ export default function Pagination({
const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1 const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1
const to = Math.min(current * pageSize, totalItems) const to = Math.min(current * pageSize, totalItems)
const items = getPageItems(totalPages, current, boundaryCount, siblingCount)
const go = (p: number) => onPageChange(clamp(p, 1, totalPages)) const go = (p: number) => onPageChange(clamp(p, 1, totalPages))
return ( return (
<div <div
className={clsx( className={clsx(
'flex items-center justify-between bg-transparent dark:border-white/10', 'flex items-center justify-center bg-transparent dark:border-white/10',
className className
)} )}
> >
{/* Mobile: nur Previous/Next */} <div className="flex flex-col items-center gap-2">
<div className="flex flex-1 justify-between sm:hidden"> {showSummary ? (
<button <p className="text-sm text-gray-700 dark:text-gray-300 px-2">
type="button" Showing <span className="font-medium">{from}</span> to{' '}
onClick={() => go(current - 1)} <span className="font-medium">{to}</span> of{' '}
disabled={current <= 1} <span className="font-medium">{totalItems}</span> results
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10" </p>
> ) : null}
{prevLabel}
</button>
<button
type="button"
onClick={() => go(current + 1)}
disabled={current >= totalPages}
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
>
{nextLabel}
</button>
</div>
{/* Desktop: Summary + Zahlen */} <nav
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between"> aria-label={ariaLabel}
<div> className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
{showSummary ? ( >
<p className="text-sm text-gray-700 dark:text-gray-300 px-2"> <PageButton
Showing <span className="font-medium">{from}</span> to{' '} rounded="l"
<span className="font-medium">{to}</span> of{' '} onClick={() => go(1)}
<span className="font-medium">{totalItems}</span> results disabled={current <= 1}
</p> title={firstLabel}
) : null}
</div>
<div>
<nav
aria-label={ariaLabel}
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
> >
<button <span className="sr-only">{firstLabel}</span>
type="button" <ChevronDoubleLeftIcon aria-hidden="true" className="size-5" />
onClick={() => go(current - 1)} </PageButton>
disabled={current <= 1}
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5"
>
<span className="sr-only">{prevLabel}</span>
<ChevronLeftIcon aria-hidden="true" className="size-5" />
</button>
{items.map((it, idx) => { <PageButton
if (it === 'ellipsis') { rounded="none"
onClick={() => go(current - 1)}
disabled={current <= 1}
title={prevLabel}
>
<span className="sr-only">{prevLabel}</span>
<ChevronLeftIcon aria-hidden="true" className="size-5" />
</PageButton>
<div className="relative">
<select
value={current}
onChange={(e) => go(Number(e.target.value))}
aria-label="Seite auswählen"
className={clsx(
'relative h-10 appearance-none bg-white pl-4 pr-10 text-sm font-semibold',
'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0',
'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700',
'min-w-[112px] cursor-pointer'
)}
>
{Array.from({ length: totalPages }, (_, i) => {
const p = i + 1
return ( return (
<span <option key={p} value={p} className="text-black dark:text-white">
key={`e-${idx}`} {p} von {totalPages}
className="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 inset-ring inset-ring-gray-300 dark:text-gray-400 dark:inset-ring-gray-700" </option>
>
</span>
) )
} })}
</select>
return ( <ChevronDownIcon
<PageButton aria-hidden="true"
key={it} className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-gray-500 dark:text-gray-400"
active={it === current} />
onClick={() => go(it)} </div>
rounded="none"
>
{it}
</PageButton>
)
})}
<button <PageButton
type="button" rounded="none"
onClick={() => go(current + 1)} onClick={() => go(current + 1)}
disabled={current >= totalPages} disabled={current >= totalPages}
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5" title={nextLabel}
> >
<span className="sr-only">{nextLabel}</span> <span className="sr-only">{nextLabel}</span>
<ChevronRightIcon aria-hidden="true" className="size-5" /> <ChevronRightIcon aria-hidden="true" className="size-5" />
</button> </PageButton>
</nav>
</div> <PageButton
rounded="r"
onClick={() => go(totalPages)}
disabled={current >= totalPages}
title={lastLabel}
>
<span className="sr-only">{lastLabel}</span>
<ChevronDoubleRightIcon aria-hidden="true" className="size-5" />
</PageButton>
</nav>
</div> </div>
</div> </div>
) )
} }

View File

@ -142,14 +142,24 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
fading: false, fading: false,
}) })
const [regenerateAssetsTask, setRegenerateAssetsTask] = useState<TaskItem>({
id: 'regenerate-assets',
status: 'idle',
title: 'Assets neu generieren',
text: '',
cancellable: false,
fading: false,
})
useEffect(() => { useEffect(() => {
const running = const running =
assetsTask.status === 'running' || assetsTask.status === 'running' ||
regenerateAssetsTask.status === 'running' ||
cleanupTask.status === 'running' || cleanupTask.status === 'running' ||
cleaning cleaning
onTaskRunningChange?.(running) 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 pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB 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(() => { useEffect(() => {
let alive = true let alive = true
const load = async () => { const load = async () => {
@ -553,7 +626,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
> >
<div className="space-y-4"> <div className="space-y-4">
<TaskList <TaskList
tasks={[assetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende” tasks={[assetsTask, regenerateAssetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
onCancel={(id: string) => { onCancel={(id: string) => {
if (id === 'generate-assets') cancelAssetsTask() if (id === 'generate-assets') cancelAssetsTask()
}} }}

View File

@ -35,6 +35,10 @@ export type Column<T> = {
sortFn?: (a: T, b: T) => number sortFn?: (a: T, b: T) => number
} }
type TableRowAttrs = React.HTMLAttributes<HTMLTableRowElement> & {
[key: `data-${string}`]: string | number | undefined
}
export type TableProps<T> = { export type TableProps<T> = {
columns: Array<Column<T>> columns: Array<Column<T>>
rows: T[] rows: T[]
@ -61,6 +65,9 @@ export type TableProps<T> = {
onRowClick?: (row: T) => void onRowClick?: (row: T) => void
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
onRowMouseEnter?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
onRowMouseLeave?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
rowAttrs?: (row: T, rowIndex: number) => TableRowAttrs
/** /**
* Controlled Sorting: * Controlled Sorting:
@ -130,6 +137,9 @@ export default function Table<T>({
rowClassName, rowClassName,
onRowClick, onRowClick,
onRowContextMenu, onRowContextMenu,
onRowMouseEnter,
onRowMouseLeave,
rowAttrs,
sort, sort,
onSortChange, onSortChange,
defaultSort = null, defaultSort = null,
@ -318,15 +328,18 @@ export default function Table<T>({
) : ( ) : (
sortedRows.map((row, rowIndex) => { sortedRows.map((row, rowIndex) => {
const key = getRowKey ? getRowKey(row, rowIndex) : String(rowIndex) const key = getRowKey ? getRowKey(row, rowIndex) : String(rowIndex)
const extraRowAttrs = rowAttrs?.(row, rowIndex)
return ( return (
<tr <tr
key={key} key={key}
{...extraRowAttrs}
className={cn( className={cn(
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50', striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
onRowClick && 'cursor-pointer', onRowClick && 'cursor-pointer',
onRowClick && 'hover:bg-gray-50 dark:hover:bg-white/5 transition-colors', onRowClick && 'hover:bg-gray-50 dark:hover:bg-white/5 transition-colors',
rowClassName?.(row, rowIndex) rowClassName?.(row, rowIndex),
extraRowAttrs?.className
)} )}
onClick={() => onRowClick?.(row)} onClick={() => onRowClick?.(row)}
onContextMenu={ onContextMenu={
@ -335,8 +348,16 @@ export default function Table<T>({
e.preventDefault() e.preventDefault()
onRowContextMenu(row, e) 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) => { {columns.map((col, colIndex) => {
const xPad = xPadForColumn(colIndex, columns.length) const xPad = xPadForColumn(colIndex, columns.length)