updates
This commit is contained in:
parent
beff6e75ba
commit
82f8b5269d
@ -166,7 +166,6 @@ type EnsureAssetsResult struct {
|
||||
type EnsurePrimaryAssetsResult struct {
|
||||
Skipped bool
|
||||
ThumbGenerated bool
|
||||
PreviewGenerated bool
|
||||
MetaOK bool
|
||||
}
|
||||
|
||||
@ -231,7 +230,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
_, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id)
|
||||
_, thumbPath, _, _, metaPath, perr := assetPathsForID(id)
|
||||
if perr != nil {
|
||||
return out, perr
|
||||
}
|
||||
@ -256,27 +255,17 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
||||
return false
|
||||
}()
|
||||
|
||||
previewBefore := func() bool {
|
||||
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
// Basis-Meta nur für Dauer/Props sicherstellen
|
||||
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
||||
out.MetaOK = meta.ok
|
||||
|
||||
if thumbBefore && previewBefore {
|
||||
if thumbBefore {
|
||||
out.Skipped = true
|
||||
progress(1)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const (
|
||||
thumbsW = 0.25
|
||||
previewW = 0.75
|
||||
)
|
||||
const thumbsW = 1.0
|
||||
|
||||
progress(0)
|
||||
|
||||
@ -330,60 +319,6 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
||||
progress(thumbsW)
|
||||
}
|
||||
|
||||
// ----------------
|
||||
// Preview.mp4
|
||||
// ----------------
|
||||
const (
|
||||
previewClipLenSec = 0.75
|
||||
previewMaxClips = 12
|
||||
)
|
||||
|
||||
if previewBefore {
|
||||
progress(1)
|
||||
} else {
|
||||
func() {
|
||||
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
progress(thumbsW + 0.02)
|
||||
|
||||
if err := genSem.Acquire(genCtx); err != nil {
|
||||
return
|
||||
}
|
||||
defer genSem.Release()
|
||||
|
||||
progress(thumbsW + 0.05)
|
||||
|
||||
if err := generateTeaserClipsMP4WithProgress(
|
||||
genCtx,
|
||||
videoPath,
|
||||
previewPath,
|
||||
previewClipLenSec,
|
||||
previewMaxClips,
|
||||
func(r float64) {
|
||||
if r < 0 {
|
||||
r = 0
|
||||
}
|
||||
if r > 1 {
|
||||
r = 1
|
||||
}
|
||||
progress(thumbsW + r*previewW)
|
||||
},
|
||||
); err != nil {
|
||||
if isFFmpegInputInvalidError(err) {
|
||||
sourceInputInvalid = true
|
||||
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("⚠️ preview clips:", err)
|
||||
return
|
||||
}
|
||||
|
||||
out.PreviewGenerated = true
|
||||
}()
|
||||
}
|
||||
|
||||
_ = sourceInputInvalid
|
||||
progress(1)
|
||||
return out, nil
|
||||
@ -405,13 +340,51 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _, _, spritePath, metaPath, perr := assetPathsForID(id)
|
||||
_, _, previewPath, spritePath, metaPath, perr := assetPathsForID(id)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
|
||||
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
||||
|
||||
// Preview erzeugen, wenn noch nicht vorhanden
|
||||
previewExists := false
|
||||
if pfi, err := os.Stat(previewPath); err == nil && pfi != nil && !pfi.IsDir() && pfi.Size() > 0 {
|
||||
previewExists = true
|
||||
}
|
||||
|
||||
if !previewExists {
|
||||
const (
|
||||
previewClipLenSec = 0.75
|
||||
previewMaxClips = 12
|
||||
)
|
||||
|
||||
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := genSem.Acquire(genCtx); err == nil {
|
||||
func() {
|
||||
defer genSem.Release()
|
||||
|
||||
if err := generateTeaserClipsMP4WithProgress(
|
||||
genCtx,
|
||||
videoPath,
|
||||
previewPath,
|
||||
previewClipLenSec,
|
||||
previewMaxClips,
|
||||
nil,
|
||||
); err != nil {
|
||||
if isFFmpegInputInvalidError(err) {
|
||||
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
|
||||
return
|
||||
}
|
||||
fmt.Printf("⚠️ deferred preview failed for %s: %v\n", videoPath, err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// vorhandene previewClips / spriteMeta aus bestehender meta.json übernehmen
|
||||
var computedPreviewClips []previewClip
|
||||
var spriteMeta *previewSpriteMeta
|
||||
@ -582,7 +555,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
|
||||
"enrich",
|
||||
st.State,
|
||||
"assets",
|
||||
"Ergänze Sprite / AI / Meta…",
|
||||
"Wird verarbeitet…",
|
||||
st.Position,
|
||||
st.Waiting,
|
||||
st.Running,
|
||||
@ -611,7 +584,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
|
||||
"enrich",
|
||||
"done",
|
||||
"",
|
||||
"Sprite / AI / Meta fertig",
|
||||
"Fertig",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
@ -631,7 +604,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
|
||||
"enrich",
|
||||
st.State,
|
||||
"assets",
|
||||
"Warte auf Sprite / AI / Meta…",
|
||||
"Wartet",
|
||||
st.Position,
|
||||
st.Waiting,
|
||||
st.Running,
|
||||
|
||||
@ -27,6 +27,7 @@ type PostWorkQueue struct {
|
||||
// ✅ für UI: Warteschlange + Running-Keys tracken
|
||||
waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind
|
||||
runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten)
|
||||
cancelByKey map[string]context.CancelFunc
|
||||
}
|
||||
|
||||
func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
|
||||
@ -45,6 +46,7 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
|
||||
|
||||
waitingKeys: make([]string, 0, queueSize),
|
||||
runningKeys: make(map[string]struct{}),
|
||||
cancelByKey: make(map[string]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,6 +82,23 @@ func (pq *PostWorkQueue) Enqueue(task PostWorkTask) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (pq *PostWorkQueue) Cancel(key string) bool {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
pq.mu.Lock()
|
||||
cancel := pq.cancelByKey[key]
|
||||
pq.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (pq *PostWorkQueue) removeWaitingKeyLocked(key string) {
|
||||
for i, k := range pq.waitingKeys {
|
||||
if k == key {
|
||||
@ -171,7 +190,18 @@ func (pq *PostWorkQueue) workerLoop(id int) {
|
||||
|
||||
// 3) Optional: Task timeout (gegen hängende ffmpeg)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
pq.mu.Lock()
|
||||
pq.cancelByKey[task.Key] = cancel
|
||||
pq.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
cancel()
|
||||
|
||||
pq.mu.Lock()
|
||||
delete(pq.cancelByKey, task.Key)
|
||||
pq.mu.Unlock()
|
||||
}()
|
||||
|
||||
// 4) Run
|
||||
if task.Run != nil {
|
||||
@ -291,9 +321,9 @@ func startEnrichStatusRefresher() {
|
||||
continue
|
||||
}
|
||||
|
||||
label := "Warte auf Sprite / AI / Meta…"
|
||||
label := "Wartet"
|
||||
if st.State == "running" {
|
||||
label = "Ergänze Sprite / AI / Meta…"
|
||||
label = "Wird verarbeitet…"
|
||||
}
|
||||
|
||||
publishFinishedPostworkStateForJob(
|
||||
|
||||
@ -90,6 +90,10 @@ type undoDeleteToken struct {
|
||||
File string `json:"file"` // original basename, z.B. "HOT xyz.mp4"
|
||||
}
|
||||
|
||||
type bulkFilesRequest struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
func encodeUndoDeleteToken(t undoDeleteToken) (string, error) {
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
@ -160,6 +164,440 @@ func recordPrepareSplit(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
type regenerateAssetsRequest struct {
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req regenerateAssetsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file := strings.TrimSpace(req.File)
|
||||
if file == "" {
|
||||
http.Error(w, "missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isAllowedVideoExt(file) {
|
||||
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil {
|
||||
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(doneAbs) == "" {
|
||||
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
videoPath, _, fi, err := resolveDoneFileByName(doneAbs, file)
|
||||
if err != nil {
|
||||
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
http.Error(w, "invalid output path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
setRegenerateAssetsTaskRunning(file, file)
|
||||
|
||||
go func(videoPath string, file string) {
|
||||
id := assetIDFromVideoPath(videoPath)
|
||||
|
||||
// 1) Vorher evtl. laufende enrich-Jobs abbrechen
|
||||
cancelDeferredEnrichForFile(file)
|
||||
|
||||
// 2) SOFORT running an UI senden
|
||||
startEv := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: file,
|
||||
AssetID: id,
|
||||
Queue: "enrich",
|
||||
State: "running",
|
||||
Phase: "assets",
|
||||
Label: "Wird verarbeitet",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if b, err := json.Marshal(startEv); err == nil {
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
|
||||
notifyDoneChanged()
|
||||
|
||||
// 3) Alte Assets löschen
|
||||
if id != "" {
|
||||
if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil {
|
||||
_ = os.Remove(filepath.Join(assetDir, "preview.jpg"))
|
||||
_ = os.Remove(filepath.Join(assetDir, "preview.mp4"))
|
||||
_ = os.Remove(filepath.Join(assetDir, "preview-sprite.jpg"))
|
||||
_ = os.Remove(metaPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Regeneration
|
||||
_, errAssets := ensureAssetsForVideoWithProgressCtx(context.Background(), videoPath, "", nil)
|
||||
errAI := ensureDeferredAssetsAndAI(context.Background(), videoPath, "")
|
||||
|
||||
// 5) Ergebnis zurück an UI
|
||||
if errAssets != nil || errAI != nil {
|
||||
errMsg := ""
|
||||
if errAssets != nil {
|
||||
errMsg = errAssets.Error()
|
||||
} else {
|
||||
errMsg = errAI.Error()
|
||||
}
|
||||
|
||||
errEv := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: file,
|
||||
AssetID: id,
|
||||
Queue: "enrich",
|
||||
State: "error",
|
||||
Phase: "assets",
|
||||
Label: "Fehler",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if b, err := json.Marshal(errEv); err == nil {
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
|
||||
fmt.Println("[regenerate-assets] failed:", file, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
doneEv := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: file,
|
||||
AssetID: id,
|
||||
Queue: "enrich",
|
||||
State: "done",
|
||||
Phase: "assets",
|
||||
Label: "Fertig",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if b, err := json.Marshal(doneEv); err == nil {
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
|
||||
notifyDoneChanged()
|
||||
}(videoPath, file)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": true,
|
||||
"file": file,
|
||||
})
|
||||
}
|
||||
|
||||
func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req bulkFilesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Files) == 0 {
|
||||
http.Error(w, "missing files", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil {
|
||||
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(doneAbs) == "" {
|
||||
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
keepRoot := filepath.Join(doneAbs, "keep")
|
||||
|
||||
type itemResult struct {
|
||||
File string `json:"file"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
results := make([]itemResult, 0, len(req.Files))
|
||||
deletedCount := 0
|
||||
seen := make(map[string]struct{}, len(req.Files))
|
||||
|
||||
for _, raw := range req.Files {
|
||||
file := strings.TrimSpace(raw)
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[file]; ok {
|
||||
continue
|
||||
}
|
||||
seen[file] = struct{}{}
|
||||
|
||||
if !isAllowedVideoExt(file) {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "nicht erlaubt",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// laufende/queued Nacharbeit abbrechen
|
||||
cancelDeferredEnrichForFile(file)
|
||||
|
||||
target, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
||||
if err != nil {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "datei nicht gefunden",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if fi != nil && fi.IsDir() {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "ist ein verzeichnis",
|
||||
From: from,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if err := removeWithRetry(target); err != nil {
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "datei wird gerade verwendet",
|
||||
From: from,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: err.Error(),
|
||||
From: from,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
purgeDurationCacheForPath(target)
|
||||
removeJobsByOutputBasename(file)
|
||||
|
||||
// generated assets aufräumen
|
||||
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)))
|
||||
if strings.TrimSpace(id) != "" {
|
||||
removeGeneratedForID(id)
|
||||
}
|
||||
|
||||
// optional: leeren Keep-Unterordner nicht hart notwendig, aber sauber
|
||||
if from == "keep" {
|
||||
dir := filepath.Dir(target)
|
||||
if strings.HasPrefix(strings.ToLower(filepath.Clean(dir)), strings.ToLower(filepath.Clean(keepRoot))) {
|
||||
_ = os.Remove(dir)
|
||||
}
|
||||
}
|
||||
|
||||
deletedCount++
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: true,
|
||||
From: from,
|
||||
})
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
notifyDoneChanged()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": deletedCount > 0,
|
||||
"deleted": deletedCount,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
func handleKeepMany(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req bulkFilesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Files) == 0 {
|
||||
http.Error(w, "missing files", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil {
|
||||
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(doneAbs) == "" {
|
||||
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
keepRoot := filepath.Join(doneAbs, "keep")
|
||||
if err := os.MkdirAll(keepRoot, 0o755); err != nil {
|
||||
http.Error(w, "keep dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type itemResult struct {
|
||||
File string `json:"file"`
|
||||
NewFile string `json:"newFile,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
results := make([]itemResult, 0, len(req.Files))
|
||||
keptCount := 0
|
||||
seen := make(map[string]struct{}, len(req.Files))
|
||||
|
||||
for _, raw := range req.Files {
|
||||
file := strings.TrimSpace(raw)
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[file]; ok {
|
||||
continue
|
||||
}
|
||||
seen[file] = struct{}{}
|
||||
|
||||
if !isAllowedVideoExt(file) {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "nicht erlaubt",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
cancelDeferredEnrichForFile(file)
|
||||
|
||||
src, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
||||
if err != nil {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "datei nicht gefunden",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Bereits in keep -> nichts tun, aber als ok zurückgeben
|
||||
if from == "keep" {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
NewFile: file,
|
||||
OK: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if fi == nil || fi.IsDir() {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "ist ein verzeichnis",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
modelKey := modelKeyFromFilenameOrPath(file, src, doneAbs)
|
||||
dstDir := keepRoot
|
||||
if modelKey != "" {
|
||||
dstDir = filepath.Join(keepRoot, modelKey)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "keep subdir erstellen fehlgeschlagen: " + err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
dst, err := uniqueDestPath(dstDir, file)
|
||||
if err != nil {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "zielname nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if err := renameWithRetryAggressive(src, dst); err != nil {
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "datei wird gerade verwendet",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
keptCount++
|
||||
results = append(results, itemResult{
|
||||
File: file,
|
||||
NewFile: filepath.Base(dst),
|
||||
OK: true,
|
||||
})
|
||||
}
|
||||
|
||||
if keptCount > 0 {
|
||||
notifyDoneChanged()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": keptCount > 0,
|
||||
"kept": keptCount,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------- Preview sprite truth (shared) ----------------
|
||||
|
||||
type previewSpriteMetaFileInfo struct {
|
||||
@ -1763,6 +2201,62 @@ func renameWithRetryAggressive(src, dst string) error {
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func cancelDeferredEnrichForFile(file string) {
|
||||
file = strings.TrimSpace(file)
|
||||
if file == "" {
|
||||
return
|
||||
}
|
||||
|
||||
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)))
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
|
||||
enrichKey := "enrich:" + id
|
||||
st := enrichQ.StatusForKey(enrichKey)
|
||||
|
||||
switch st.State {
|
||||
case "queued":
|
||||
if enrichQ.RemoveQueued(enrichKey) {
|
||||
// optional: stale badge sofort entfernen
|
||||
ev := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: file,
|
||||
AssetID: id,
|
||||
Queue: "enrich",
|
||||
State: "missing",
|
||||
Phase: "assets",
|
||||
Label: "",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if b, err := json.Marshal(ev); err == nil {
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
}
|
||||
|
||||
case "running":
|
||||
if enrichQ.Cancel(enrichKey) {
|
||||
// ffmpeg / reads / handles kurz auslaufen lassen
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
|
||||
ev := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: file,
|
||||
AssetID: id,
|
||||
Queue: "enrich",
|
||||
State: "missing",
|
||||
Phase: "assets",
|
||||
Label: "",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if b, err := json.Marshal(ev); err == nil {
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
|
||||
@ -1779,6 +2273,9 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen
|
||||
cancelDeferredEnrichForFile(file)
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil {
|
||||
@ -2046,6 +2543,9 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird
|
||||
cancelDeferredEnrichForFile(file)
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil {
|
||||
@ -2116,6 +2616,9 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird
|
||||
cancelDeferredEnrichForFile(file)
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil {
|
||||
@ -2222,6 +2725,9 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// optional, aber sinnvoll
|
||||
cancelDeferredEnrichForFile(file)
|
||||
|
||||
s := getSettings()
|
||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||
if err != nil {
|
||||
|
||||
@ -63,6 +63,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
api.HandleFunc("/api/record/unkeep", recordUnkeepVideo)
|
||||
api.HandleFunc("/api/record/restore", recordRestoreVideo)
|
||||
api.HandleFunc("/api/record/prepare-split", recordPrepareSplit)
|
||||
api.HandleFunc("/api/record/regenerate-assets", handleRegenerateAssets)
|
||||
api.HandleFunc("/api/record/delete-many", handleDeleteMany)
|
||||
api.HandleFunc("/api/record/keep-many", handleKeepMany)
|
||||
|
||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||
|
||||
@ -322,7 +322,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
publishAssetsTaskFinishedPostworkState(
|
||||
it.name,
|
||||
"running",
|
||||
"Erstelle Vorschau / Thumbnails…",
|
||||
"Erstelle Teaser / Vorschau / Thumbnails…",
|
||||
)
|
||||
|
||||
// ID aus Dateiname
|
||||
@ -397,7 +397,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
publishAssetsTaskFinishedPostworkState(
|
||||
it.name,
|
||||
"done",
|
||||
"Vorschau / Thumbnails fertig",
|
||||
"Teaser / Vorschau / Thumbnails fertig",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -15,8 +15,16 @@ type CleanupTaskState struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type RegenerateAssetsTaskState struct {
|
||||
Running bool `json:"running"`
|
||||
File string `json:"file,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type TasksStatusResponse struct {
|
||||
GenerateAssets AssetsTaskState `json:"generateAssets"`
|
||||
RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"`
|
||||
Cleanup CleanupTaskState `json:"cleanup"`
|
||||
AnyRunning bool `json:"anyRunning"`
|
||||
}
|
||||
@ -24,8 +32,51 @@ type TasksStatusResponse struct {
|
||||
var (
|
||||
cleanupTaskMu sync.Mutex
|
||||
cleanupTaskState CleanupTaskState
|
||||
|
||||
regenerateAssetsTaskMu sync.Mutex
|
||||
regenerateAssetsTaskState RegenerateAssetsTaskState
|
||||
)
|
||||
|
||||
func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState {
|
||||
regenerateAssetsTaskMu.Lock()
|
||||
st := regenerateAssetsTaskState
|
||||
regenerateAssetsTaskMu.Unlock()
|
||||
return st
|
||||
}
|
||||
|
||||
func setRegenerateAssetsTaskRunning(file, text string) {
|
||||
regenerateAssetsTaskMu.Lock()
|
||||
regenerateAssetsTaskState = RegenerateAssetsTaskState{
|
||||
Running: true,
|
||||
File: strings.TrimSpace(file),
|
||||
Text: strings.TrimSpace(text),
|
||||
Error: "",
|
||||
}
|
||||
regenerateAssetsTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func setRegenerateAssetsTaskDone(file, text string) {
|
||||
regenerateAssetsTaskMu.Lock()
|
||||
regenerateAssetsTaskState = RegenerateAssetsTaskState{
|
||||
Running: false,
|
||||
File: strings.TrimSpace(file),
|
||||
Text: strings.TrimSpace(text),
|
||||
Error: "",
|
||||
}
|
||||
regenerateAssetsTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func setRegenerateAssetsTaskError(file, errText string) {
|
||||
regenerateAssetsTaskMu.Lock()
|
||||
regenerateAssetsTaskState = RegenerateAssetsTaskState{
|
||||
Running: false,
|
||||
File: strings.TrimSpace(file),
|
||||
Text: "",
|
||||
Error: strings.TrimSpace(errText),
|
||||
}
|
||||
regenerateAssetsTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func snapshotCleanupTaskState() CleanupTaskState {
|
||||
cleanupTaskMu.Lock()
|
||||
st := cleanupTaskState
|
||||
@ -70,12 +121,14 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
assets := getGenerateAssetsTaskStatus()
|
||||
regenerate := snapshotRegenerateAssetsTaskState()
|
||||
cleanup := snapshotCleanupTaskState()
|
||||
|
||||
resp := TasksStatusResponse{
|
||||
GenerateAssets: assets,
|
||||
RegenerateAssets: regenerate,
|
||||
Cleanup: cleanup,
|
||||
AnyRunning: assets.Running || cleanup.Running,
|
||||
AnyRunning: assets.Running || regenerate.Running || cleanup.Running,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@ -3070,7 +3070,7 @@ export default function App() {
|
||||
|
||||
return res
|
||||
} catch (e: any) {
|
||||
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e))
|
||||
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
|
||||
return
|
||||
}
|
||||
},
|
||||
|
||||
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,
|
||||
shouldEnableTeaserAudio,
|
||||
} from './teaserPlayback'
|
||||
import Checkbox from './Checkbox'
|
||||
|
||||
type InlinePlayState = { key: string; nonce: number } | null
|
||||
|
||||
@ -23,6 +24,9 @@ type Props = {
|
||||
rows: RecordJob[]
|
||||
isLoading?: boolean
|
||||
isSmall: boolean
|
||||
selectedKeys: Set<string>
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onToggleSelectAllPage: (checked: boolean) => void
|
||||
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
@ -98,16 +102,23 @@ type Props = {
|
||||
ts: number
|
||||
}>
|
||||
|
||||
renderPostworkBadge?: (badge?: {
|
||||
file: string
|
||||
assetId?: string
|
||||
queue: 'postwork' | 'enrich'
|
||||
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
|
||||
phase?: string
|
||||
label?: string
|
||||
position?: number
|
||||
waiting?: number
|
||||
running?: number
|
||||
maxParallel?: number
|
||||
ts: number
|
||||
}) => React.ReactNode
|
||||
|
||||
forcePreviewMuted?: boolean
|
||||
}
|
||||
|
||||
type PostworkBadgeTone = 'idle' | 'running' | 'done' | 'error'
|
||||
|
||||
type PostworkBadge = {
|
||||
label: string
|
||||
tone: PostworkBadgeTone
|
||||
}
|
||||
|
||||
const parseTags = (raw?: string): string[] => {
|
||||
const s = String(raw ?? '').trim()
|
||||
if (!s) return []
|
||||
@ -179,189 +190,6 @@ function chooseSpriteGrid(count: number): [number, number] {
|
||||
return [bestCols, bestRows]
|
||||
}
|
||||
|
||||
function postworkBadgeClass(tone: PostworkBadgeTone) {
|
||||
if (tone === 'running') {
|
||||
return 'bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-400/20'
|
||||
}
|
||||
if (tone === 'done') {
|
||||
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-400/20'
|
||||
}
|
||||
if (tone === 'error') {
|
||||
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-400/20'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
|
||||
}
|
||||
|
||||
function isPostworkJob(job: RecordJob): boolean {
|
||||
const anyJ = job as any
|
||||
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
|
||||
const pw = anyJ.postWork
|
||||
const pwKey = String(anyJ.postWorkKey ?? '').trim()
|
||||
|
||||
if (pwKey) return true
|
||||
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
|
||||
if (job.endedAt && phase) return true
|
||||
if (phase === 'postwork') return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getEffectivePostworkState(job: RecordJob): 'running' | 'queued' | 'none' {
|
||||
const anyJ = job as any
|
||||
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
|
||||
const pw = anyJ.postWork
|
||||
const pwState = String(pw?.state ?? '').trim().toLowerCase()
|
||||
const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== ''
|
||||
|
||||
if (!isPostworkJob(job)) return 'none'
|
||||
|
||||
// ✅ Für Finished-Tab: echte Postwork-Phasen/Queue-Hinweise dürfen auch
|
||||
// bei status=finished weiter sichtbar bleiben.
|
||||
|
||||
if (pwState === 'queued') return 'queued'
|
||||
if (pwState === 'running') return 'running'
|
||||
|
||||
if (
|
||||
phase === 'postwork' ||
|
||||
phase === 'probe' ||
|
||||
phase === 'remuxing' ||
|
||||
phase === 'moving' ||
|
||||
phase === 'assets'
|
||||
) {
|
||||
return 'running'
|
||||
}
|
||||
|
||||
if (
|
||||
typeof pw?.position === 'number' &&
|
||||
Number.isFinite(pw.position) &&
|
||||
pw.position > 0
|
||||
) {
|
||||
return 'queued'
|
||||
}
|
||||
|
||||
if (
|
||||
typeof pw?.running === 'number' &&
|
||||
Number.isFinite(pw.running) &&
|
||||
pw.running > 0 &&
|
||||
(!Number.isFinite(pw?.position) || pw.position <= 0)
|
||||
) {
|
||||
return 'running'
|
||||
}
|
||||
|
||||
if (hasPwKey) return 'queued'
|
||||
if (anyJ.endedAt || job.endedAt) return 'queued'
|
||||
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function phaseLabel(phaseRaw?: string): string {
|
||||
switch (String(phaseRaw ?? '').trim().toLowerCase()) {
|
||||
case 'probe':
|
||||
return 'Analysiere Datei (Dauer/Streams)…'
|
||||
case 'remuxing':
|
||||
return 'Konvertiere Container zu MP4…'
|
||||
case 'moving':
|
||||
return 'Verschiebe nach Done…'
|
||||
case 'assets':
|
||||
return 'Erstelle Vorschau/Thumbnails…'
|
||||
case 'postwork':
|
||||
return 'Nacharbeiten laufen…'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function postWorkLabel(job: RecordJob): string {
|
||||
const anyJ = job as any
|
||||
const pw = anyJ.postWork
|
||||
const effectiveState = getEffectivePostworkState(job)
|
||||
|
||||
if (effectiveState === 'running') {
|
||||
const running = typeof pw?.running === 'number' && pw.running > 0 ? pw.running : 1
|
||||
const maxP = typeof pw?.maxParallel === 'number' ? pw.maxParallel : 0
|
||||
|
||||
return maxP > 0
|
||||
? `Nacharbeiten laufen… (${running}/${maxP} parallel)`
|
||||
: 'Nacharbeiten laufen…'
|
||||
}
|
||||
|
||||
if (effectiveState === 'queued') {
|
||||
const posServer = typeof pw?.position === 'number' ? pw.position : 0
|
||||
const waitingServer = typeof pw?.waiting === 'number' ? pw.waiting : 0
|
||||
const totalServer = Math.max(waitingServer, posServer)
|
||||
|
||||
return posServer > 0 && totalServer > 0
|
||||
? `Warte auf Nacharbeiten… ${posServer} / ${totalServer}`
|
||||
: 'Warte auf Nacharbeiten…'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function finishedPostworkBadge(job: RecordJob): {
|
||||
label: string
|
||||
tone: 'idle' | 'running' | 'done' | 'error'
|
||||
} | null {
|
||||
const anyJ = job as any
|
||||
const phaseRaw = String(anyJ?.phase ?? '').trim()
|
||||
const phase = phaseRaw.toLowerCase()
|
||||
const effectiveState = getEffectivePostworkState(job)
|
||||
|
||||
if (effectiveState === 'none') return null
|
||||
|
||||
if (
|
||||
phase === 'probe' ||
|
||||
phase === 'remuxing' ||
|
||||
phase === 'moving' ||
|
||||
phase === 'assets'
|
||||
) {
|
||||
return {
|
||||
label: phaseLabel(phaseRaw) || 'Nacharbeiten laufen…',
|
||||
tone: 'running',
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveState === 'queued') {
|
||||
return {
|
||||
label: postWorkLabel(job) || 'Warte auf Nacharbeiten…',
|
||||
tone: 'idle',
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveState === 'running') {
|
||||
return {
|
||||
label: phaseLabel(phaseRaw) || postWorkLabel(job) || 'Nacharbeiten laufen…',
|
||||
tone: 'running',
|
||||
}
|
||||
}
|
||||
|
||||
const rawStatus =
|
||||
String(anyJ?.postworkStatus ?? anyJ?.postprocessingStatus ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (rawStatus === 'failed' || rawStatus === 'error') {
|
||||
return {
|
||||
label: 'Nacharbeiten fehlgeschlagen',
|
||||
tone: 'error',
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
rawStatus === 'done' ||
|
||||
rawStatus === 'finished' ||
|
||||
rawStatus === 'completed' ||
|
||||
rawStatus === 'success'
|
||||
) {
|
||||
return {
|
||||
label: 'Nacharbeiten fertig',
|
||||
tone: 'done',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function CardBlurWrapper({
|
||||
blurred,
|
||||
animateUnblurOnMount,
|
||||
@ -545,6 +373,8 @@ function MobileLoadingStack({
|
||||
export default function FinishedDownloadsCardsView({
|
||||
rows,
|
||||
isSmall,
|
||||
selectedKeys,
|
||||
onToggleSelected,
|
||||
isLoading,
|
||||
blurPreviews,
|
||||
durations,
|
||||
@ -590,9 +420,13 @@ export default function FinishedDownloadsCardsView({
|
||||
onSplit,
|
||||
onAddToDownloads,
|
||||
postworkByFile,
|
||||
renderPostworkBadge,
|
||||
forcePreviewMuted,
|
||||
}: Props) {
|
||||
|
||||
const hasAnySelection = selectedKeys.size > 0
|
||||
const footerTapRef = React.useRef<Record<string, boolean>>({})
|
||||
|
||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
||||
const raw: any = (j as any)?.meta
|
||||
if (!raw) return null
|
||||
@ -757,6 +591,7 @@ export default function FinishedDownloadsCardsView({
|
||||
})
|
||||
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const checked = selectedKeys.has(k)
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const fileRaw = baseName(j.output || '')
|
||||
@ -892,30 +727,6 @@ export default function FinishedDownloadsCardsView({
|
||||
const isFav = Boolean(flags?.favorite)
|
||||
const isLiked = flags?.liked === true
|
||||
const isWatching = Boolean(flags?.watching)
|
||||
const postworkStatus: PostworkBadge | null =
|
||||
livePostwork && livePostwork.state !== 'missing'
|
||||
? {
|
||||
label:
|
||||
livePostwork.label ||
|
||||
(livePostwork.state === 'queued'
|
||||
? 'Warte auf Nachbearbeitung…'
|
||||
: livePostwork.state === 'running'
|
||||
? 'Nachbearbeitung läuft…'
|
||||
: livePostwork.state === 'error'
|
||||
? 'Nachbearbeitung fehlgeschlagen'
|
||||
: livePostwork.state === 'done'
|
||||
? 'Nachbearbeitung fertig'
|
||||
: ''),
|
||||
tone:
|
||||
livePostwork.state === 'running'
|
||||
? 'running'
|
||||
: livePostwork.state === 'queued'
|
||||
? 'idle'
|
||||
: livePostwork.state === 'error'
|
||||
? 'error'
|
||||
: 'done',
|
||||
}
|
||||
: finishedPostworkBadge(j)
|
||||
|
||||
const tags = parseTags(flags?.tags)
|
||||
|
||||
@ -955,8 +766,37 @@ export default function FinishedDownloadsCardsView({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-finished-hover-key={k}
|
||||
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) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
@ -968,6 +808,27 @@ export default function FinishedDownloadsCardsView({
|
||||
blurred={opts?.blur}
|
||||
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 noBodyPadding className="overflow-hidden bg-transparent">
|
||||
{/* Preview */}
|
||||
@ -980,15 +841,13 @@ export default function FinishedDownloadsCardsView({
|
||||
}
|
||||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||
onMouseEnter={
|
||||
isSmall || opts?.disablePreviewHover
|
||||
isSmall
|
||||
? undefined
|
||||
: () => {
|
||||
setHoveredThumbKey(k)
|
||||
onHoverPreviewKeyChange?.(k)
|
||||
}
|
||||
}
|
||||
onMouseLeave={() => {
|
||||
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
|
||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||
clearScrubActiveIndex(k)
|
||||
setScrubHovering(k, false)
|
||||
@ -996,6 +855,12 @@ export default function FinishedDownloadsCardsView({
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (hasAnySelection) {
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
|
||||
if (isSmall || opts?.disableInline) return
|
||||
startInline(k)
|
||||
}}
|
||||
@ -1144,24 +1009,28 @@ export default function FinishedDownloadsCardsView({
|
||||
</div>
|
||||
|
||||
{/* 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="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<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">
|
||||
{model}
|
||||
</div>
|
||||
|
||||
{postworkStatus ? (
|
||||
<span
|
||||
className={[
|
||||
'shrink-0 inline-flex max-w-[220px] items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1',
|
||||
postworkBadgeClass(postworkStatus.tone),
|
||||
].join(' ')}
|
||||
title={postworkStatus.label}
|
||||
>
|
||||
<span className="truncate">{postworkStatus.label}</span>
|
||||
</span>
|
||||
{renderPostworkBadge && livePostwork ? (
|
||||
<div className="shrink-0">
|
||||
{renderPostworkBadge(livePostwork)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -1465,6 +1334,17 @@ export default function FinishedDownloadsCardsView({
|
||||
onTap={() => {
|
||||
if (!mobileTopTapEnabled) return
|
||||
|
||||
if (footerTapRef.current[k]) {
|
||||
delete footerTapRef.current[k]
|
||||
openPlayer(j)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasAnySelection) {
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
|
||||
startInline(k)
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryAutoplayInline(inlineDomId)) {
|
||||
|
||||
@ -4,11 +4,6 @@
|
||||
import * as React from 'react'
|
||||
import type { RecordJob } from '../../types'
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import {
|
||||
StarIcon as StarSolidIcon,
|
||||
HeartIcon as HeartSolidIcon,
|
||||
EyeIcon as EyeSolidIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
@ -20,6 +15,7 @@ import {
|
||||
shouldEnableTeaserAudio,
|
||||
shouldObserveTeasers,
|
||||
} from './teaserPlayback'
|
||||
import Checkbox from './Checkbox'
|
||||
|
||||
type ModelFlags = {
|
||||
favorite?: boolean
|
||||
@ -52,11 +48,15 @@ type FinishedPostworkBadge = {
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
selectedKeys: Set<string>
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onToggleSelectAllPage: (checked: boolean) => void
|
||||
isLoading?: boolean
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
postworkByFile: Record<string, FinishedPostworkBadge>
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode
|
||||
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
|
||||
@ -169,6 +169,9 @@ export default function FinishedDownloadsGalleryView({
|
||||
durations,
|
||||
postworkByFile,
|
||||
teaserState,
|
||||
renderPostworkBadge,
|
||||
selectedKeys,
|
||||
onToggleSelected,
|
||||
|
||||
handleDuration,
|
||||
handleScrubberClickIndex,
|
||||
@ -204,8 +207,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
}: Props) {
|
||||
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||
|
||||
|
||||
const hasAnySelection = selectedKeys.size > 0
|
||||
|
||||
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
|
||||
const registerTeaserHostIfNeeded = React.useCallback(
|
||||
@ -290,6 +292,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{rows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
const checked = selectedKeys.has(k)
|
||||
|
||||
const isPreviewActive =
|
||||
teaserState.activeKey === k || teaserState.hoverKey === k
|
||||
@ -479,12 +482,48 @@ export default function FinishedDownloadsGalleryView({
|
||||
<div
|
||||
role="button"
|
||||
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"
|
||||
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) => {
|
||||
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
|
||||
className={[
|
||||
'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)}
|
||||
onMouseEnter={() => {
|
||||
setHoveredThumbKey(k)
|
||||
onHoverPreviewKeyChange?.(k)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||
onHoverPreviewKeyChange?.(null)
|
||||
clearScrubIndex(k)
|
||||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||||
}}
|
||||
@ -626,31 +663,20 @@ export default function FinishedDownloadsGalleryView({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* ✅ stashapp-like: Dateiname zuerst */}
|
||||
<div className="min-w-0">
|
||||
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
||||
{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">
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"
|
||||
title={stripHotPrefix(file) || '—'}
|
||||
<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"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onOpenPlayer(j)
|
||||
}}
|
||||
>
|
||||
{stripHotPrefix(file) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
{/* Model oben + Badge direkt dahinter */}
|
||||
<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 truncate text-xs text-gray-500 dark:text-gray-400',
|
||||
'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)
|
||||
@ -670,47 +696,26 @@ export default function FinishedDownloadsGalleryView({
|
||||
{model || '—'}
|
||||
</span>
|
||||
|
||||
{showPostworkBadge ? (
|
||||
<span
|
||||
className={[
|
||||
'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>
|
||||
{showPostworkBadge && renderPostworkBadge ? (
|
||||
<div className="shrink-0">
|
||||
{renderPostworkBadge(postworkBadge)}
|
||||
</div>
|
||||
) : 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 className="mt-0.5 flex items-start gap-2 min-w-0">
|
||||
{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">
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span
|
||||
className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"
|
||||
title={stripHotPrefix(file) || '—'}
|
||||
>
|
||||
{stripHotPrefix(file) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
shouldAnimateTeaser,
|
||||
shouldEnableTeaserAudio,
|
||||
} from './teaserPlayback'
|
||||
import Checkbox from './Checkbox'
|
||||
|
||||
type SortMode =
|
||||
| 'completed_desc'
|
||||
@ -43,6 +44,9 @@ type FinishedPostworkBadge = {
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
isLoading?: boolean
|
||||
selectedKeys: Set<string>
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onToggleSelectAllPage: (checked: boolean) => void
|
||||
|
||||
// helpers
|
||||
keyFor: (j: RecordJob) => string
|
||||
@ -56,6 +60,7 @@ type Props = {
|
||||
durations: Record<string, number>
|
||||
postworkByFile: Record<string, FinishedPostworkBadge>
|
||||
jobForDetails: (job: RecordJob) => RecordJob
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode
|
||||
|
||||
// teaser/preview
|
||||
canHover: boolean
|
||||
@ -98,6 +103,9 @@ type Props = {
|
||||
export default function FinishedDownloadsTableView({
|
||||
rows,
|
||||
isLoading,
|
||||
selectedKeys,
|
||||
onToggleSelected,
|
||||
onToggleSelectAllPage,
|
||||
keyFor,
|
||||
baseName,
|
||||
lower,
|
||||
@ -109,6 +117,7 @@ export default function FinishedDownloadsTableView({
|
||||
durations,
|
||||
postworkByFile,
|
||||
jobForDetails,
|
||||
renderPostworkBadge,
|
||||
|
||||
canHover,
|
||||
teaserState,
|
||||
@ -229,8 +238,54 @@ export default function FinishedDownloadsTableView({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const allSelectedOnPage =
|
||||
rows.length > 0 && rows.every((j) => selectedKeys.has(keyFor(j)))
|
||||
|
||||
const someSelectedOnPage =
|
||||
rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage
|
||||
|
||||
const columns = React.useMemo<Column<RecordJob>[]>(() => {
|
||||
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',
|
||||
header: 'Vorschau',
|
||||
@ -266,11 +321,7 @@ export default function FinishedDownloadsTableView({
|
||||
className="group relative py-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseEnter={() => {
|
||||
if (canHover) setHoverTeaserKey(k)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (canHover) setHoverTeaserKey(null)
|
||||
setScrubActiveIndex(k, undefined)
|
||||
}}
|
||||
>
|
||||
@ -348,32 +399,9 @@ export default function FinishedDownloadsTableView({
|
||||
{model}
|
||||
</div>
|
||||
|
||||
{showPostworkBadge ? (
|
||||
<span
|
||||
className={[
|
||||
'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}
|
||||
{showPostworkBadge && renderPostworkBadge
|
||||
? renderPostworkBadge(postworkBadge)
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<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,
|
||||
runtimeSecondsForSort,
|
||||
forcePreviewMuted,
|
||||
renderPostworkBadge,
|
||||
rows,
|
||||
selectedKeys,
|
||||
onToggleSelected,
|
||||
onToggleSelectAllPage,
|
||||
allSelectedOnPage,
|
||||
someSelectedOnPage,
|
||||
])
|
||||
|
||||
const sortStateToMode = React.useCallback((s: SortState): SortMode => {
|
||||
@ -607,6 +642,18 @@ export default function FinishedDownloadsTableView({
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
onRowClick={onOpenPlayer}
|
||||
onRowMouseEnter={(j) => {
|
||||
if (canHover) setHoverTeaserKey(keyFor(j))
|
||||
}}
|
||||
onRowMouseLeave={(j) => {
|
||||
if (canHover) {
|
||||
setHoverTeaserKey((prev) => (prev === keyFor(j) ? null : prev))
|
||||
}
|
||||
setScrubActiveIndex(keyFor(j), undefined)
|
||||
}}
|
||||
rowAttrs={(j) => ({
|
||||
'data-finished-hover-key': keyFor(j),
|
||||
})}
|
||||
rowClassName={rowClassName}
|
||||
/>
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ export default function LabeledSwitch({
|
||||
// With left label and description Beispiel
|
||||
return (
|
||||
<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}
|
||||
</label>
|
||||
|
||||
@ -2000,8 +2000,6 @@ export default function ModelDetails({
|
||||
pageSize={DONE_PAGE_SIZE}
|
||||
totalItems={doneTotalCount}
|
||||
onPageChange={(p) => setDonePage(p)}
|
||||
siblingCount={1}
|
||||
boundaryCount={1}
|
||||
showSummary={true}
|
||||
prevLabel="Zurück"
|
||||
nextLabel="Weiter"
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
// frontend\src\components\ui\Pagination.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
type PageItem = number | 'ellipsis'
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronDoubleLeftIcon,
|
||||
ChevronDoubleRightIcon,
|
||||
ChevronDownIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
|
||||
export type PaginationProps = {
|
||||
page: number // 1-based
|
||||
@ -12,106 +18,27 @@ export type PaginationProps = {
|
||||
totalItems: number
|
||||
onPageChange: (page: number) => void
|
||||
|
||||
/** wie viele Seiten links/rechts neben aktueller Seite */
|
||||
siblingCount?: number
|
||||
/** wie viele Seiten am Anfang/Ende immer gezeigt werden */
|
||||
boundaryCount?: number
|
||||
|
||||
/** Summary "Showing x to y of z" anzeigen */
|
||||
showSummary?: boolean
|
||||
/** Wrapper Klassen */
|
||||
className?: string
|
||||
|
||||
/** Labels (optional) */
|
||||
ariaLabel?: string
|
||||
prevLabel?: string
|
||||
nextLabel?: string
|
||||
firstLabel?: string
|
||||
lastLabel?: string
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
function range(start: number, end: number): number[] {
|
||||
const out: number[] = []
|
||||
for (let i = start; i <= end; i++) out.push(i)
|
||||
return out
|
||||
}
|
||||
|
||||
function getPageItems(
|
||||
totalPages: number,
|
||||
current: number,
|
||||
boundaryCount: number,
|
||||
siblingCount: number
|
||||
): PageItem[] {
|
||||
if (totalPages <= 1) return [1]
|
||||
|
||||
const first = 1
|
||||
const last = totalPages
|
||||
|
||||
const startPages = range(first, Math.min(boundaryCount, last))
|
||||
const endPages = range(Math.max(last - boundaryCount + 1, boundaryCount + 1), last)
|
||||
|
||||
const siblingsStart = Math.max(
|
||||
Math.min(
|
||||
current - siblingCount,
|
||||
last - boundaryCount - siblingCount * 2 - 1
|
||||
),
|
||||
boundaryCount + 1
|
||||
)
|
||||
|
||||
const siblingsEnd = Math.min(
|
||||
Math.max(
|
||||
current + siblingCount,
|
||||
boundaryCount + siblingCount * 2 + 2
|
||||
),
|
||||
last - boundaryCount
|
||||
)
|
||||
|
||||
const items: PageItem[] = []
|
||||
|
||||
// start
|
||||
items.push(...startPages)
|
||||
|
||||
// left gap
|
||||
if (siblingsStart > boundaryCount + 1) {
|
||||
items.push('ellipsis')
|
||||
} else if (boundaryCount + 1 < last - boundaryCount) {
|
||||
items.push(boundaryCount + 1)
|
||||
}
|
||||
|
||||
// siblings
|
||||
items.push(...range(siblingsStart, siblingsEnd))
|
||||
|
||||
// right gap
|
||||
if (siblingsEnd < last - boundaryCount) {
|
||||
items.push('ellipsis')
|
||||
} else if (last - boundaryCount > boundaryCount) {
|
||||
items.push(last - boundaryCount)
|
||||
}
|
||||
|
||||
// end
|
||||
items.push(...endPages)
|
||||
|
||||
// dedupe + keep order
|
||||
const seen = new Set<string>()
|
||||
return items.filter((x) => {
|
||||
const k = String(x)
|
||||
if (seen.has(k)) return false
|
||||
seen.add(k)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function PageButton({
|
||||
active,
|
||||
disabled,
|
||||
rounded,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
rounded?: 'l' | 'r' | 'none'
|
||||
onClick?: () => void
|
||||
@ -128,14 +55,12 @@ function PageButton({
|
||||
onClick={disabled ? undefined : onClick}
|
||||
title={title}
|
||||
className={clsx(
|
||||
'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0',
|
||||
'relative inline-flex h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0',
|
||||
roundedCls,
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
active
|
||||
? 'z-10 !bg-indigo-600 !text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500'
|
||||
: 'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:inset-ring-gray-700 dark:hover:bg-white/5'
|
||||
disabled ? 'opacity-100 cursor-not-allowed' : 'cursor-pointer',
|
||||
'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50',
|
||||
'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700'
|
||||
)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@ -147,15 +72,18 @@ export default function Pagination({
|
||||
pageSize,
|
||||
totalItems,
|
||||
onPageChange,
|
||||
siblingCount = 1,
|
||||
boundaryCount = 1,
|
||||
showSummary = true,
|
||||
showSummary = false,
|
||||
className,
|
||||
ariaLabel = 'Pagination',
|
||||
prevLabel = 'Previous',
|
||||
nextLabel = 'Next',
|
||||
prevLabel = 'Vorherige Seite',
|
||||
nextLabel = 'Nächste Seite',
|
||||
firstLabel = 'Erste Seite',
|
||||
lastLabel = 'Letzte Seite',
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil((totalItems || 0) / Math.max(1, pageSize || 1)))
|
||||
const totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil((totalItems || 0) / Math.max(1, pageSize || 1))
|
||||
)
|
||||
const current = clamp(page || 1, 1, totalPages)
|
||||
|
||||
if (totalPages <= 1) return null
|
||||
@ -163,40 +91,16 @@ export default function Pagination({
|
||||
const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1
|
||||
const to = Math.min(current * pageSize, totalItems)
|
||||
|
||||
const items = getPageItems(totalPages, current, boundaryCount, siblingCount)
|
||||
|
||||
const go = (p: number) => onPageChange(clamp(p, 1, totalPages))
|
||||
|
||||
return (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
>
|
||||
{/* Mobile: nur Previous/Next */}
|
||||
<div className="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(current - 1)}
|
||||
disabled={current <= 1}
|
||||
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"
|
||||
>
|
||||
{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 */}
|
||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{showSummary ? (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 px-2">
|
||||
Showing <span className="font-medium">{from}</span> to{' '}
|
||||
@ -204,59 +108,80 @@ export default function Pagination({
|
||||
<span className="font-medium">{totalItems}</span> results
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<PageButton
|
||||
rounded="l"
|
||||
onClick={() => go(1)}
|
||||
disabled={current <= 1}
|
||||
title={firstLabel}
|
||||
>
|
||||
<span className="sr-only">{firstLabel}</span>
|
||||
<ChevronDoubleLeftIcon aria-hidden="true" className="size-5" />
|
||||
</PageButton>
|
||||
|
||||
<PageButton
|
||||
rounded="none"
|
||||
onClick={() => go(current - 1)}
|
||||
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"
|
||||
title={prevLabel}
|
||||
>
|
||||
<span className="sr-only">{prevLabel}</span>
|
||||
<ChevronLeftIcon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
|
||||
{items.map((it, idx) => {
|
||||
if (it === 'ellipsis') {
|
||||
return (
|
||||
<span
|
||||
key={`e-${idx}`}
|
||||
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"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageButton
|
||||
key={it}
|
||||
active={it === current}
|
||||
onClick={() => go(it)}
|
||||
rounded="none"
|
||||
>
|
||||
{it}
|
||||
</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 (
|
||||
<option key={p} value={p} className="text-black dark:text-white">
|
||||
{p} von {totalPages}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PageButton
|
||||
rounded="none"
|
||||
onClick={() => go(current + 1)}
|
||||
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>
|
||||
<ChevronRightIcon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
</PageButton>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@ -142,14 +142,24 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
fading: false,
|
||||
})
|
||||
|
||||
const [regenerateAssetsTask, setRegenerateAssetsTask] = useState<TaskItem>({
|
||||
id: 'regenerate-assets',
|
||||
status: 'idle',
|
||||
title: 'Assets neu generieren',
|
||||
text: '',
|
||||
cancellable: false,
|
||||
fading: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const running =
|
||||
assetsTask.status === 'running' ||
|
||||
regenerateAssetsTask.status === 'running' ||
|
||||
cleanupTask.status === 'running' ||
|
||||
cleaning
|
||||
|
||||
onTaskRunningChange?.(running)
|
||||
}, [assetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange])
|
||||
}, [assetsTask.status, regenerateAssetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange])
|
||||
|
||||
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
|
||||
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
|
||||
@ -205,6 +215,69 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
|
||||
const loadTasksStatus = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/tasks/status', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!alive || !data) return
|
||||
|
||||
const regen = data.regenerateAssets
|
||||
if (regen?.running) {
|
||||
setRegenerateAssetsTask((t) => ({
|
||||
...t,
|
||||
status: 'running',
|
||||
title: 'Assets neu generieren',
|
||||
text: String(regen.text || regen.file || ''),
|
||||
err: undefined,
|
||||
fading: false,
|
||||
}))
|
||||
} else if (regen?.error) {
|
||||
setRegenerateAssetsTask((t) => ({
|
||||
...t,
|
||||
status: 'error',
|
||||
title: 'Assets neu generieren',
|
||||
text: String(regen.file || ''),
|
||||
err: String(regen.error),
|
||||
fading: false,
|
||||
}))
|
||||
} else if (regen?.text || regen?.file) {
|
||||
setRegenerateAssetsTask((t) => ({
|
||||
...t,
|
||||
status: 'done',
|
||||
title: 'Assets neu generieren',
|
||||
text: String(regen.text || regen.file || ''),
|
||||
err: undefined,
|
||||
fading: false,
|
||||
}))
|
||||
} else {
|
||||
setRegenerateAssetsTask((t) => ({
|
||||
...t,
|
||||
status: 'idle',
|
||||
title: 'Assets neu generieren',
|
||||
text: '',
|
||||
err: undefined,
|
||||
fading: false,
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
void loadTasksStatus()
|
||||
const timer = window.setInterval(loadTasksStatus, 1500)
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const load = async () => {
|
||||
@ -553,7 +626,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<TaskList
|
||||
tasks={[assetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
|
||||
tasks={[assetsTask, regenerateAssetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
|
||||
onCancel={(id: string) => {
|
||||
if (id === 'generate-assets') cancelAssetsTask()
|
||||
}}
|
||||
|
||||
@ -35,6 +35,10 @@ export type Column<T> = {
|
||||
sortFn?: (a: T, b: T) => number
|
||||
}
|
||||
|
||||
type TableRowAttrs = React.HTMLAttributes<HTMLTableRowElement> & {
|
||||
[key: `data-${string}`]: string | number | undefined
|
||||
}
|
||||
|
||||
export type TableProps<T> = {
|
||||
columns: Array<Column<T>>
|
||||
rows: T[]
|
||||
@ -61,6 +65,9 @@ export type TableProps<T> = {
|
||||
|
||||
onRowClick?: (row: T) => 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:
|
||||
@ -130,6 +137,9 @@ export default function Table<T>({
|
||||
rowClassName,
|
||||
onRowClick,
|
||||
onRowContextMenu,
|
||||
onRowMouseEnter,
|
||||
onRowMouseLeave,
|
||||
rowAttrs,
|
||||
sort,
|
||||
onSortChange,
|
||||
defaultSort = null,
|
||||
@ -318,15 +328,18 @@ export default function Table<T>({
|
||||
) : (
|
||||
sortedRows.map((row, rowIndex) => {
|
||||
const key = getRowKey ? getRowKey(row, rowIndex) : String(rowIndex)
|
||||
const extraRowAttrs = rowAttrs?.(row, rowIndex)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
{...extraRowAttrs}
|
||||
className={cn(
|
||||
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||
onRowClick && 'cursor-pointer',
|
||||
onRowClick && 'hover:bg-gray-50 dark:hover:bg-white/5 transition-colors',
|
||||
rowClassName?.(row, rowIndex)
|
||||
rowClassName?.(row, rowIndex),
|
||||
extraRowAttrs?.className
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
onContextMenu={
|
||||
@ -335,8 +348,16 @@ export default function Table<T>({
|
||||
e.preventDefault()
|
||||
onRowContextMenu(row, e)
|
||||
}
|
||||
: undefined
|
||||
: extraRowAttrs?.onContextMenu
|
||||
}
|
||||
onMouseEnter={(e) => {
|
||||
extraRowAttrs?.onMouseEnter?.(e)
|
||||
onRowMouseEnter?.(row, e)
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
extraRowAttrs?.onMouseLeave?.(e)
|
||||
onRowMouseLeave?.(row, e)
|
||||
}}
|
||||
>
|
||||
{columns.map((col, colIndex) => {
|
||||
const xPad = xPadForColumn(colIndex, columns.length)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user