updates
This commit is contained in:
parent
beff6e75ba
commit
82f8b5269d
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
71
frontend/src/components/ui/Checkbox.tsx
Normal file
71
frontend/src/components/ui/Checkbox.tsx
Normal 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
@ -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)) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user