updated
This commit is contained in:
parent
160544a65d
commit
ae67c817ac
@ -16,16 +16,25 @@ var autostartPaused int32 // 0=false, 1=true
|
|||||||
|
|
||||||
// --- SSE subscribers für Autostart-State ---
|
// --- SSE subscribers für Autostart-State ---
|
||||||
var autostartSubsMu sync.Mutex
|
var autostartSubsMu sync.Mutex
|
||||||
var autostartSubs = map[chan bool]struct{}{}
|
|
||||||
|
|
||||||
func broadcastAutostartPaused(paused bool) {
|
type autostartStatePayload struct {
|
||||||
|
Paused bool `json:"paused"`
|
||||||
|
PausedByUser bool `json:"pausedByUser"`
|
||||||
|
PausedByDisk bool `json:"pausedByDisk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var autostartSubs = map[chan autostartStatePayload]struct{}{}
|
||||||
|
|
||||||
|
func broadcastAutostartPaused() {
|
||||||
|
state := getAutostartStatePayload()
|
||||||
|
|
||||||
autostartSubsMu.Lock()
|
autostartSubsMu.Lock()
|
||||||
defer autostartSubsMu.Unlock()
|
defer autostartSubsMu.Unlock()
|
||||||
|
|
||||||
for ch := range autostartSubs {
|
for ch := range autostartSubs {
|
||||||
// non-blocking: wenn Client langsam ist, droppen wir Updates (neuester Zustand kommt eh wieder)
|
// non-blocking: wenn Client langsam ist, droppen wir Updates
|
||||||
select {
|
select {
|
||||||
case ch <- paused:
|
case ch <- state:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,8 +49,19 @@ func isAutostartPaused() bool {
|
|||||||
return atomic.LoadInt32(&diskEmergency) == 1
|
return atomic.LoadInt32(&diskEmergency) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getAutostartStatePayload() autostartStatePayload {
|
||||||
|
userPaused := atomic.LoadInt32(&autostartPaused) == 1
|
||||||
|
diskPaused := atomic.LoadInt32(&diskEmergency) == 1
|
||||||
|
|
||||||
|
return autostartStatePayload{
|
||||||
|
Paused: userPaused || diskPaused,
|
||||||
|
PausedByUser: userPaused,
|
||||||
|
PausedByDisk: diskPaused,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setAutostartPaused(v bool) {
|
func setAutostartPaused(v bool) {
|
||||||
old := isAutostartPaused()
|
old := getAutostartStatePayload()
|
||||||
|
|
||||||
if v {
|
if v {
|
||||||
atomic.StoreInt32(&autostartPaused, 1)
|
atomic.StoreInt32(&autostartPaused, 1)
|
||||||
@ -49,9 +69,11 @@ func setAutostartPaused(v bool) {
|
|||||||
atomic.StoreInt32(&autostartPaused, 0)
|
atomic.StoreInt32(&autostartPaused, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// nur wenn sich der Zustand wirklich geändert hat -> pushen
|
newState := getAutostartStatePayload()
|
||||||
if old != v {
|
|
||||||
broadcastAutostartPaused(v)
|
// nur wenn sich der relevante sichtbare Zustand geändert hat -> pushen
|
||||||
|
if old != newState {
|
||||||
|
broadcastAutostartPaused()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,11 +84,7 @@ type autostartPauseReq struct {
|
|||||||
func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
|
func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeAutostartState(w)
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"paused": isAutostartPaused(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
|
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
@ -96,11 +114,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
setAutostartPaused(*val)
|
setAutostartPaused(*val)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeAutostartState(w)
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"paused": isAutostartPaused(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -112,9 +126,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
func writeAutostartState(w http.ResponseWriter) {
|
func writeAutostartState(w http.ResponseWriter) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(getAutostartStatePayload())
|
||||||
"paused": isAutostartPaused(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/autostart/state
|
// GET /api/autostart/state
|
||||||
@ -149,6 +161,13 @@ func autostartResumeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeAutostartState(w)
|
writeAutostartState(w)
|
||||||
return
|
return
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
|
// ✅ Resume blocken, solange Disk-Notbremse aktiv ist
|
||||||
|
if atomic.LoadInt32(&diskEmergency) == 1 {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
http.Error(w, "Autostart durch Speicherplatz-Notbremse gesperrt", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setAutostartPaused(false)
|
setAutostartPaused(false)
|
||||||
writeAutostartState(w)
|
writeAutostartState(w)
|
||||||
return
|
return
|
||||||
@ -176,7 +195,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("X-Accel-Buffering", "no") // wichtig falls Proxy/Nginx
|
w.Header().Set("X-Accel-Buffering", "no") // wichtig falls Proxy/Nginx
|
||||||
|
|
||||||
ch := make(chan bool, 1)
|
ch := make(chan autostartStatePayload, 1)
|
||||||
|
|
||||||
// subscribe
|
// subscribe
|
||||||
autostartSubsMu.Lock()
|
autostartSubsMu.Lock()
|
||||||
@ -191,10 +210,12 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
close(ch)
|
close(ch)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
send := func(paused bool) {
|
send := func(state autostartStatePayload) {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"paused": paused,
|
"paused": state.Paused,
|
||||||
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
"pausedByUser": state.PausedByUser,
|
||||||
|
"pausedByDisk": state.PausedByDisk,
|
||||||
|
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
}
|
}
|
||||||
|
|
||||||
b, _ := json.Marshal(payload)
|
b, _ := json.Marshal(payload)
|
||||||
@ -206,7 +227,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initial state sofort senden
|
// initial state sofort senden
|
||||||
send(isAutostartPaused())
|
send(getAutostartStatePayload())
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
hb := time.NewTicker(15 * time.Second)
|
hb := time.NewTicker(15 * time.Second)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -173,6 +173,23 @@ func inFlightBytesForJob(j *RecordJob) uint64 {
|
|||||||
return sizeOfPathBestEffort(j.Output)
|
return sizeOfPathBestEffort(j.Output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func minRelevantInFlightBytes() uint64 {
|
||||||
|
s := getSettings()
|
||||||
|
|
||||||
|
// Nur wenn Auto-Delete kleine Downloads aktiv ist und eine sinnvolle Schwelle gesetzt ist
|
||||||
|
if !s.AutoDeleteSmallDownloads {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mb := s.AutoDeleteSmallDownloadsBelowMB
|
||||||
|
if mb <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MB -> Bytes (MiB passend zum restlichen Code mit GiB)
|
||||||
|
return uint64(mb) * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
const giB = uint64(1024 * 1024 * 1024)
|
const giB = uint64(1024 * 1024 * 1024)
|
||||||
|
|
||||||
// computeDiskThresholds:
|
// computeDiskThresholds:
|
||||||
@ -207,6 +224,7 @@ func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseN
|
|||||||
// Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve.
|
// Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve.
|
||||||
func sumInFlightBytes() uint64 {
|
func sumInFlightBytes() uint64 {
|
||||||
var sum uint64
|
var sum uint64
|
||||||
|
minKeepBytes := minRelevantInFlightBytes()
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
defer jobsMu.Unlock()
|
defer jobsMu.Unlock()
|
||||||
@ -219,10 +237,19 @@ func sumInFlightBytes() uint64 {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nimm die Datei, die gerade wächst.
|
b := inFlightBytesForJob(j)
|
||||||
// In deinem System ist das typischerweise j.Output (TS oder temporäres Ziel).
|
|
||||||
// Falls du ein separates Feld für "TempTS" o.ä. hast: hier ergänzen.
|
// ✅ Nur "relevante" Dateien berücksichtigen:
|
||||||
sum += inFlightBytesForJob(j)
|
// Wenn Auto-Delete kleine Downloads aktiv ist, zählen wir nur Jobs,
|
||||||
|
// deren aktuelle Dateigröße bereits über der Schwelle liegt.
|
||||||
|
//
|
||||||
|
// Hinweis: Ein Job kann später noch über die Schwelle wachsen.
|
||||||
|
// Diese Logik ist bewusst "weniger konservativ", so wie gewünscht.
|
||||||
|
if minKeepBytes > 0 && b > 0 && b < minKeepBytes {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sum += b
|
||||||
}
|
}
|
||||||
|
|
||||||
return sum
|
return sum
|
||||||
@ -230,8 +257,11 @@ func sumInFlightBytes() uint64 {
|
|||||||
|
|
||||||
// startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser.
|
// startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser.
|
||||||
// Bei wenig freiem Platz:
|
// Bei wenig freiem Platz:
|
||||||
// - Autostart pausieren
|
// - diskEmergency aktivieren (Autostart blockieren)
|
||||||
// - laufende Jobs stoppen (nur Status=running und Phase leer)
|
// - laufende Jobs stoppen
|
||||||
|
//
|
||||||
|
// Bei Erholung (Resume-Schwelle):
|
||||||
|
// - diskEmergency automatisch wieder freigeben
|
||||||
func startDiskSpaceGuard() {
|
func startDiskSpaceGuard() {
|
||||||
t := time.NewTicker(diskGuardInterval)
|
t := time.NewTicker(diskGuardInterval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@ -259,41 +289,73 @@ func startDiskSpaceGuard() {
|
|||||||
// Pause = ceil((2 * inFlight) / GiB)
|
// Pause = ceil((2 * inFlight) / GiB)
|
||||||
// Resume = Pause + 3 GB
|
// Resume = Pause + 3 GB
|
||||||
// pauseNeed/resumeNeed sind die benötigten freien Bytes
|
// pauseNeed/resumeNeed sind die benötigten freien Bytes
|
||||||
pauseGB, resumeGB, inFlight, pauseNeed, _ := computeDiskThresholds()
|
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
|
||||||
|
|
||||||
// Wenn nichts läuft, gibt es nichts zu reservieren.
|
// ✅ diskEmergency NICHT sticky behalten.
|
||||||
// (Optional: Emergency zurücksetzen, damit Autostart wieder frei wird.)
|
// Stattdessen dynamisch mit Hysterese setzen/löschen:
|
||||||
|
//
|
||||||
|
// - triggern bei free < pauseNeed
|
||||||
|
// - freigeben erst bei free >= resumeNeed
|
||||||
|
//
|
||||||
|
// So kann die Notbremse später erneut greifen.
|
||||||
|
|
||||||
|
wasEmergency := atomic.LoadInt32(&diskEmergency) == 1
|
||||||
|
|
||||||
|
// Wenn aktuell nichts läuft, brauchen wir keine Reservierung.
|
||||||
|
// Dann diskEmergency freigeben (falls gesetzt), damit Autostart wieder möglich ist.
|
||||||
|
// (User-Pause bleibt davon unberührt.)
|
||||||
if inFlight == 0 {
|
if inFlight == 0 {
|
||||||
// Kein Auto-Recovery:
|
if wasEmergency {
|
||||||
// Emergency bleibt aktiv, bis manuell zurückgesetzt wird.
|
atomic.StoreInt32(&diskEmergency, 0)
|
||||||
|
broadcastAutostartPaused()
|
||||||
|
fmt.Printf("✅ [disk] Emergency cleared (no in-flight jobs). free=%s (%dB) path=%s\n",
|
||||||
|
formatBytesSI(u64ToI64(free)), free, dir,
|
||||||
|
)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn Emergency aktiv ist, niemals automatisch freigeben.
|
isLowForPause := free < pauseNeed
|
||||||
// (Manueller Reset erforderlich)
|
isHighEnoughForResume := free >= resumeNeed
|
||||||
if atomic.LoadInt32(&diskEmergency) == 1 {
|
|
||||||
|
if !wasEmergency {
|
||||||
|
// Normalzustand: nur triggern, wenn unter Pause-Schwelle
|
||||||
|
if !isLowForPause {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.StoreInt32(&diskEmergency, 1)
|
||||||
|
broadcastAutostartPaused()
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n",
|
||||||
|
formatBytesSI(u64ToI64(free)), free,
|
||||||
|
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
|
||||||
|
pauseGB, resumeGB,
|
||||||
|
formatBytesSI(u64ToI64(inFlight)), inFlight,
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
stopped := stopAllStoppableJobs()
|
||||||
|
if stopped > 0 {
|
||||||
|
fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Normalzustand: solange free >= pauseNeed, nichts tun
|
// ✅ Emergency ist aktiv: nur freigeben, wenn Resume-Schwelle erreicht ist
|
||||||
if free >= pauseNeed {
|
if isHighEnoughForResume {
|
||||||
continue
|
atomic.StoreInt32(&diskEmergency, 0)
|
||||||
}
|
broadcastAutostartPaused()
|
||||||
|
|
||||||
// ✅ Trigger: Notbremse aktivieren, Jobs stoppen
|
fmt.Printf(
|
||||||
atomic.StoreInt32(&diskEmergency, 1)
|
"✅ [disk] Space recovered: free=%s (%dB) (>= %s, %dB, resume=%dGB, inFlight=%s, %dB) -> unblock autostart (path=%s)\n",
|
||||||
fmt.Printf(
|
formatBytesSI(u64ToI64(free)), free,
|
||||||
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n",
|
formatBytesSI(u64ToI64(resumeNeed)), resumeNeed,
|
||||||
formatBytesSI(u64ToI64(free)), free,
|
resumeGB,
|
||||||
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
|
formatBytesSI(u64ToI64(inFlight)), inFlight,
|
||||||
pauseGB, resumeGB,
|
dir,
|
||||||
formatBytesSI(u64ToI64(inFlight)), inFlight,
|
)
|
||||||
dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
stopped := stopAllStoppableJobs()
|
|
||||||
if stopped > 0 {
|
|
||||||
fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@ -1802,6 +1802,38 @@ func max(a, b int) int {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renameWithRetryAggressive(src, dst string) error {
|
||||||
|
// Mehrere kurze Versuche + leichtes Backoff
|
||||||
|
var lastErr error
|
||||||
|
delays := []time.Duration{
|
||||||
|
80 * time.Millisecond,
|
||||||
|
140 * time.Millisecond,
|
||||||
|
220 * time.Millisecond,
|
||||||
|
320 * time.Millisecond,
|
||||||
|
450 * time.Millisecond,
|
||||||
|
650 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, d := range delays {
|
||||||
|
if err := os.Rename(src, dst); err == nil {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
// nur bei Windows SharingViolation lohnt Retry wirklich
|
||||||
|
if runtime.GOOS != "windows" || !isSharingViolation(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vor letztem Sleep nicht mehr warten
|
||||||
|
if i < len(delays)-1 {
|
||||||
|
time.Sleep(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
|
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
|
||||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||||
@ -1938,7 +1970,7 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
// (wir encoden den Token nicht neu — wir speichern Trashname separat in last.json)
|
// (wir encoden den Token nicht neu — wir speichern Trashname separat in last.json)
|
||||||
|
|
||||||
// move mit retry (Windows file-lock robust)
|
// move mit retry (Windows file-lock robust)
|
||||||
if err := renameWithRetry(target, dst); err != nil {
|
if err := renameWithRetryAggressive(target, dst); err != nil {
|
||||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
|
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
|
||||||
return
|
return
|
||||||
@ -2097,7 +2129,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := renameWithRetry(src, dst); err != nil {
|
if err := renameWithRetryAggressive(src, dst); err != nil {
|
||||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
http.Error(w, "restore fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
http.Error(w, "restore fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
||||||
return
|
return
|
||||||
@ -2199,7 +2231,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := renameWithRetry(src, dst); err != nil {
|
if err := renameWithRetryAggressive(src, dst); err != nil {
|
||||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
||||||
return
|
return
|
||||||
@ -2337,7 +2369,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rename mit retry (Windows file-lock)
|
// rename mit retry (Windows file-lock)
|
||||||
if err := renameWithRetry(src, dst); err != nil {
|
if err := renameWithRetryAggressive(src, dst); err != nil {
|
||||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
||||||
return
|
return
|
||||||
@ -2436,7 +2468,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := renameWithRetry(src, dst); err != nil {
|
if err := renameWithRetryAggressive(src, dst); err != nil {
|
||||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||||
http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
|
http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
|
||||||
return
|
return
|
||||||
|
|||||||
1
backend/web/dist/assets/index-B-X4TsOo.css
vendored
1
backend/web/dist/assets/index-B-X4TsOo.css
vendored
File diff suppressed because one or more lines are too long
465
backend/web/dist/assets/index-BZ38s29o.js
vendored
Normal file
465
backend/web/dist/assets/index-BZ38s29o.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-CZMtb58J.css
vendored
Normal file
1
backend/web/dist/assets/index-CZMtb58J.css
vendored
Normal file
File diff suppressed because one or more lines are too long
422
backend/web/dist/assets/index-DNoPI-qJ.js
vendored
422
backend/web/dist/assets/index-DNoPI-qJ.js
vendored
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>App</title>
|
<title>App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DNoPI-qJ.js"></script>
|
<script type="module" crossorigin src="/assets/index-BZ38s29o.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B-X4TsOo.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CZMtb58J.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -410,11 +410,6 @@ export default function App() {
|
|||||||
const donePrefetchRef = useRef<DonePrefetch | null>(null)
|
const donePrefetchRef = useRef<DonePrefetch | null>(null)
|
||||||
const donePrefetchInFlightRef = useRef(false)
|
const donePrefetchInFlightRef = useRef(false)
|
||||||
|
|
||||||
// ✅ verhindert "pending forever": immer nur 1 done-fetch gleichzeitig
|
|
||||||
const doneFetchAbortRef = useRef<AbortController | null>(null)
|
|
||||||
const doneFetchInFlightRef = useRef(false)
|
|
||||||
|
|
||||||
|
|
||||||
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
|
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
|
||||||
|
|
||||||
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
|
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
|
||||||
@ -450,8 +445,16 @@ export default function App() {
|
|||||||
}, [doneSort])
|
}, [doneSort])
|
||||||
|
|
||||||
const loadDoneCount = useCallback(async () => {
|
const loadDoneCount = useCallback(async () => {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// ✅ harte Dedupe-Schranke gegen Bursts
|
||||||
|
if (doneCountInFlightRef.current) return
|
||||||
|
if (now - doneCountLastAtRef.current < 500) return
|
||||||
|
|
||||||
|
doneCountInFlightRef.current = true
|
||||||
|
doneCountLastAtRef.current = now
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ✅ leichtgewichtiger Endpoint (siehe Backend unten)
|
|
||||||
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
|
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
||||||
@ -463,9 +466,14 @@ export default function App() {
|
|||||||
setLastHeaderUpdateAtMs(Date.now())
|
setLastHeaderUpdateAtMs(Date.now())
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
} finally {
|
||||||
|
doneCountInFlightRef.current = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const doneCountInFlightRef = useRef(false)
|
||||||
|
const doneCountLastAtRef = useRef(0)
|
||||||
|
|
||||||
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
|
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
|
||||||
const finishedReloadTimerRef = useRef<number | null>(null)
|
const finishedReloadTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
@ -1236,80 +1244,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [loadDoneCount])
|
}, [loadDoneCount])
|
||||||
|
|
||||||
const refreshDoneNow = useCallback(
|
|
||||||
async (preferPage?: number) => {
|
|
||||||
// ✅ wenn noch ein done-fetch läuft: abbrechen (sonst stauen sich Requests)
|
|
||||||
if (doneFetchInFlightRef.current) {
|
|
||||||
doneFetchAbortRef.current?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
const ac = new AbortController()
|
|
||||||
doneFetchAbortRef.current = ac
|
|
||||||
doneFetchInFlightRef.current = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const wanted = typeof preferPage === 'number' ? preferPage : donePage
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
|
|
||||||
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
|
|
||||||
{ cache: 'no-store' as any, signal: ac.signal }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
|
|
||||||
const data = await res.json().catch(() => null)
|
|
||||||
|
|
||||||
const items = Array.isArray(data?.items)
|
|
||||||
? (data.items as RecordJob[])
|
|
||||||
: Array.isArray(data)
|
|
||||||
? (data as RecordJob[])
|
|
||||||
: []
|
|
||||||
|
|
||||||
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
|
|
||||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
|
|
||||||
|
|
||||||
setDoneCount(count)
|
|
||||||
|
|
||||||
const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
|
|
||||||
const target = Math.min(Math.max(1, wanted), maxPage)
|
|
||||||
if (target !== donePage) setDonePage(target)
|
|
||||||
|
|
||||||
// Wenn wir auf eine andere Page clampen mussten: die richtige Page nachladen
|
|
||||||
if (target !== wanted) {
|
|
||||||
const res2 = await fetch(
|
|
||||||
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
|
|
||||||
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
|
|
||||||
{ cache: 'no-store' as any, signal: ac.signal }
|
|
||||||
)
|
|
||||||
if (!res2.ok) throw new Error(`HTTP ${res2.status}`)
|
|
||||||
const data2 = await res2.json().catch(() => null)
|
|
||||||
const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : []
|
|
||||||
setDoneJobs(items2)
|
|
||||||
} else {
|
|
||||||
setDoneJobs(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
|
||||||
} catch (e: any) {
|
|
||||||
// Abort ist ok
|
|
||||||
if (String(e?.name) !== 'AbortError') {
|
|
||||||
// optional: console.debug('[DONE] refresh failed', e)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// ✅ Nur der "aktuelle" Request darf den InFlight-Status zurücksetzen.
|
|
||||||
// Sonst kann ein älterer (abgebrochener) Request einen neueren überschreiben.
|
|
||||||
const isCurrent = doneFetchAbortRef.current === ac
|
|
||||||
if (isCurrent) {
|
|
||||||
doneFetchAbortRef.current = null
|
|
||||||
doneFetchInFlightRef.current = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[donePage, doneSort]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTab !== 'finished') return
|
if (selectedTab !== 'finished') return
|
||||||
|
|
||||||
@ -1498,18 +1432,17 @@ export default function App() {
|
|||||||
const e = ev as CustomEvent<{ delta?: number }>
|
const e = ev as CustomEvent<{ delta?: number }>
|
||||||
const delta = Number(e.detail?.delta ?? 0)
|
const delta = Number(e.detail?.delta ?? 0)
|
||||||
|
|
||||||
if (!Number.isFinite(delta) || delta === 0) {
|
if (Number.isFinite(delta) && delta !== 0) {
|
||||||
void loadDoneCount()
|
setDoneCount((c) => Math.max(0, c + delta))
|
||||||
requestFinishedReload()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Tabs sofort updaten (optimistisch)
|
// Count darf immer aktualisiert werden (Badge/Header)
|
||||||
setDoneCount((c) => Math.max(0, c + delta))
|
|
||||||
|
|
||||||
// ✅ danach server-truth holen + ALL reload
|
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
requestFinishedReload()
|
|
||||||
|
// ✅ Nur Finished-Tab wirklich neu laden
|
||||||
|
if (selectedTabRef.current === 'finished') {
|
||||||
|
requestFinishedReload()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
|
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||||
@ -1570,124 +1503,160 @@ export default function App() {
|
|||||||
[startUrl, notify]
|
[startUrl, notify]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type FinishedFileActionKind = 'delete' | 'keep' | 'rename'
|
||||||
|
|
||||||
|
async function runFinishedFileAction<T>(opts: {
|
||||||
|
kind: FinishedFileActionKind
|
||||||
|
file: string
|
||||||
|
run: () => Promise<T>
|
||||||
|
onSuccess?: (result: T) => void | Promise<void>
|
||||||
|
onError?: (err: unknown) => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
const { kind, file, run, onSuccess, onError } = opts
|
||||||
|
|
||||||
|
// Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row)
|
||||||
|
if (kind === 'delete' || kind === 'keep') {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await run()
|
||||||
|
|
||||||
|
if (kind === 'delete' || kind === 'keep') {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSuccess?.(result)
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
if (kind === 'delete' || kind === 'keep') {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await onError?.(e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteJobWithUndo = useCallback(
|
const handleDeleteJobWithUndo = useCallback(
|
||||||
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
|
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ✅ Wichtig: JSON-Response lesen, weil undoToken dort drin steckt
|
const data = await runFinishedFileAction<{ undoToken?: string }>({
|
||||||
const data = await apiJSON<{ undoToken?: string }>(
|
kind: 'delete',
|
||||||
`/api/record/delete?file=${encodeURIComponent(file)}`,
|
file,
|
||||||
{ method: 'POST' }
|
run: () =>
|
||||||
)
|
apiJSON<{ undoToken?: string }>(
|
||||||
|
`/api/record/delete?file=${encodeURIComponent(file)}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
),
|
||||||
|
onSuccess: async () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
// ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch)
|
||||||
|
setDoneJobs((prev) => {
|
||||||
|
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
|
||||||
|
|
||||||
window.dispatchEvent(
|
const need = DONE_PAGE_SIZE - filtered.length
|
||||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
|
if (need <= 0) return filtered
|
||||||
)
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
||||||
setDoneJobs((prev) => {
|
const buf = donePrefetchRef.current
|
||||||
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
|
|
||||||
|
|
||||||
// ✅ sofort auffüllen, wenn wir Platz haben
|
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
|
||||||
const need = DONE_PAGE_SIZE - filtered.length
|
return filtered
|
||||||
if (need <= 0) return filtered
|
}
|
||||||
|
|
||||||
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
const next: RecordJob[] = [...filtered]
|
||||||
const buf = donePrefetchRef.current
|
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
|
||||||
|
|
||||||
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
|
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
|
||||||
return filtered
|
const cand = buf.items.shift()!
|
||||||
}
|
const id = String(cand.id || baseName(cand.output || '')).trim()
|
||||||
|
if (!id || used.has(id)) continue
|
||||||
|
used.add(id)
|
||||||
|
next.push(cand)
|
||||||
|
}
|
||||||
|
|
||||||
const next: RecordJob[] = [...filtered]
|
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
|
||||||
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
|
// ✅ Count sofort optimistisch runter
|
||||||
const cand = buf.items.shift()!
|
setDoneCount((c) => Math.max(0, c - 1))
|
||||||
const id = String(cand.id || baseName(cand.output || '')).trim()
|
|
||||||
if (!id || used.has(id)) continue
|
|
||||||
used.add(id)
|
|
||||||
next.push(cand)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buffer zurückschreiben (mit verkürzter items-Liste)
|
// ✅ Running-/Player-State bereinigen (falls offen)
|
||||||
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
|
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||||
|
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||||
|
|
||||||
return next
|
// ✅ Prefetch wieder nachfüllen
|
||||||
})
|
void prefetchDonePage(donePage + 1)
|
||||||
|
}, 320)
|
||||||
// ✅ Count sofort optimistisch runter
|
},
|
||||||
setDoneCount((c) => Math.max(0, c - 1))
|
onError: async () => {
|
||||||
|
notify.error('Löschen fehlgeschlagen', file)
|
||||||
// ✅ Player / jobs cleanup wie bei dir
|
},
|
||||||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
})
|
||||||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
|
||||||
|
|
||||||
// ✅ Buffer direkt wieder nachfüllen (background)
|
|
||||||
void prefetchDonePage(donePage + 1)
|
|
||||||
}, 320)
|
|
||||||
|
|
||||||
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
|
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
|
||||||
return undoToken ? { undoToken } : {} // ✅ kein null mehr
|
return undoToken ? { undoToken } : {}
|
||||||
} catch (e: any) {
|
} catch {
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
|
|
||||||
)
|
|
||||||
notify.error('Löschen fehlgeschlagen: ', file)
|
|
||||||
return // ✅ void statt null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[notify, refreshDoneNow]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDeleteJob = useCallback(
|
|
||||||
async (job: RecordJob): Promise<void> => {
|
|
||||||
await handleDeleteJobWithUndo(job)
|
|
||||||
},
|
|
||||||
[handleDeleteJobWithUndo]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleKeepJob = useCallback(
|
|
||||||
async (job: RecordJob) => {
|
|
||||||
const file = baseName(job.output || '')
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } }))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
|
||||||
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } }))
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
|
||||||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
|
||||||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
|
||||||
}, 320)
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
|
|
||||||
notify.error('Keep fehlgeschlagen', file)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notify]
|
[runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleKeepJob = useCallback(
|
||||||
|
async (job: RecordJob): Promise<void> => {
|
||||||
|
const file = baseName(job.output || '')
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runFinishedFileAction({
|
||||||
|
kind: 'keep',
|
||||||
|
file,
|
||||||
|
run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
// ✅ Entfernt aus Finished + Running + Player
|
||||||
|
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||||
|
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||||
|
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||||
|
|
||||||
|
// ✅ Count ebenfalls runter (Keep entfernt den Eintrag aus "finished")
|
||||||
|
setDoneCount((c) => Math.max(0, c - 1))
|
||||||
|
|
||||||
|
// Optional: nächste Seite vorladen (schadet nicht)
|
||||||
|
void prefetchDonePage(donePage + 1)
|
||||||
|
}, 320)
|
||||||
|
},
|
||||||
|
onError: async () => {
|
||||||
|
notify.error('Keep fehlgeschlagen', file)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[runFinishedFileAction, notify, donePage, prefetchDonePage]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleToggleHot = useCallback(
|
const handleToggleHot = useCallback(
|
||||||
async (job: RecordJob) => {
|
async (job: RecordJob): Promise<void | { ok: boolean; oldFile: string; newFile: string }> => {
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
|
// ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
|
||||||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||||||
// kurze Pause hilft in der Praxis, wenn Video.js/Browser noch “dran” hängt
|
|
||||||
await new Promise((r) => window.setTimeout(r, 60))
|
await new Promise((r) => window.setTimeout(r, 60))
|
||||||
|
|
||||||
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
|
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
|
||||||
@ -1695,30 +1664,38 @@ export default function App() {
|
|||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
)
|
)
|
||||||
|
|
||||||
// ✅ FinishedDownloads lokal syncen (wenn Rename außerhalb der Liste passiert, z.B. im Player)
|
const oldFile = baseName(res.oldFile || file) || file
|
||||||
|
const newFile = baseName(res.newFile || '') || ''
|
||||||
|
if (!newFile) throw new Error('Backend lieferte keinen neuen Dateinamen zurück')
|
||||||
|
|
||||||
|
// ✅ FinishedDownloads lokal synchronisieren (wichtig bei Rename aus Player/Details)
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('finished-downloads:rename', {
|
new CustomEvent('finished-downloads:rename', {
|
||||||
detail: { oldFile: res.oldFile, newFile: res.newFile },
|
detail: { oldFile, newFile },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const apply = (out: string) => replaceBasename(out || '', res.newFile)
|
const apply = (out: string) => replaceBasename(out || '', newFile)
|
||||||
|
|
||||||
// ✅ 1) Player immer updaten
|
// ✅ Player nur updaten, wenn wirklich derselbe Job / dieselbe Datei
|
||||||
setPlayerJob((prev) => (prev ? { ...prev, output: apply(prev.output || '') } : prev))
|
setPlayerJob((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const match = prev.id === job.id || baseName(prev.output || '') === oldFile
|
||||||
|
return match ? { ...prev, output: apply(prev.output || '') } : prev
|
||||||
|
})
|
||||||
|
|
||||||
// ✅ 2) doneJobs über ID (Fallback: basename)
|
// ✅ doneJobs über ID (Fallback basename)
|
||||||
setDoneJobs((prev) =>
|
setDoneJobs((prev) =>
|
||||||
prev.map((j) => {
|
prev.map((j) => {
|
||||||
const match = j.id === job.id || baseName(j.output || '') === file
|
const match = j.id === job.id || baseName(j.output || '') === oldFile
|
||||||
return match ? { ...j, output: apply(j.output || '') } : j
|
return match ? { ...j, output: apply(j.output || '') } : j
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// ✅ 3) jobs (/record/list) über ID (Fallback: basename)
|
// ✅ jobs (/record/list) über ID (Fallback basename)
|
||||||
setJobs((prev) =>
|
setJobs((prev) =>
|
||||||
prev.map((j) => {
|
prev.map((j) => {
|
||||||
const match = j.id === job.id || baseName(j.output || '') === file
|
const match = j.id === job.id || baseName(j.output || '') === oldFile
|
||||||
return match ? { ...j, output: apply(j.output || '') } : j
|
return match ? { ...j, output: apply(j.output || '') } : j
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@ -1732,6 +1709,13 @@ export default function App() {
|
|||||||
[notify]
|
[notify]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleDeleteJob = useCallback(
|
||||||
|
async (job: RecordJob): Promise<void> => {
|
||||||
|
await handleDeleteJobWithUndo(job)
|
||||||
|
},
|
||||||
|
[handleDeleteJobWithUndo]
|
||||||
|
)
|
||||||
|
|
||||||
// --- flags patch (wie bei dir) ---
|
// --- flags patch (wie bei dir) ---
|
||||||
async function patchModelFlags(patch: any): Promise<any | null> {
|
async function patchModelFlags(patch: any): Promise<any | null> {
|
||||||
const res = await fetch('/api/models/flags', {
|
const res = await fetch('/api/models/flags', {
|
||||||
@ -2471,7 +2455,7 @@ export default function App() {
|
|||||||
// ✅ war irgendwann schon mal online (vor diesem Poll)?
|
// ✅ war irgendwann schon mal online (vor diesem Poll)?
|
||||||
const hadEverBeenOnline = Boolean(everOnline[keyLower])
|
const hadEverBeenOnline = Boolean(everOnline[keyLower])
|
||||||
|
|
||||||
const name = String((room as any)?.username ?? keyLower).trim() || keyLower
|
const modelName = String((room as any)?.username ?? keyLower).trim() || keyLower
|
||||||
const imageUrl = String((room as any)?.image_url ?? '').trim()
|
const imageUrl = String((room as any)?.image_url ?? '').trim()
|
||||||
|
|
||||||
// immer merken: jetzt ist es online
|
// immer merken: jetzt ist es online
|
||||||
@ -2481,10 +2465,17 @@ export default function App() {
|
|||||||
const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow)
|
const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow)
|
||||||
if (becamePublicFromWaiting) {
|
if (becamePublicFromWaiting) {
|
||||||
if (notificationsOn) {
|
if (notificationsOn) {
|
||||||
notify.info(name, 'ist wieder online.', {
|
notify.info(modelName, 'ist wieder online.', {
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imageAlt: `${name} Vorschau`,
|
imageAlt: `${modelName} Vorschau`,
|
||||||
durationMs: 5500,
|
durationMs: 5500,
|
||||||
|
onClick: () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('open-model-details', {
|
||||||
|
detail: { modelKey: modelName },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2500,12 +2491,19 @@ export default function App() {
|
|||||||
// Startup-Spam vermeiden
|
// Startup-Spam vermeiden
|
||||||
if (notificationsOn && !isInitial) {
|
if (notificationsOn && !isInitial) {
|
||||||
notify.info(
|
notify.info(
|
||||||
name,
|
modelName,
|
||||||
cameBackFromOffline ? 'ist wieder online.' : 'ist online.',
|
cameBackFromOffline ? 'ist wieder online.' : 'ist online.',
|
||||||
{
|
{
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imageAlt: `${name} Vorschau`,
|
imageAlt: `${modelName} Vorschau`,
|
||||||
durationMs: 5500,
|
durationMs: 5500,
|
||||||
|
onClick: () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('open-model-details', {
|
||||||
|
detail: { modelKey: modelName },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -2673,7 +2671,7 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<header className="z-30 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
|
<header className="z-30 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3 sm:py-4 space-y-2 sm:space-y-3">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3">
|
||||||
<div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
|
<div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -2813,7 +2811,7 @@ export default function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="sm:hidden sticky top-0 z-20 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
|
<div className="sm:hidden sticky top-0 z-20 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-2">
|
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
|
||||||
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
|
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2849,6 +2847,7 @@ export default function App() {
|
|||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onToggleLike={handleToggleLike}
|
onToggleLike={handleToggleLike}
|
||||||
onToggleWatch={handleToggleWatch}
|
onToggleWatch={handleToggleWatch}
|
||||||
|
onKeepJob={handleKeepJob}
|
||||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||||
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
||||||
teaserAudio={Boolean(recSettings.teaserAudio)}
|
teaserAudio={Boolean(recSettings.teaserAudio)}
|
||||||
|
|||||||
@ -43,7 +43,14 @@ export default function ButtonGroup({
|
|||||||
const s = sizeMap[size]
|
const s = sizeMap[size]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cn('isolate inline-flex rounded-md shadow-xs dark:shadow-none', className)} role="group" aria-label={ariaLabel}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
'isolate inline-flex rounded-md shadow-xs dark:shadow-none ring-1 ring-gray-300 dark:ring-gray-700 overflow-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="group"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
{items.map((it, idx) => {
|
{items.map((it, idx) => {
|
||||||
const active = it.id === value
|
const active = it.id === value
|
||||||
const isFirst = idx === 0
|
const isFirst = idx === 0
|
||||||
@ -59,16 +66,14 @@ export default function ButtonGroup({
|
|||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors',
|
'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors',
|
||||||
!isFirst && '-ml-px',
|
!isFirst && 'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700',
|
||||||
isFirst && 'rounded-l-md',
|
isFirst && 'rounded-l-md',
|
||||||
isLast && 'rounded-r-md',
|
isLast && 'rounded-r-md',
|
||||||
|
|
||||||
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
|
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
|
||||||
active
|
active
|
||||||
? 'bg-indigo-100 text-indigo-800 inset-ring-1 inset-ring-indigo-300 hover:bg-indigo-200 ' +
|
? 'bg-indigo-100 text-indigo-800 hover:bg-indigo-200 dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50'
|
||||||
'dark:bg-indigo-500/40 dark:text-indigo-100 dark:inset-ring-indigo-400/50 dark:hover:bg-indigo-500/50'
|
: 'bg-white text-gray-900 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:hover:bg-white/20',
|
||||||
: 'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 ' +
|
|
||||||
'dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
|
|
||||||
|
|
||||||
// Disabled
|
// Disabled
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
|||||||
@ -26,7 +26,11 @@ type WaitingModelRow = {
|
|||||||
currentShow?: string // public / private / hidden / away / unknown
|
currentShow?: string // public / private / hidden / away / unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
type AutostartState = { paused?: boolean }
|
type AutostartState = {
|
||||||
|
paused?: boolean
|
||||||
|
pausedByUser?: boolean
|
||||||
|
pausedByDisk?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobs: RecordJob[]
|
jobs: RecordJob[]
|
||||||
@ -704,6 +708,20 @@ const isTerminalStatus = (status?: unknown) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiskEmergencyBadge() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="
|
||||||
|
inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold
|
||||||
|
bg-red-100 text-red-800 ring-1 ring-red-200
|
||||||
|
dark:bg-red-500/15 dark:text-red-200 dark:ring-red-400/25
|
||||||
|
"
|
||||||
|
title="Speicherplatz-Notbremse aktiv: Autostart gesperrt und Downloads wurden gestoppt"
|
||||||
|
>
|
||||||
|
Speicherplatz-Notbremse aktiv
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Downloads({
|
export default function Downloads({
|
||||||
jobs,
|
jobs,
|
||||||
@ -725,15 +743,30 @@ export default function Downloads({
|
|||||||
const [stopAllBusy, setStopAllBusy] = useState(false)
|
const [stopAllBusy, setStopAllBusy] = useState(false)
|
||||||
|
|
||||||
const [watchedPaused, setWatchedPaused] = useState(false)
|
const [watchedPaused, setWatchedPaused] = useState(false)
|
||||||
|
const [, setWatchedPausedByUser] = useState(false)
|
||||||
|
const [watchedPausedByDisk, setWatchedPausedByDisk] = useState(false)
|
||||||
|
|
||||||
const watchedPausedRef = useRef<boolean | null>(null)
|
const watchedPausedRef = useRef<boolean | null>(null)
|
||||||
|
const watchedPausedByUserRef = useRef<boolean | null>(null)
|
||||||
|
const watchedPausedByDiskRef = useRef<boolean | null>(null)
|
||||||
|
|
||||||
const [watchedBusy, setWatchedBusy] = useState(false)
|
const [watchedBusy, setWatchedBusy] = useState(false)
|
||||||
|
|
||||||
const refreshWatchedState = useCallback(async () => {
|
const refreshWatchedState = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
|
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
|
||||||
const next = Boolean(s?.paused)
|
|
||||||
watchedPausedRef.current = next
|
const nextPaused = Boolean(s?.paused)
|
||||||
setWatchedPaused(next)
|
const nextPausedByUser = Boolean(s?.pausedByUser)
|
||||||
|
const nextPausedByDisk = Boolean(s?.pausedByDisk)
|
||||||
|
|
||||||
|
watchedPausedRef.current = nextPaused
|
||||||
|
watchedPausedByUserRef.current = nextPausedByUser
|
||||||
|
watchedPausedByDiskRef.current = nextPausedByDisk
|
||||||
|
|
||||||
|
setWatchedPaused(nextPaused)
|
||||||
|
setWatchedPausedByUser(nextPausedByUser)
|
||||||
|
setWatchedPausedByDisk(nextPausedByDisk)
|
||||||
} catch {
|
} catch {
|
||||||
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen
|
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen
|
||||||
}
|
}
|
||||||
@ -748,10 +781,24 @@ export default function Downloads({
|
|||||||
'/api/autostart/state/stream',
|
'/api/autostart/state/stream',
|
||||||
'autostart',
|
'autostart',
|
||||||
(data) => {
|
(data) => {
|
||||||
const next = Boolean((data as any)?.paused)
|
const nextPaused = Boolean((data as any)?.paused)
|
||||||
if (watchedPausedRef.current === next) return
|
const nextPausedByUser = Boolean((data as any)?.pausedByUser)
|
||||||
watchedPausedRef.current = next
|
const nextPausedByDisk = Boolean((data as any)?.pausedByDisk)
|
||||||
setWatchedPaused(next)
|
|
||||||
|
const unchanged =
|
||||||
|
watchedPausedRef.current === nextPaused &&
|
||||||
|
watchedPausedByUserRef.current === nextPausedByUser &&
|
||||||
|
watchedPausedByDiskRef.current === nextPausedByDisk
|
||||||
|
|
||||||
|
if (unchanged) return
|
||||||
|
|
||||||
|
watchedPausedRef.current = nextPaused
|
||||||
|
watchedPausedByUserRef.current = nextPausedByUser
|
||||||
|
watchedPausedByDiskRef.current = nextPausedByDisk
|
||||||
|
|
||||||
|
setWatchedPaused(nextPaused)
|
||||||
|
setWatchedPausedByUser(nextPausedByUser)
|
||||||
|
setWatchedPausedByDisk(nextPausedByDisk)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -762,29 +809,38 @@ export default function Downloads({
|
|||||||
|
|
||||||
const pauseWatched = useCallback(async () => {
|
const pauseWatched = useCallback(async () => {
|
||||||
if (watchedBusy || watchedPaused) return
|
if (watchedBusy || watchedPaused) return
|
||||||
|
|
||||||
setWatchedBusy(true)
|
setWatchedBusy(true)
|
||||||
try {
|
try {
|
||||||
await fetch('/api/autostart/pause', { method: 'POST' })
|
const res = await fetch('/api/autostart/pause', { method: 'POST' })
|
||||||
setWatchedPaused(true)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
|
||||||
|
// State kommt normalerweise per SSE; fallback refresh:
|
||||||
|
await refreshWatchedState()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setWatchedBusy(false)
|
setWatchedBusy(false)
|
||||||
}
|
}
|
||||||
}, [watchedBusy, watchedPaused])
|
}, [watchedBusy, watchedPaused, refreshWatchedState])
|
||||||
|
|
||||||
const resumeWatched = useCallback(async () => {
|
const resumeWatched = useCallback(async () => {
|
||||||
if (watchedBusy || !watchedPaused) return
|
// ✅ Bei Disk-Notbremse kein Resume erlauben
|
||||||
|
if (watchedBusy || !watchedPaused || watchedPausedByDisk) return
|
||||||
|
|
||||||
setWatchedBusy(true)
|
setWatchedBusy(true)
|
||||||
try {
|
try {
|
||||||
await fetch('/api/autostart/resume', { method: 'POST' })
|
const res = await fetch('/api/autostart/resume', { method: 'POST' })
|
||||||
setWatchedPaused(false)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
|
||||||
|
// State kommt normalerweise per SSE; fallback refresh:
|
||||||
|
await refreshWatchedState()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setWatchedBusy(false)
|
setWatchedBusy(false)
|
||||||
}
|
}
|
||||||
}, [watchedBusy, watchedPaused])
|
}, [watchedBusy, watchedPaused, watchedPausedByDisk, refreshWatchedState])
|
||||||
|
|
||||||
// ✅ Merkt sich: für diese Jobs wurde "Stop" bereits angefordert (z.B. via "Alle stoppen")
|
// ✅ Merkt sich: für diese Jobs wurde "Stop" bereits angefordert (z.B. via "Alle stoppen")
|
||||||
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
|
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
|
||||||
@ -1333,14 +1389,21 @@ export default function Downloads({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={watchedPaused ? 'secondary' : 'primary'}
|
variant={watchedPaused ? 'secondary' : 'primary'}
|
||||||
disabled={watchedBusy}
|
disabled={watchedBusy || watchedPausedByDisk}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
if (watchedPausedByDisk) return
|
||||||
void (watchedPaused ? resumeWatched() : pauseWatched())
|
void (watchedPaused ? resumeWatched() : pauseWatched())
|
||||||
}}
|
}}
|
||||||
className="hidden sm:inline-flex"
|
className="hidden sm:inline-flex"
|
||||||
title={watchedPaused ? 'Autostart fortsetzen' : 'Autostart pausieren'}
|
title={
|
||||||
|
watchedPausedByDisk
|
||||||
|
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
|
||||||
|
: watchedPaused
|
||||||
|
? 'Autostart fortsetzen'
|
||||||
|
: 'Autostart pausieren'
|
||||||
|
}
|
||||||
leadingIcon={
|
leadingIcon={
|
||||||
watchedPaused
|
watchedPaused
|
||||||
? <PauseIcon className="size-4 shrink-0" />
|
? <PauseIcon className="size-4 shrink-0" />
|
||||||
@ -1377,8 +1440,9 @@ export default function Downloads({
|
|||||||
<div className="mt-3 grid gap-4">
|
<div className="mt-3 grid gap-4">
|
||||||
{downloadJobRows.length > 0 ? (
|
{downloadJobRows.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
Downloads ({downloadJobRows.length})
|
<span>Downloads ({downloadJobRows.length})</span>
|
||||||
|
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
|
||||||
</div>
|
</div>
|
||||||
{downloadJobRows.map((r) => (
|
{downloadJobRows.map((r) => (
|
||||||
<DownloadsCardRow
|
<DownloadsCardRow
|
||||||
@ -1455,8 +1519,9 @@ export default function Downloads({
|
|||||||
<div className="mt-3 space-y-4">
|
<div className="mt-3 space-y-4">
|
||||||
{downloadJobRows.length > 0 ? (
|
{downloadJobRows.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
Downloads ({downloadJobRows.length})
|
<span>Downloads ({downloadJobRows.length})</span>
|
||||||
|
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
rows={downloadJobRows}
|
rows={downloadJobRows}
|
||||||
|
|||||||
@ -57,6 +57,7 @@ type Props = {
|
|||||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onKeepJob?: (job: RecordJob) => void | Promise<void>
|
||||||
doneTotal: number
|
doneTotal: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@ -195,6 +196,112 @@ const sizeBytesOf = (job: RecordJob): number | null => {
|
|||||||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorTextOf(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message || String(err)
|
||||||
|
return String(err ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeFileInUseError(err: unknown): boolean {
|
||||||
|
const s = errorTextOf(err).toLowerCase()
|
||||||
|
return (
|
||||||
|
s.includes('wird gerade verwendet') ||
|
||||||
|
s.includes('wird gerade abgespielt') ||
|
||||||
|
s.includes('sharing violation') ||
|
||||||
|
s.includes('used by another process') ||
|
||||||
|
s.includes('file in use') ||
|
||||||
|
s.includes('409')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit) {
|
||||||
|
const res = await fetch(input, init)
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
const msg = (text || `HTTP ${res.status}`).trim()
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueuedMutationTask = {
|
||||||
|
id: string
|
||||||
|
run: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMutationQueue() {
|
||||||
|
const queueRef = React.useRef<QueuedMutationTask[]>([])
|
||||||
|
const runningRef = React.useRef(false)
|
||||||
|
const scheduledRef = React.useRef(false)
|
||||||
|
|
||||||
|
// verhindert Doppel-Klick/Doppel-Swipe auf dieselbe Aktion
|
||||||
|
const pendingIdsRef = React.useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const schedulePump = React.useCallback(() => {
|
||||||
|
if (scheduledRef.current) return
|
||||||
|
scheduledRef.current = true
|
||||||
|
|
||||||
|
const kick = () => {
|
||||||
|
scheduledRef.current = false
|
||||||
|
void pump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// best-effort "im Hintergrund"
|
||||||
|
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||||
|
;(window as any).requestIdleCallback(kick, { timeout: 250 })
|
||||||
|
} else {
|
||||||
|
setTimeout(kick, 0)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pump = React.useCallback(async () => {
|
||||||
|
if (runningRef.current) return
|
||||||
|
runningRef.current = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (queueRef.current.length > 0) {
|
||||||
|
const task = queueRef.current.shift()
|
||||||
|
if (!task) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
await task.run()
|
||||||
|
} finally {
|
||||||
|
pendingIdsRef.current.delete(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield zwischen Tasks -> UI bleibt responsiver
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runningRef.current = false
|
||||||
|
|
||||||
|
// falls währenddessen neue Tasks reinkamen
|
||||||
|
if (queueRef.current.length > 0) {
|
||||||
|
schedulePump()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [schedulePump])
|
||||||
|
|
||||||
|
const enqueue = React.useCallback((id: string, run: () => Promise<void>) => {
|
||||||
|
if (!id) return false
|
||||||
|
if (pendingIdsRef.current.has(id)) return false
|
||||||
|
|
||||||
|
pendingIdsRef.current.add(id)
|
||||||
|
queueRef.current.push({ id, run })
|
||||||
|
schedulePump()
|
||||||
|
return true
|
||||||
|
}, [schedulePump])
|
||||||
|
|
||||||
|
const isQueued = React.useCallback((id: string) => {
|
||||||
|
return pendingIdsRef.current.has(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { enqueue, isQueued }
|
||||||
|
}
|
||||||
|
|
||||||
export default function FinishedDownloads({
|
export default function FinishedDownloads({
|
||||||
jobs,
|
jobs,
|
||||||
doneJobs,
|
doneJobs,
|
||||||
@ -207,6 +314,7 @@ export default function FinishedDownloads({
|
|||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
|
onKeepJob,
|
||||||
doneTotal,
|
doneTotal,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@ -226,6 +334,8 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const notify = useNotify()
|
const notify = useNotify()
|
||||||
|
|
||||||
|
const mutationQueue = useMutationQueue()
|
||||||
|
|
||||||
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
||||||
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
||||||
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
|
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
|
||||||
@ -240,6 +350,7 @@ export default function FinishedDownloads({
|
|||||||
const [isLoading, setIsLoading] = React.useState(false)
|
const [isLoading, setIsLoading] = React.useState(false)
|
||||||
|
|
||||||
const refillInFlightRef = React.useRef(false)
|
const refillInFlightRef = React.useRef(false)
|
||||||
|
const refillQueuedWhileInFlightRef = React.useRef(false)
|
||||||
|
|
||||||
type UndoAction =
|
type UndoAction =
|
||||||
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
|
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
|
||||||
@ -382,12 +493,19 @@ export default function FinishedDownloads({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const globalFilterActive = searchTokens.length > 0 || activeTagSet.size > 0
|
// ✅ Mobile/UX: globales "all=1" erst bei sinnvoller Suche triggern
|
||||||
|
const searchActiveForGlobalFetch =
|
||||||
|
activeTagSet.size > 0 ||
|
||||||
|
searchTokens.some((t) => t.length >= 2)
|
||||||
|
|
||||||
|
const globalFilterActive = searchActiveForGlobalFetch
|
||||||
const effectiveAllMode = globalFilterActive || allMode
|
const effectiveAllMode = globalFilterActive || allMode
|
||||||
|
|
||||||
const fetchAllDoneJobs = useCallback(
|
const fetchAllDoneJobs = useCallback(
|
||||||
async (signal?: AbortSignal) => {
|
async (signal?: AbortSignal) => {
|
||||||
setIsLoading(true)
|
// ✅ Nur sichtbares Loading zeigen, wenn wir noch keine Override-Daten haben
|
||||||
|
const shouldShowLoading = overrideDoneJobs == null
|
||||||
|
if (shouldShowLoading) setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
|
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
|
||||||
@ -405,7 +523,7 @@ export default function FinishedDownloads({
|
|||||||
setOverrideDoneJobs(items)
|
setOverrideDoneJobs(items)
|
||||||
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
|
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
if (shouldShowLoading) setIsLoading(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sortMode, includeKeep]
|
[sortMode, includeKeep]
|
||||||
@ -458,6 +576,12 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const finishRefill = () => {
|
const finishRefill = () => {
|
||||||
refillInFlightRef.current = false
|
refillInFlightRef.current = false
|
||||||
|
|
||||||
|
// ✅ Nachlauf-Reload ausführen, falls während des laufenden Refills ein Event kam
|
||||||
|
if (refillQueuedWhileInFlightRef.current) {
|
||||||
|
refillQueuedWhileInFlightRef.current = false
|
||||||
|
queueRefill()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Refill läuft
|
// ✅ Refill läuft
|
||||||
@ -966,149 +1090,296 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const releasePlayingFile = useCallback(
|
const releasePlayingFile = useCallback(
|
||||||
async (file: string, opts?: { close?: boolean }) => {
|
async (file: string, opts?: { close?: boolean }) => {
|
||||||
|
// 1) App-/Overlay-Player freigeben
|
||||||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||||||
if (opts?.close) {
|
if (opts?.close) {
|
||||||
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
|
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
|
||||||
}
|
}
|
||||||
await new Promise((r) => window.setTimeout(r, 250))
|
|
||||||
|
// 2) Einmal auf den nächsten Frame warten (React/DOM cleanup)
|
||||||
|
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
||||||
|
|
||||||
|
// 3) Nochmals release senden (hilft bei race zwischen close/unmount)
|
||||||
|
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||||||
|
|
||||||
|
// 4) Windows/Filesystem braucht manchmal einen Moment bis Handles wirklich frei sind
|
||||||
|
await new Promise((r) => window.setTimeout(r, 260))
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const withFileReleaseRetry = useCallback(
|
||||||
|
async <T,>(
|
||||||
|
file: string,
|
||||||
|
run: () => Promise<T>,
|
||||||
|
opts?: { close?: boolean; attempts?: number; baseDelayMs?: number }
|
||||||
|
): Promise<T> => {
|
||||||
|
const attempts = Math.max(1, opts?.attempts ?? 4)
|
||||||
|
const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)
|
||||||
|
|
||||||
|
let lastErr: unknown
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
|
try {
|
||||||
|
// vor JEDEM Versuch freigeben (nicht nur einmal)
|
||||||
|
await releasePlayingFile(file, { close: opts?.close ?? true })
|
||||||
|
|
||||||
|
// kurzer Tick extra (DOM/video cleanup, OS handle release)
|
||||||
|
if (attempt > 1) {
|
||||||
|
await sleep(baseDelayMs * attempt)
|
||||||
|
} else {
|
||||||
|
await sleep(80)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await run()
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e
|
||||||
|
|
||||||
|
// nur bei "Datei in Verwendung" retryen
|
||||||
|
if (!looksLikeFileInUseError(e) || attempt >= attempts) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
// nächster Versuch
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? 'Unbekannter Fehler'))
|
||||||
|
},
|
||||||
|
[releasePlayingFile]
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileMutationKind = 'delete' | 'keep' | 'rename'
|
||||||
|
|
||||||
|
type RunFileMutationOptions<T> = {
|
||||||
|
kind: FileMutationKind
|
||||||
|
job: RecordJob
|
||||||
|
file: string
|
||||||
|
rowKey: string
|
||||||
|
|
||||||
|
// UI / State
|
||||||
|
setBusy?: (v: boolean) => void
|
||||||
|
isBusyNow?: () => boolean
|
||||||
|
optimisticRemove?: boolean
|
||||||
|
alreadyRemoved?: boolean
|
||||||
|
|
||||||
|
// Ausführung
|
||||||
|
run: () => Promise<T>
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
onSuccess?: (result: T) => Promise<void> | void
|
||||||
|
onError?: (err: unknown) => Promise<void> | void
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
labels: {
|
||||||
|
invalidTitle: string
|
||||||
|
invalidBody: string
|
||||||
|
inUseTitle: string
|
||||||
|
failTitle: string
|
||||||
|
failPrefix?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runFileMutation = useCallback(
|
||||||
|
async <T,>(opts: RunFileMutationOptions<T>): Promise<{ ok: boolean; result?: T }> => {
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
rowKey,
|
||||||
|
setBusy,
|
||||||
|
isBusyNow,
|
||||||
|
optimisticRemove,
|
||||||
|
alreadyRemoved,
|
||||||
|
run,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
labels,
|
||||||
|
} = opts
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
notify.error(labels.invalidTitle, labels.invalidBody)
|
||||||
|
return { ok: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBusyNow?.()) return { ok: false }
|
||||||
|
|
||||||
|
setBusy?.(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (optimisticRemove && !alreadyRemoved) {
|
||||||
|
animateRemove(rowKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await run()
|
||||||
|
await onSuccess?.(result)
|
||||||
|
|
||||||
|
return { ok: true, result }
|
||||||
|
} catch (e: any) {
|
||||||
|
// Optimistik zurückrollen
|
||||||
|
if (optimisticRemove) {
|
||||||
|
restoreRow(rowKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
await onError?.(e)
|
||||||
|
|
||||||
|
if (looksLikeFileInUseError(e)) {
|
||||||
|
notify.error(labels.inUseTitle, `${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.`)
|
||||||
|
} else {
|
||||||
|
const suffix = e?.message ? ` — ${String(e.message)}` : ''
|
||||||
|
notify.error(labels.failTitle, `${labels.failPrefix ?? file}${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false }
|
||||||
|
} finally {
|
||||||
|
setBusy?.(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notify, animateRemove, restoreRow]
|
||||||
|
)
|
||||||
|
|
||||||
const deleteVideo = useCallback(
|
const deleteVideo = useCallback(
|
||||||
async (job: RecordJob): Promise<boolean> => {
|
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise<boolean> => {
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
const key = keyFor(job)
|
const key = keyFor(job)
|
||||||
|
|
||||||
if (!file) {
|
const res = await runFileMutation({
|
||||||
notify.error('Löschen nicht möglich', 'Kein Dateiname gefunden – kann nicht löschen.')
|
kind: 'delete',
|
||||||
return false
|
job,
|
||||||
}
|
file,
|
||||||
if (deletingKeys.has(key)) return false
|
rowKey: key,
|
||||||
|
setBusy: (v) => markDeleting(key, v),
|
||||||
markDeleting(key, true)
|
isBusyNow: () => deletingKeys.has(key),
|
||||||
try {
|
optimisticRemove: true,
|
||||||
await releasePlayingFile(file, { close: true })
|
alreadyRemoved: opts?.alreadyRemoved,
|
||||||
|
labels: {
|
||||||
// ✅ Wenn App-Handler vorhanden: den benutzen
|
invalidTitle: 'Löschen nicht möglich',
|
||||||
// (WICHTIG für Undo: onDeleteJob sollte idealerweise {undoToken} zurückgeben)
|
invalidBody: 'Kein Dateiname gefunden – kann nicht löschen.',
|
||||||
if (onDeleteJob) {
|
inUseTitle: 'Löschen fehlgeschlagen',
|
||||||
const r = await onDeleteJob(job)
|
failTitle: 'Löschen fehlgeschlagen',
|
||||||
const undoToken = (r as any)?.undoToken
|
failPrefix: file,
|
||||||
|
},
|
||||||
if (typeof undoToken === 'string' && undoToken) {
|
run: async () => {
|
||||||
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
|
if (onDeleteJob) {
|
||||||
} else {
|
return await withFileReleaseRetry(
|
||||||
setLastAction(null)
|
file,
|
||||||
// optional: nicht als "error" melden, eher info/warn
|
async () => await onDeleteJob(job),
|
||||||
// notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.')
|
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await withFileReleaseRetry(
|
||||||
|
file,
|
||||||
|
async () =>
|
||||||
|
await fetchWithTextError(`/api/record/delete?file=${encodeURIComponent(file)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||||||
|
)
|
||||||
|
|
||||||
|
return (await r.json().catch(() => null)) as any
|
||||||
|
},
|
||||||
|
onSuccess: async (result: any) => {
|
||||||
|
// Fall 1: externer Handler (App) liefert { undoToken }
|
||||||
|
if (onDeleteJob) {
|
||||||
|
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
||||||
|
if (undoToken) {
|
||||||
|
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
|
||||||
|
} else {
|
||||||
|
setLastAction(null)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall 2: lokaler API-Call (liefert from + undoToken)
|
||||||
|
const from = (result?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
|
||||||
|
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
||||||
|
|
||||||
|
if (undoToken) {
|
||||||
|
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
|
||||||
|
} else {
|
||||||
|
setLastAction(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ OPTIMISTIK + Pagination refill + count hint
|
|
||||||
animateRemove(key)
|
|
||||||
queueRefill()
|
queueRefill()
|
||||||
emitCountHint(-1)
|
emitCountHint(-1)
|
||||||
// animateRemove queued already queueRefill(), aber extra ist ok:
|
},
|
||||||
// queueRefill()
|
})
|
||||||
|
|
||||||
return true
|
return res.ok
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Backend direkt
|
|
||||||
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '')
|
|
||||||
throw new Error(text || `HTTP ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Backend liefert undoToken (Trash)
|
|
||||||
const data = (await res.json().catch(() => null)) as any
|
|
||||||
const from = (data?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
|
|
||||||
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
|
|
||||||
|
|
||||||
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
|
|
||||||
else setLastAction(null)
|
|
||||||
|
|
||||||
animateRemove(key)
|
|
||||||
queueRefill()
|
|
||||||
|
|
||||||
// ✅ Tab-Count sofort korrigieren (App hört drauf)
|
|
||||||
emitCountHint(-1)
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (e: any) {
|
|
||||||
// ✅ falls irgendwo (z.B. via External-Event) schon optimistisch entfernt wurde: zurückrollen
|
|
||||||
restoreRow(key)
|
|
||||||
|
|
||||||
notify.error('Löschen fehlgeschlagen: ', file)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
markDeleting(key, false)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
baseName,
|
||||||
|
keyFor,
|
||||||
deletingKeys,
|
deletingKeys,
|
||||||
markDeleting,
|
markDeleting,
|
||||||
releasePlayingFile,
|
|
||||||
onDeleteJob,
|
onDeleteJob,
|
||||||
animateRemove,
|
withFileReleaseRetry,
|
||||||
notify,
|
runFileMutation,
|
||||||
restoreRow,
|
|
||||||
queueRefill,
|
queueRefill,
|
||||||
emitCountHint,
|
emitCountHint,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const keepVideo = useCallback(
|
const keepVideo = useCallback(
|
||||||
async (job: RecordJob) => {
|
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => {
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
const key = keyFor(job)
|
const key = keyFor(job)
|
||||||
|
|
||||||
if (!file) {
|
const res = await runFileMutation({
|
||||||
notify.error('Keep nicht möglich', 'Kein Dateiname gefunden – kann nicht behalten.')
|
kind: 'keep',
|
||||||
return false
|
job,
|
||||||
}
|
file,
|
||||||
if (keepingKeys.has(key) || deletingKeys.has(key)) return false
|
rowKey: key,
|
||||||
|
setBusy: (v) => markKeeping(key, v),
|
||||||
|
isBusyNow: () => keepingKeys.has(key) || deletingKeys.has(key),
|
||||||
|
optimisticRemove: true,
|
||||||
|
alreadyRemoved: opts?.alreadyRemoved,
|
||||||
|
labels: {
|
||||||
|
invalidTitle: 'Keep nicht möglich',
|
||||||
|
invalidBody: 'Kein Dateiname gefunden – kann nicht behalten.',
|
||||||
|
inUseTitle: 'Keep fehlgeschlagen',
|
||||||
|
failTitle: 'Keep fehlgeschlagen',
|
||||||
|
failPrefix: file,
|
||||||
|
},
|
||||||
|
run: async () => {
|
||||||
|
if (onKeepJob) {
|
||||||
|
return await withFileReleaseRetry(
|
||||||
|
file,
|
||||||
|
async () => await onKeepJob(job),
|
||||||
|
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
markKeeping(key, true)
|
const r = await withFileReleaseRetry(
|
||||||
try {
|
file,
|
||||||
await releasePlayingFile(file, { close: true })
|
async () =>
|
||||||
|
await fetchWithTextError(`/api/record/keep?file=${encodeURIComponent(file)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||||||
|
)
|
||||||
|
return (await r.json().catch(() => null)) as any
|
||||||
|
},
|
||||||
|
onSuccess: async (data: any) => {
|
||||||
|
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
|
||||||
|
|
||||||
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '')
|
|
||||||
throw new Error(text || `HTTP ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Backend liefert ggf. newFile (uniqueDestPath)
|
queueRefill()
|
||||||
const data = (await res.json().catch(() => null)) as any
|
emitCountHint(includeKeep ? 0 : -1)
|
||||||
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// ✅ Undo-Info merken
|
return res.ok
|
||||||
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
|
|
||||||
|
|
||||||
// ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
|
|
||||||
animateRemove(key)
|
|
||||||
queueRefill()
|
|
||||||
|
|
||||||
// ✅ Tab-Count sofort korrigieren (App hört drauf)
|
|
||||||
emitCountHint(includeKeep ? 0 : -1)
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (e: any) {
|
|
||||||
notify.error('Keep fehlgeschlagen', file)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
markKeeping(key, false)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
baseName,
|
||||||
|
keyFor,
|
||||||
|
markKeeping,
|
||||||
keepingKeys,
|
keepingKeys,
|
||||||
deletingKeys,
|
deletingKeys,
|
||||||
markKeeping,
|
withFileReleaseRetry,
|
||||||
releasePlayingFile,
|
runFileMutation,
|
||||||
animateRemove,
|
|
||||||
notify,
|
|
||||||
queueRefill,
|
queueRefill,
|
||||||
emitCountHint,
|
emitCountHint,
|
||||||
includeKeep,
|
includeKeep,
|
||||||
@ -1250,86 +1521,185 @@ export default function FinishedDownloads({
|
|||||||
applyRename,
|
applyRename,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const [hotBusyKeys, setHotBusyKeys] = React.useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const markHotBusy = useCallback((key: string, value: boolean) => {
|
||||||
|
setHotBusyKeys((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (value) next.add(key)
|
||||||
|
else next.delete(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleHotVideo = useCallback(
|
const toggleHotVideo = useCallback(
|
||||||
async (job: RecordJob) => {
|
async (job: RecordJob): Promise<void> => {
|
||||||
const currentFile = baseName(job.output || '')
|
const currentFile = baseName(job.output || '')
|
||||||
if (!currentFile) {
|
const key = keyFor(job)
|
||||||
notify.error('HOT nicht möglich', 'Kein Dateiname gefunden – kann nicht HOT togglen.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// genau "HOT " Prefix
|
|
||||||
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
|
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
|
||||||
|
|
||||||
// Server-Truth anwenden (inkl. duration-key move via applyRename)
|
|
||||||
const applyServerTruth = (apiOld: string, apiNew: string) => {
|
|
||||||
if (!apiOld || !apiNew || apiOld === apiNew) return
|
|
||||||
applyRename(apiOld, apiNew)
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldFile = currentFile
|
const oldFile = currentFile
|
||||||
const optimisticNew = toggledName(oldFile)
|
const optimisticNew = toggledName(oldFile)
|
||||||
|
|
||||||
// Optimistik sofort anwenden (UI snappy)
|
await runFileMutation({
|
||||||
applyRename(oldFile, optimisticNew)
|
kind: 'rename',
|
||||||
|
job,
|
||||||
|
file: currentFile,
|
||||||
|
rowKey: key,
|
||||||
|
setBusy: (v) => markHotBusy(key, v),
|
||||||
|
isBusyNow: () => hotBusyKeys.has(key),
|
||||||
|
optimisticRemove: false,
|
||||||
|
labels: {
|
||||||
|
invalidTitle: 'HOT nicht möglich',
|
||||||
|
invalidBody: 'Kein Dateiname gefunden – kann nicht HOT togglen.',
|
||||||
|
inUseTitle: 'HOT umbenennen fehlgeschlagen',
|
||||||
|
failTitle: 'HOT umbenennen fehlgeschlagen',
|
||||||
|
failPrefix: oldFile,
|
||||||
|
},
|
||||||
|
run: async () => {
|
||||||
|
// Optimistik sofort anwenden
|
||||||
|
applyRename(oldFile, optimisticNew)
|
||||||
|
|
||||||
try {
|
if (onToggleHot) {
|
||||||
await releasePlayingFile(oldFile, { close: true })
|
const r = await onToggleHot(job)
|
||||||
|
return r as any
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 1) Wenn du einen externen Handler hast:
|
const r = await withFileReleaseRetry(
|
||||||
// -> ideal: er gibt {oldFile,newFile} zurück (optional)
|
oldFile,
|
||||||
if (onToggleHot) {
|
async () =>
|
||||||
const r = (await onToggleHot(job)) as any
|
await fetchWithTextError(
|
||||||
|
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
),
|
||||||
|
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||||||
|
)
|
||||||
|
|
||||||
// Wenn Handler Server-Truth liefert, übernehmen, sonst Optimistik behalten
|
return (await r.json().catch(() => null)) as any
|
||||||
const apiOld = typeof r?.oldFile === 'string' ? r.oldFile : ''
|
},
|
||||||
const apiNew = typeof r?.newFile === 'string' ? r.newFile : ''
|
onSuccess: async (data: any) => {
|
||||||
if (apiOld && apiNew) applyServerTruth(apiOld, apiNew)
|
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
|
||||||
|
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
|
||||||
|
|
||||||
// ✅ Undo erst jetzt setzen (nach Erfolg)
|
if (apiOld && apiNew && apiOld !== apiNew) {
|
||||||
setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew })
|
applyRename(apiOld, apiNew)
|
||||||
|
}
|
||||||
|
|
||||||
if (sortMode === 'file_asc' || sortMode === 'file_desc') {
|
setLastAction({ kind: 'hot', currentFile: apiNew })
|
||||||
|
|
||||||
|
if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
|
||||||
queueRefill()
|
queueRefill()
|
||||||
}
|
}
|
||||||
return
|
},
|
||||||
}
|
onError: async () => {
|
||||||
|
// Rename-Optimistik rollback
|
||||||
// ✅ 2) Fallback: Backend direkt (wichtig: oldFile verwenden!)
|
clearRenamePair(oldFile, optimisticNew)
|
||||||
const res = await fetch(
|
setLastAction(null)
|
||||||
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
|
},
|
||||||
{ method: 'POST' }
|
})
|
||||||
)
|
},
|
||||||
|
[
|
||||||
if (!res.ok) {
|
baseName,
|
||||||
const text = await res.text().catch(() => '')
|
keyFor,
|
||||||
throw new Error(text || `HTTP ${res.status}`)
|
hotBusyKeys,
|
||||||
}
|
markHotBusy,
|
||||||
|
runFileMutation,
|
||||||
const data = (await res.json().catch(() => null)) as any
|
applyRename,
|
||||||
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
|
clearRenamePair,
|
||||||
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
|
onToggleHot,
|
||||||
|
withFileReleaseRetry,
|
||||||
// Server-Truth über Optimistik drüberziehen (falls der Server anders entschieden hat)
|
queueRefill,
|
||||||
if (apiOld !== apiNew) applyServerTruth(apiOld, apiNew)
|
sortMode,
|
||||||
|
]
|
||||||
// ✅ Undo nach Erfolg
|
|
||||||
setLastAction({ kind: 'hot', currentFile: apiNew })
|
|
||||||
|
|
||||||
queueRefill()
|
|
||||||
} catch (e: any) {
|
|
||||||
// ❌ Rollback, weil Optimistik schon angewendet wurde
|
|
||||||
clearRenamePair(oldFile, optimisticNew)
|
|
||||||
|
|
||||||
// und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist)
|
|
||||||
setLastAction(null)
|
|
||||||
|
|
||||||
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[notify, applyRename, clearRenamePair, releasePlayingFile, onToggleHot, queueRefill, sortMode]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const enqueueDeleteVideo = useCallback((job: RecordJob): boolean => {
|
||||||
|
const key = keyFor(job)
|
||||||
|
const file = baseName(job.output || '')
|
||||||
|
if (!key || !file) return false
|
||||||
|
|
||||||
|
// bereits aktiv? dann nicht nochmal
|
||||||
|
if (deletingKeys.has(key) || keepingKeys.has(key)) return false
|
||||||
|
|
||||||
|
// sofort visuelles Busy (leichtgewichtig)
|
||||||
|
markDeleting(key, true)
|
||||||
|
|
||||||
|
// ✅ sofort raus aus dem Stack (optimistisch)
|
||||||
|
animateRemove(key)
|
||||||
|
|
||||||
|
const qid = `delete:${key}`
|
||||||
|
const accepted = mutationQueue.enqueue(qid, async () => {
|
||||||
|
try {
|
||||||
|
await deleteVideo(job, { alreadyRemoved: true })
|
||||||
|
} finally {
|
||||||
|
// deleteVideo setzt markDeleting(false) selbst im finally,
|
||||||
|
// daher hier nichts zusätzlich nötig.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
restoreRow(key) // ✅ macht markDeleting false + removing/deleted rollback
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepted
|
||||||
|
}, [
|
||||||
|
mutationQueue,
|
||||||
|
keyFor,
|
||||||
|
baseName,
|
||||||
|
deletingKeys,
|
||||||
|
keepingKeys,
|
||||||
|
markDeleting,
|
||||||
|
deleteVideo,
|
||||||
|
])
|
||||||
|
|
||||||
|
const enqueueKeepVideo = useCallback((job: RecordJob): boolean => {
|
||||||
|
const key = keyFor(job)
|
||||||
|
const file = baseName(job.output || '')
|
||||||
|
if (!key || !file) return false
|
||||||
|
|
||||||
|
if (keepingKeys.has(key) || deletingKeys.has(key)) return false
|
||||||
|
|
||||||
|
markKeeping(key, true)
|
||||||
|
|
||||||
|
// ✅ sofort aus dem sichtbaren Stack raus
|
||||||
|
animateRemove(key)
|
||||||
|
|
||||||
|
const qid = `keep:${key}`
|
||||||
|
const accepted = mutationQueue.enqueue(qid, async () => {
|
||||||
|
try {
|
||||||
|
await keepVideo(job, { alreadyRemoved: true })
|
||||||
|
} finally {
|
||||||
|
// keepVideo macht markKeeping(false) im finally
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
restoreRow(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepted
|
||||||
|
}, [
|
||||||
|
mutationQueue,
|
||||||
|
keyFor,
|
||||||
|
baseName,
|
||||||
|
keepingKeys,
|
||||||
|
deletingKeys,
|
||||||
|
markKeeping,
|
||||||
|
keepVideo,
|
||||||
|
])
|
||||||
|
|
||||||
|
const enqueueToggleHotVideo = useCallback((job: RecordJob): boolean => {
|
||||||
|
const key = keyFor(job)
|
||||||
|
if (!key) return false
|
||||||
|
|
||||||
|
if (hotBusyKeys.has(key)) return false
|
||||||
|
|
||||||
|
const qid = `hot:${key}`
|
||||||
|
return mutationQueue.enqueue(qid, async () => {
|
||||||
|
await toggleHotVideo(job)
|
||||||
|
})
|
||||||
|
}, [mutationQueue, keyFor, toggleHotVideo, hotBusyKeys])
|
||||||
|
|
||||||
const applyRenamedOutput = useCallback(
|
const applyRenamedOutput = useCallback(
|
||||||
(job: RecordJob): RecordJob => {
|
(job: RecordJob): RecordJob => {
|
||||||
const out = norm(job.output || '')
|
const out = norm(job.output || '')
|
||||||
@ -1415,9 +1785,7 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (detail.phase === 'success') {
|
if (detail.phase === 'success') {
|
||||||
// delete final bestätigt
|
|
||||||
markDeleting(key, false)
|
markDeleting(key, false)
|
||||||
queueRefill()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1428,13 +1796,18 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onReload = () => {
|
const onReload = () => {
|
||||||
if (refillInFlightRef.current) return
|
// ✅ Wenn gerade ein Refill läuft, Reload nicht verlieren, sondern merken
|
||||||
|
if (refillInFlightRef.current) {
|
||||||
|
refillQueuedWhileInFlightRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
queueRefill()
|
queueRefill()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('finished-downloads:reload', onReload as any)
|
window.addEventListener('finished-downloads:reload', onReload as EventListener)
|
||||||
return () => window.removeEventListener('finished-downloads:reload', onReload as any)
|
return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener)
|
||||||
}, [queueRefill /* oder fetchAllDoneJobs */])
|
}, [queueRefill])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onExternalRename = (ev: Event) => {
|
const onExternalRename = (ev: Event) => {
|
||||||
@ -1634,9 +2007,12 @@ export default function FinishedDownloads({
|
|||||||
// ✅ Hooks immer zuerst – unabhängig von rows
|
// ✅ Hooks immer zuerst – unabhängig von rows
|
||||||
const isSmall = useMediaQuery('(max-width: 639px)')
|
const isSmall = useMediaQuery('(max-width: 639px)')
|
||||||
|
|
||||||
|
// ✅ Mobile-Offsets für Cards-Ansicht (zentral steuerbar)
|
||||||
|
const cardsMobileOffsetTopClass = 'mt-10'
|
||||||
|
const cardsMobileOffsetBottomClass = 'mb-2' // bei Bedarf z. B. 'mb-4'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSmall) return
|
if (!isSmall || view !== 'cards') return
|
||||||
if (view !== 'cards') return
|
|
||||||
|
|
||||||
const top = pageRows[0]
|
const top = pageRows[0]
|
||||||
if (!top) {
|
if (!top) {
|
||||||
@ -1645,7 +2021,15 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const topKey = keyFor(top)
|
const topKey = keyFor(top)
|
||||||
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
|
|
||||||
|
// ✅ Erst mal kein sofortiger Teaser-Start auf der frisch promoted Card
|
||||||
|
setTeaserKey((prev) => (prev === topKey ? prev : null))
|
||||||
|
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
|
||||||
|
}, 140) // 100–180ms testen
|
||||||
|
|
||||||
|
return () => window.clearTimeout(t)
|
||||||
}, [isSmall, view, pageRows, keyFor])
|
}, [isSmall, view, pageRows, keyFor])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1763,9 +2147,8 @@ export default function FinishedDownloads({
|
|||||||
{/* Views */}
|
{/* Views */}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size={isSmall ? 'sm' : 'md'}
|
size='md'
|
||||||
variant="soft"
|
variant="soft"
|
||||||
className={isSmall ? 'h-9' : 'h-10'}
|
|
||||||
disabled={!lastAction || undoing}
|
disabled={!lastAction || undoing}
|
||||||
onClick={undoLastAction}
|
onClick={undoLastAction}
|
||||||
title={
|
title={
|
||||||
@ -2023,7 +2406,7 @@ export default function FinishedDownloads({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{view === 'cards' && (
|
{view === 'cards' && (
|
||||||
<div className={isSmall ? 'mt-8' : ''}>
|
<div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
|
||||||
<FinishedDownloadsCardsView
|
<FinishedDownloadsCardsView
|
||||||
rows={pageRows}
|
rows={pageRows}
|
||||||
isSmall={isSmall}
|
isSmall={isSmall}
|
||||||
@ -2067,6 +2450,9 @@ export default function FinishedDownloads({
|
|||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
|
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||||
|
enqueueKeepVideo={enqueueKeepVideo}
|
||||||
|
enqueueToggleHot={enqueueToggleHotVideo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -2112,6 +2498,9 @@ export default function FinishedDownloads({
|
|||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
deleteVideo={deleteVideo}
|
deleteVideo={deleteVideo}
|
||||||
keepVideo={keepVideo}
|
keepVideo={keepVideo}
|
||||||
|
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||||
|
enqueueKeepVideo={enqueueKeepVideo}
|
||||||
|
enqueueToggleHot={enqueueToggleHotVideo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -2150,6 +2539,9 @@ export default function FinishedDownloads({
|
|||||||
activeTagSet={activeTagSet}
|
activeTagSet={activeTagSet}
|
||||||
onToggleTagFilter={toggleTagFilter}
|
onToggleTagFilter={toggleTagFilter}
|
||||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||||
|
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||||
|
enqueueKeepVideo={enqueueKeepVideo}
|
||||||
|
enqueueToggleHot={enqueueToggleHotVideo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -84,6 +84,10 @@ type Props = {
|
|||||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
|
|
||||||
|
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
||||||
|
enqueueKeepVideo?: (job: RecordJob) => boolean
|
||||||
|
enqueueToggleHot?: (job: RecordJob) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseTags = (raw?: string): string[] => {
|
const parseTags = (raw?: string): string[] => {
|
||||||
@ -157,6 +161,72 @@ function chooseSpriteGrid(count: number): [number, number] {
|
|||||||
return [bestCols, bestRows]
|
return [bestCols, bestRows]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CardBlurWrapper({
|
||||||
|
blurred,
|
||||||
|
animateUnblurOnMount,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
blurred?: boolean
|
||||||
|
animateUnblurOnMount?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const [entered, setEntered] = React.useState(!animateUnblurOnMount)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!animateUnblurOnMount) return
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
setEntered(true)
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [animateUnblurOnMount])
|
||||||
|
|
||||||
|
const className = [
|
||||||
|
'relative will-change-[filter,transform] transition-[filter,transform,opacity] duration-180 ease-out',
|
||||||
|
blurred
|
||||||
|
? 'blur-[1.5px] saturate-90 scale-[0.995]'
|
||||||
|
: entered
|
||||||
|
? 'blur-0 saturate-100 scale-100'
|
||||||
|
: 'blur-[1.5px] saturate-90 scale-[0.995]',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return <div className={className}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromoteToFrontWrapper({
|
||||||
|
animateOnMount,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
animateOnMount?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const [entered, setEntered] = React.useState(!animateOnMount)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!animateOnMount) return
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
setEntered(true)
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [animateOnMount])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="will-change-[transform,opacity] transition-[transform,opacity] duration-160 ease-out motion-reduce:transition-none"
|
||||||
|
style={{
|
||||||
|
transform: entered
|
||||||
|
? 'translateY(0px) scale(1) translateZ(0)'
|
||||||
|
: 'translateY(-15px) scale(0.97) translateZ(0)',
|
||||||
|
opacity: entered ? 1 : 0.92,
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FinishedDownloadsCardsView({
|
export default function FinishedDownloadsCardsView({
|
||||||
rows,
|
rows,
|
||||||
isSmall,
|
isSmall,
|
||||||
@ -204,6 +274,10 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
|
|
||||||
|
enqueueDeleteVideo,
|
||||||
|
enqueueKeepVideo,
|
||||||
|
enqueueToggleHot,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
||||||
@ -324,6 +398,10 @@ export default function FinishedDownloadsCardsView({
|
|||||||
isDecorative?: boolean
|
isDecorative?: boolean
|
||||||
forceLoadStill?: boolean
|
forceLoadStill?: boolean
|
||||||
mobileStackTopOnlyVideo?: boolean
|
mobileStackTopOnlyVideo?: boolean
|
||||||
|
disableScrubber?: boolean
|
||||||
|
blur?: boolean
|
||||||
|
animateUnblurOnMount?: boolean
|
||||||
|
preloadTeaserWhenStill?: boolean
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
@ -346,7 +424,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
opts?.forceStill
|
opts?.forceStill
|
||||||
? false
|
? false
|
||||||
: opts?.mobileStackTopOnlyVideo
|
: opts?.mobileStackTopOnlyVideo
|
||||||
? (teaserPlayback === 'all' || (teaserPlayback === 'hover' ? teaserKey === k : false))
|
? (teaserPlayback === 'still' ? false : true)
|
||||||
: (teaserPlayback === 'all'
|
: (teaserPlayback === 'all'
|
||||||
? true
|
? true
|
||||||
: teaserPlayback === 'hover'
|
: teaserPlayback === 'hover'
|
||||||
@ -525,145 +603,169 @@ export default function FinishedDownloadsCardsView({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Card shell keeps backgrounds consistent */}
|
<CardBlurWrapper
|
||||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
blurred={opts?.blur}
|
||||||
{/* Preview */}
|
animateUnblurOnMount={opts?.animateUnblurOnMount}
|
||||||
<div
|
>
|
||||||
id={inlineDomId}
|
{/* Card shell keeps backgrounds consistent */}
|
||||||
ref={
|
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||||
opts?.disableInline || opts?.isDecorative
|
{/* Preview */}
|
||||||
? undefined
|
<div
|
||||||
: registerTeaserHost(k)
|
id={inlineDomId}
|
||||||
}
|
ref={
|
||||||
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
opts?.disableInline || opts?.isDecorative
|
||||||
onMouseEnter={
|
? undefined
|
||||||
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
|
: registerTeaserHost(k)
|
||||||
}
|
}
|
||||||
onMouseLeave={() => {
|
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||||
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
|
onMouseEnter={
|
||||||
clearScrubActiveIndex(k)
|
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
|
||||||
}}
|
}
|
||||||
onClick={(e) => {
|
onMouseLeave={() => {
|
||||||
e.preventDefault()
|
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
|
||||||
e.stopPropagation()
|
clearScrubActiveIndex(k)
|
||||||
if (isSmall || opts?.disableInline) return
|
}}
|
||||||
startInline(k)
|
onClick={(e) => {
|
||||||
}}
|
e.preventDefault()
|
||||||
>
|
e.stopPropagation()
|
||||||
{/* media */}
|
if (isSmall || opts?.disableInline) return
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
startInline(k)
|
||||||
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
|
}}
|
||||||
{!inlineActive ? (
|
>
|
||||||
<div
|
{/* media */}
|
||||||
className={
|
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||||
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
|
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
|
||||||
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
|
{!inlineActive ? (
|
||||||
(inlineActive ? 'opacity-0' : 'opacity-100')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
className={
|
||||||
title={[
|
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
|
||||||
dur,
|
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
|
||||||
resObj ? `${resObj.w}×${resObj.h}` : resLabel || '',
|
(inlineActive ? 'opacity-0' : 'opacity-100')
|
||||||
size,
|
}
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' • ')}
|
|
||||||
>
|
>
|
||||||
<span>{dur}</span>
|
<div
|
||||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||||||
{resLabel ? <span>{resLabel}</span> : null}
|
title={[
|
||||||
<span aria-hidden="true">•</span>
|
dur,
|
||||||
<span>{size}</span>
|
resObj ? `${resObj.w}×${resObj.h}` : resLabel || '',
|
||||||
|
size,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' • ')}
|
||||||
|
>
|
||||||
|
<span>{dur}</span>
|
||||||
|
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||||
|
{resLabel ? <span>{resLabel}</span> : null}
|
||||||
|
<span aria-hidden="true">•</span>
|
||||||
|
<span>{size}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
job={j}
|
job={j}
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
variant="fill"
|
variant="fill"
|
||||||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
||||||
onDuration={handleDuration}
|
onDuration={handleDuration}
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||||
animated={allowTeaserAnimation}
|
animated={allowTeaserAnimation}
|
||||||
animatedMode="teaser"
|
animatedMode="teaser"
|
||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
clipSeconds={1}
|
clipSeconds={1}
|
||||||
thumbSamples={18}
|
thumbSamples={18}
|
||||||
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
||||||
inlineNonce={inlineNonce}
|
inlineNonce={inlineNonce}
|
||||||
inlineControls={inlineActive}
|
inlineControls={inlineActive}
|
||||||
inlineLoop={false}
|
inlineLoop={false}
|
||||||
muted={previewMuted}
|
muted={previewMuted}
|
||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
alwaysLoadStill={forceLoadStill}
|
alwaysLoadStill={forceLoadStill}
|
||||||
teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall}
|
teaserPreloadEnabled={
|
||||||
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'}
|
opts?.mobileStackTopOnlyVideo
|
||||||
scrubProgressRatio={scrubProgressRatio}
|
? true
|
||||||
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
|
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
|
||||||
/>
|
}
|
||||||
|
teaserPreloadRootMargin={
|
||||||
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
|
opts?.preloadTeaserWhenStill
|
||||||
{hasSpriteScrubber && spriteUrl ? (
|
? '1200px 0px'
|
||||||
<img
|
: (isSmall ? '900px 0px' : '700px 0px')
|
||||||
src={spriteUrl}
|
}
|
||||||
alt=""
|
scrubProgressRatio={scrubProgressRatio}
|
||||||
className="hidden"
|
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
|
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
|
||||||
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
|
{hasSpriteScrubber && spriteUrl ? (
|
||||||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
<img
|
||||||
<div className="h-full w-full" style={spriteFrameStyle} />
|
src={spriteUrl}
|
||||||
</div>
|
alt=""
|
||||||
) : null}
|
className="hidden"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
|
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
|
||||||
{!opts?.isDecorative && !inlineActive && scrubberCount > 1 ? (
|
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
|
||||||
<div
|
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
<div className="h-full w-full" style={spriteFrameStyle} />
|
||||||
onClick={(e) => e.stopPropagation()}
|
</div>
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
) : null}
|
||||||
onMouseEnter={() => setScrubHovering(k, true)}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setScrubHovering(k, false)
|
|
||||||
// optional: Index sofort loslassen, dann springt Bar direkt zurück auf Teaser
|
|
||||||
setScrubActiveIndex(k, undefined)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PreviewScrubber
|
|
||||||
className="pointer-events-auto px-1"
|
|
||||||
imageCount={scrubberCount}
|
|
||||||
activeIndex={scrubActiveIndex}
|
|
||||||
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
|
|
||||||
onIndexClick={(index) => {
|
|
||||||
// wie Preview-Klick: inline starten
|
|
||||||
if (isSmall || opts?.disableInline) {
|
|
||||||
// Mobile/Decorative/Fallback: bestehendes Verhalten
|
|
||||||
handleScrubberClickIndex(j, index, scrubberCount)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zielsekunde aus Scrubber ableiten
|
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
|
||||||
const seconds =
|
{!opts?.isDecorative && !opts?.disableScrubber && !inlineActive && scrubberCount > 1 ? (
|
||||||
scrubberStepSeconds > 0
|
<div
|
||||||
? index * scrubberStepSeconds
|
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||||||
: 0
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseEnter={() => setScrubHovering(k, true)}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setScrubHovering(k, false)
|
||||||
|
// optional: Index sofort loslassen, dann springt Bar direkt zurück auf Teaser
|
||||||
|
setScrubActiveIndex(k, undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PreviewScrubber
|
||||||
|
className="pointer-events-auto px-1"
|
||||||
|
imageCount={scrubberCount}
|
||||||
|
activeIndex={scrubActiveIndex}
|
||||||
|
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
|
||||||
|
onIndexClick={(index) => {
|
||||||
|
// wie Preview-Klick: inline starten
|
||||||
|
if (isSmall || opts?.disableInline) {
|
||||||
|
// Mobile/Decorative/Fallback: bestehendes Verhalten
|
||||||
|
handleScrubberClickIndex(j, index, scrubberCount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 1) bevorzugt: direkt inline an Position starten (falls Parent das unterstützt)
|
// Zielsekunde aus Scrubber ableiten
|
||||||
if (startInlineAt) {
|
const seconds =
|
||||||
startInlineAt(k, seconds, inlineDomId)
|
scrubberStepSeconds > 0
|
||||||
|
? index * scrubberStepSeconds
|
||||||
|
: 0
|
||||||
|
|
||||||
// wie bei Tap im Mobile-Stack: Autoplay nochmal anschubsen
|
// 1) bevorzugt: direkt inline an Position starten (falls Parent das unterstützt)
|
||||||
|
if (startInlineAt) {
|
||||||
|
startInlineAt(k, seconds, inlineDomId)
|
||||||
|
|
||||||
|
// wie bei Tap im Mobile-Stack: Autoplay nochmal anschubsen
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!tryAutoplayInline(inlineDomId)) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
tryAutoplayInline(inlineDomId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback: inline normal starten (ohne exakten Seek)
|
||||||
|
startInline(k)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!tryAutoplayInline(inlineDomId)) {
|
if (!tryAutoplayInline(inlineDomId)) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@ -671,99 +773,88 @@ export default function FinishedDownloadsCardsView({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Fallback: inline normal starten (ohne exakten Seek)
|
// 3) Optionaler Fallback auf bestehenden Handler (wenn du dort OpenPlayerAt machst)
|
||||||
startInline(k)
|
// handleScrubberClickIndex(j, index, scrubberCount)
|
||||||
requestAnimationFrame(() => {
|
}}
|
||||||
if (!tryAutoplayInline(inlineDomId)) {
|
stepSeconds={scrubberStepSeconds}
|
||||||
requestAnimationFrame(() => {
|
/>
|
||||||
tryAutoplayInline(inlineDomId)
|
</div>
|
||||||
})
|
) : null}
|
||||||
}
|
</div>
|
||||||
})
|
|
||||||
|
|
||||||
// 3) Optionaler Fallback auf bestehenden Handler (wenn du dort OpenPlayerAt machst)
|
|
||||||
// handleScrubberClickIndex(j, index, scrubberCount)
|
|
||||||
}}
|
|
||||||
stepSeconds={scrubberStepSeconds}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</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">
|
||||||
<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">
|
||||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
|
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</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">
|
||||||
HOT
|
HOT
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{stripHotPrefix(fileRaw) || '—'}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
|
||||||
{stripHotPrefix(fileRaw) || '—'}
|
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
|
||||||
</span>
|
{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 className="shrink-0 flex items-center gap-1.5 pt-0.5">
|
{/* Meta + Actions (nicht im Video) */}
|
||||||
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
|
<div
|
||||||
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
|
className="mt-2"
|
||||||
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
|
onClick={(e) => e.stopPropagation()}
|
||||||
</div>
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
</div>
|
>
|
||||||
|
{/* Actions: volle Breite */}
|
||||||
{/* Meta + Actions (nicht im Video) */}
|
<div className="w-full">
|
||||||
<div
|
<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">
|
||||||
className="mt-2"
|
<RecordJobActions
|
||||||
onClick={(e) => e.stopPropagation()}
|
job={j}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
variant="table"
|
||||||
>
|
busy={busy}
|
||||||
{/* Actions: volle Breite */}
|
collapseToMenu
|
||||||
<div className="w-full">
|
compact={false}
|
||||||
<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">
|
isHot={isHot}
|
||||||
<RecordJobActions
|
isFavorite={isFav}
|
||||||
job={j}
|
isLiked={isLiked}
|
||||||
variant="table"
|
isWatching={isWatching}
|
||||||
busy={busy}
|
onToggleWatch={onToggleWatch}
|
||||||
collapseToMenu
|
onToggleFavorite={onToggleFavorite}
|
||||||
compact={false}
|
onToggleLike={onToggleLike}
|
||||||
isHot={isHot}
|
onToggleHot={onToggleHot}
|
||||||
isFavorite={isFav}
|
onKeep={keepVideo}
|
||||||
isLiked={isLiked}
|
onDelete={deleteVideo}
|
||||||
isWatching={isWatching}
|
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
|
||||||
onToggleWatch={onToggleWatch}
|
className="w-full gap-1.5"
|
||||||
onToggleFavorite={onToggleFavorite}
|
/>
|
||||||
onToggleLike={onToggleLike}
|
</div>
|
||||||
onToggleHot={onToggleHot}
|
|
||||||
onKeep={keepVideo}
|
|
||||||
onDelete={deleteVideo}
|
|
||||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
|
|
||||||
className="w-full gap-1.5"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
<TagOverflowRow
|
<TagOverflowRow
|
||||||
rowKey={k}
|
rowKey={k}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
activeTagSet={activeTagSet}
|
activeTagSet={activeTagSet}
|
||||||
lower={lower}
|
lower={lower}
|
||||||
onToggleTagFilter={onToggleTagFilter}
|
onToggleTagFilter={onToggleTagFilter}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
</CardBlurWrapper>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -782,17 +873,17 @@ export default function FinishedDownloadsCardsView({
|
|||||||
// Sichtbarer Stack bleibt bei 3 Karten
|
// Sichtbarer Stack bleibt bei 3 Karten
|
||||||
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
|
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
|
||||||
|
|
||||||
// Für Preload (Still-Previews) verwenden wir ALLE Rows auf Mobile
|
// ✅ Mobile-Preload stark begrenzen (sonst zu viele hidden <FinishedVideoPreview/> Mounts)
|
||||||
const mobileAllRows = isSmall ? rows : []
|
const MOBILE_STILL_PRELOAD_LIMIT = 4 // 0..6 je nach Gerätetest
|
||||||
|
const mobileStillPreloadRows = isSmall
|
||||||
|
? rows.slice(mobileStackDepth, mobileStackDepth + MOBILE_STILL_PRELOAD_LIMIT)
|
||||||
|
: []
|
||||||
|
|
||||||
// größerer Peek-Offset für stärkeren Stack-Effekt
|
// größerer Peek-Offset für stärkeren Stack-Effekt
|
||||||
const stackPeekOffsetPx = 15
|
const stackPeekOffsetPx = 15
|
||||||
|
|
||||||
// zusätzlicher Abstand ÜBER dem Stack (zum vorherigen Element)
|
|
||||||
const stackTopGapPx = 24
|
|
||||||
|
|
||||||
// weil wir nach OBEN stacken, brauchen wir oben Platz
|
// weil wir nach OBEN stacken, brauchen wir oben Platz
|
||||||
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1) * stackPeekOffsetPx
|
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -809,109 +900,135 @@ export default function FinishedDownloadsCardsView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative">
|
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
|
||||||
{rows.length === 0 ? null : (
|
{rows.length === 0 ? null : (
|
||||||
<div className="relative mx-auto w-full max-w-[560px] overflow-visible">
|
<div
|
||||||
|
className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
|
||||||
|
style={{ overflowX: 'clip' }}
|
||||||
|
>
|
||||||
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
|
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
|
||||||
<div
|
<div
|
||||||
className="relative overflow-visible"
|
className="relative overflow-visible"
|
||||||
style={{
|
style={{
|
||||||
minHeight: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
|
paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
|
||||||
paddingTop: `${stackExtraTopPx + stackTopGapPx}px`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mobileVisibleStackRows
|
{(() => {
|
||||||
.map((j, idx) => {
|
const visible = mobileVisibleStackRows
|
||||||
const isTop = idx === 0
|
const topRow = visible[0]
|
||||||
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(
|
const backRows = visible.slice(1)
|
||||||
j,
|
|
||||||
isTop
|
return (
|
||||||
? {
|
<>
|
||||||
forceLoadStill: true,
|
{/* Hintere Karten zuerst (absolut, dekorativ) */}
|
||||||
mobileStackTopOnlyVideo: true,
|
{backRows
|
||||||
}
|
.map((j, backIdx) => {
|
||||||
: {
|
const idx = backIdx + 1 // 1,2...
|
||||||
|
const { k, cardInner } = renderCardItem(j, {
|
||||||
forceStill: true,
|
forceStill: true,
|
||||||
disableInline: true,
|
disableInline: true,
|
||||||
disablePreviewHover: true,
|
disablePreviewHover: true,
|
||||||
isDecorative: true,
|
isDecorative: true,
|
||||||
forceLoadStill: true,
|
forceLoadStill: true,
|
||||||
}
|
blur: true,
|
||||||
)
|
preloadTeaserWhenStill: true,
|
||||||
const depth = idx // 0,1,2
|
})
|
||||||
const y = -(depth * stackPeekOffsetPx) // nach OBEN staffeln
|
|
||||||
const scale = 1 - depth * 0.03 // etwas stärkerer Tiefen-Effekt
|
|
||||||
const opacity = 1 - depth * 0.14
|
|
||||||
|
|
||||||
// untere Karten nur Deko (keine Interaktion)
|
const depth = idx
|
||||||
if (!isTop) {
|
const y = -(depth * stackPeekOffsetPx)
|
||||||
return (
|
const scale = 1 - depth * 0.03
|
||||||
<div
|
const opacity = 1 - depth * 0.14
|
||||||
key={k}
|
|
||||||
className="absolute inset-x-0 top-0 pointer-events-none"
|
return (
|
||||||
style={{
|
<div
|
||||||
zIndex: 20 - depth,
|
key={k}
|
||||||
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
|
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity] transition-[transform,opacity] duration-220 ease-out motion-reduce:transition-none"
|
||||||
opacity,
|
style={{
|
||||||
transformOrigin: 'top center',
|
zIndex: 20 - depth,
|
||||||
}}
|
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
|
||||||
aria-hidden="true"
|
opacity,
|
||||||
>
|
transformOrigin: 'top center',
|
||||||
<div className="relative">
|
}}
|
||||||
{cardInner}
|
aria-hidden="true"
|
||||||
{/* leichtes Frosting, damit klar ist: nur Vorschau */}
|
>
|
||||||
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
|
<div className="relative">
|
||||||
|
{cardInner}
|
||||||
|
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.reverse()}
|
||||||
|
|
||||||
|
{/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
|
||||||
|
{topRow ? (() => {
|
||||||
|
const j = topRow
|
||||||
|
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
|
||||||
|
forceLoadStill: true,
|
||||||
|
mobileStackTopOnlyVideo: true,
|
||||||
|
disableScrubber: true,
|
||||||
|
animateUnblurOnMount: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={k}
|
||||||
|
className="relative touch-pan-y"
|
||||||
|
style={{ zIndex: 30 }}
|
||||||
|
>
|
||||||
|
<PromoteToFrontWrapper animateOnMount>
|
||||||
|
<SwipeCard
|
||||||
|
ref={(h) => {
|
||||||
|
if (h) swipeRefs.current.set(k, h)
|
||||||
|
else swipeRefs.current.delete(k)
|
||||||
|
}}
|
||||||
|
enabled
|
||||||
|
disabled={busy}
|
||||||
|
ignoreFromBottomPx={110}
|
||||||
|
doubleTapMs={360}
|
||||||
|
doubleTapMaxMovePx={48}
|
||||||
|
onDoubleTap={async () => {
|
||||||
|
if (isHot) return
|
||||||
|
|
||||||
|
if (enqueueToggleHot) {
|
||||||
|
enqueueToggleHot(j)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await onToggleHot?.(j)
|
||||||
|
}}
|
||||||
|
onTap={() => {
|
||||||
|
startInline(k)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!tryAutoplayInline(inlineDomId)) {
|
||||||
|
requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onSwipeLeft={() => {
|
||||||
|
if (enqueueDeleteVideo) return enqueueDeleteVideo(j)
|
||||||
|
return deleteVideo(j)
|
||||||
|
}}
|
||||||
|
onSwipeRight={() => {
|
||||||
|
if (enqueueKeepVideo) return enqueueKeepVideo(j)
|
||||||
|
return keepVideo(j)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cardInner}
|
||||||
|
</SwipeCard>
|
||||||
|
</PromoteToFrontWrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})() : null}
|
||||||
}
|
</>
|
||||||
|
)
|
||||||
// oberste Karte: echte SwipeCard (wie bisher)
|
})()}
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={k}
|
|
||||||
className="absolute inset-x-0 top-0"
|
|
||||||
style={{
|
|
||||||
zIndex: 30,
|
|
||||||
transform: `translateY(${y}px) scale(${scale})`,
|
|
||||||
transformOrigin: 'top center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SwipeCard
|
|
||||||
ref={(h) => {
|
|
||||||
if (h) swipeRefs.current.set(k, h)
|
|
||||||
else swipeRefs.current.delete(k)
|
|
||||||
}}
|
|
||||||
enabled
|
|
||||||
disabled={busy}
|
|
||||||
ignoreFromBottomPx={110}
|
|
||||||
doubleTapMs={360}
|
|
||||||
doubleTapMaxMovePx={48}
|
|
||||||
onDoubleTap={async () => {
|
|
||||||
if (isHot) return
|
|
||||||
await onToggleHot?.(j)
|
|
||||||
}}
|
|
||||||
onTap={() => {
|
|
||||||
startInline(k)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!tryAutoplayInline(inlineDomId)) requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onSwipeLeft={() => deleteVideo(j)}
|
|
||||||
onSwipeRight={() => keepVideo(j)}
|
|
||||||
>
|
|
||||||
{cardInner}
|
|
||||||
</SwipeCard>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.reverse() /* zuerst hinten rendern, oben zuletzt */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden preloader: lädt für ALLE weiteren Mobile-Rows nur das Still-Preview */}
|
{/* ✅ Hidden preloader (mobile): nur wenige nächste Cards, sonst UI träge */}
|
||||||
{mobileAllRows.length > mobileStackDepth ? (
|
{mobileStillPreloadRows.length > 0 ? (
|
||||||
<div className="sr-only" aria-hidden="true">
|
<div className="sr-only" aria-hidden="true">
|
||||||
{mobileAllRows.slice(mobileStackDepth).map((j) => {
|
{mobileStillPreloadRows.map((j) => {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -922,9 +1039,9 @@ export default function FinishedDownloadsCardsView({
|
|||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={Boolean(blurPreviews)}
|
blur={Boolean(blurPreviews)}
|
||||||
|
|
||||||
// ✅ Nur Previewbild laden – kein Teaser-Video
|
|
||||||
animated={false}
|
animated={false}
|
||||||
|
teaserPreloadEnabled={true}
|
||||||
|
teaserPreloadRootMargin="1200px 0px"
|
||||||
animatedMode="teaser"
|
animatedMode="teaser"
|
||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
inlineVideo={false}
|
inlineVideo={false}
|
||||||
@ -932,27 +1049,14 @@ export default function FinishedDownloadsCardsView({
|
|||||||
inlineLoop={false}
|
inlineLoop={false}
|
||||||
muted={true}
|
muted={true}
|
||||||
popoverMuted={true}
|
popoverMuted={true}
|
||||||
|
|
||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
|
|
||||||
// ✅ Still immer laden
|
|
||||||
alwaysLoadStill
|
alwaysLoadStill
|
||||||
|
|
||||||
// ✅ Für Hidden-Preloader kein Teaser-Video vorladen
|
|
||||||
teaserPreloadEnabled={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* optionaler Hinweis */}
|
|
||||||
{rows.length > 1 ? (
|
|
||||||
<div className="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Wische nach links zum Löschen • nach rechts zum Behalten
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -72,6 +72,11 @@ type Props = {
|
|||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleHot: (job: RecordJob) => void | Promise<void>
|
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||||||
|
|
||||||
|
// optional queued actions (bevorzugt verwenden, falls vorhanden)
|
||||||
|
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
||||||
|
enqueueKeepVideo?: (job: RecordJob) => boolean
|
||||||
|
enqueueToggleHot?: (job: RecordJob) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
||||||
@ -177,6 +182,9 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
|
enqueueDeleteVideo,
|
||||||
|
enqueueKeepVideo,
|
||||||
|
enqueueToggleHot,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
||||||
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
|
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
|
||||||
@ -649,9 +657,27 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleHot={onToggleHot}
|
onToggleHot={async (job) => {
|
||||||
onKeep={keepVideo}
|
if (enqueueToggleHot) {
|
||||||
onDelete={deleteVideo}
|
const accepted = enqueueToggleHot(job)
|
||||||
|
if (accepted) return
|
||||||
|
}
|
||||||
|
return onToggleHot(job)
|
||||||
|
}}
|
||||||
|
onKeep={async (job) => {
|
||||||
|
if (enqueueKeepVideo) {
|
||||||
|
const accepted = enqueueKeepVideo(job)
|
||||||
|
if (accepted) return true
|
||||||
|
}
|
||||||
|
return keepVideo(job)
|
||||||
|
}}
|
||||||
|
onDelete={async (job) => {
|
||||||
|
if (enqueueDeleteVideo) {
|
||||||
|
const accepted = enqueueDeleteVideo(job)
|
||||||
|
if (accepted) return true
|
||||||
|
}
|
||||||
|
return deleteVideo(job)
|
||||||
|
}}
|
||||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
|
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
|
||||||
className="w-full gap-1.5"
|
className="w-full gap-1.5"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -73,6 +73,11 @@ type Props = {
|
|||||||
|
|
||||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
|
||||||
|
// optional queued actions (bevorzugt verwenden, falls vorhanden)
|
||||||
|
enqueueDeleteVideo?: (job: RecordJob) => boolean
|
||||||
|
enqueueKeepVideo?: (job: RecordJob) => boolean
|
||||||
|
enqueueToggleHot?: (job: RecordJob) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinishedDownloadsTableView({
|
export default function FinishedDownloadsTableView({
|
||||||
@ -120,6 +125,9 @@ export default function FinishedDownloadsTableView({
|
|||||||
|
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
|
enqueueDeleteVideo,
|
||||||
|
enqueueKeepVideo,
|
||||||
|
enqueueToggleHot,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sort, setSort] = React.useState<SortState>(null)
|
const [sort, setSort] = React.useState<SortState>(null)
|
||||||
|
|
||||||
@ -427,9 +435,31 @@ export default function FinishedDownloadsTableView({
|
|||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleHot={onToggleHot}
|
onToggleHot={
|
||||||
onKeep={keepVideo}
|
onToggleHot
|
||||||
onDelete={deleteVideo}
|
? async (job) => {
|
||||||
|
if (enqueueToggleHot) {
|
||||||
|
const accepted = enqueueToggleHot(job)
|
||||||
|
if (accepted) return
|
||||||
|
}
|
||||||
|
return onToggleHot(job)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onKeep={async (job) => {
|
||||||
|
if (enqueueKeepVideo) {
|
||||||
|
const accepted = enqueueKeepVideo(job)
|
||||||
|
if (accepted) return true
|
||||||
|
}
|
||||||
|
return keepVideo(job)
|
||||||
|
}}
|
||||||
|
onDelete={async (job) => {
|
||||||
|
if (enqueueDeleteVideo) {
|
||||||
|
const accepted = enqueueDeleteVideo(job)
|
||||||
|
if (accepted) return true
|
||||||
|
}
|
||||||
|
return deleteVideo(job)
|
||||||
|
}}
|
||||||
order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']}
|
order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']}
|
||||||
className="flex items-center justify-end gap-1"
|
className="flex items-center justify-end gap-1"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -636,10 +636,8 @@ export default function FinishedVideoPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const byId = await tryFetch(`/api/record/done/meta?id=${encodeURIComponent(previewId)}`)
|
|
||||||
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
|
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
|
||||||
const j = byId ?? byFile
|
if (!aborted && byFile) setFetchedMeta(byFile)
|
||||||
if (!aborted && j) setFetchedMeta(j)
|
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -25,6 +25,16 @@ type ParsedModel = {
|
|||||||
type ImportKind = 'liked' | 'favorite'
|
type ImportKind = 'liked' | 'favorite'
|
||||||
type ModelsViewMode = 'table' | 'gallery'
|
type ModelsViewMode = 'table' | 'gallery'
|
||||||
|
|
||||||
|
type GallerySortMode =
|
||||||
|
| 'created_desc'
|
||||||
|
| 'created_asc'
|
||||||
|
| 'model_asc'
|
||||||
|
| 'model_desc'
|
||||||
|
| 'videos_desc'
|
||||||
|
| 'videos_asc'
|
||||||
|
| 'tags_desc'
|
||||||
|
| 'tags_asc'
|
||||||
|
|
||||||
export type StoredModel = {
|
export type StoredModel = {
|
||||||
id: string
|
id: string
|
||||||
input: string
|
input: string
|
||||||
@ -208,6 +218,46 @@ function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string
|
|||||||
return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() }
|
return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gallerySortModeFromSort(
|
||||||
|
sort: { key: string; direction: 'asc' | 'desc' } | null
|
||||||
|
): GallerySortMode {
|
||||||
|
if (!sort) return 'created_desc'
|
||||||
|
|
||||||
|
if (sort.key === 'createdAt') return sort.direction === 'asc' ? 'created_asc' : 'created_desc'
|
||||||
|
if (sort.key === 'model') return sort.direction === 'asc' ? 'model_asc' : 'model_desc'
|
||||||
|
if (sort.key === 'videos') return sort.direction === 'asc' ? 'videos_asc' : 'videos_desc'
|
||||||
|
if (sort.key === 'tags') return sort.direction === 'asc' ? 'tags_asc' : 'tags_desc'
|
||||||
|
|
||||||
|
return 'created_desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFromGallerySortMode(mode: GallerySortMode): { key: string; direction: 'asc' | 'desc' } {
|
||||||
|
switch (mode) {
|
||||||
|
case 'created_asc':
|
||||||
|
return { key: 'createdAt', direction: 'asc' }
|
||||||
|
case 'created_desc':
|
||||||
|
return { key: 'createdAt', direction: 'desc' }
|
||||||
|
|
||||||
|
case 'model_asc':
|
||||||
|
return { key: 'model', direction: 'asc' }
|
||||||
|
case 'model_desc':
|
||||||
|
return { key: 'model', direction: 'desc' }
|
||||||
|
|
||||||
|
case 'videos_asc':
|
||||||
|
return { key: 'videos', direction: 'asc' }
|
||||||
|
case 'videos_desc':
|
||||||
|
return { key: 'videos', direction: 'desc' }
|
||||||
|
|
||||||
|
case 'tags_asc':
|
||||||
|
return { key: 'tags', direction: 'asc' }
|
||||||
|
case 'tags_desc':
|
||||||
|
return { key: 'tags', direction: 'desc' }
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { key: 'createdAt', direction: 'desc' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function GridIcon() {
|
function GridIcon() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@ -249,8 +299,8 @@ export default function ModelsTab() {
|
|||||||
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
|
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
|
||||||
|
|
||||||
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
|
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
|
||||||
key: 'model',
|
key: 'createdAt',
|
||||||
direction: 'asc',
|
direction: 'desc',
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshVideoCounts = React.useCallback(async () => {
|
const refreshVideoCounts = React.useCallback(async () => {
|
||||||
@ -749,6 +799,34 @@ export default function ModelsTab() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
header: 'Hinzugefügt',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (m) => {
|
||||||
|
const t = Date.parse(String(m.createdAt ?? ''))
|
||||||
|
return Number.isFinite(t) ? t : 0
|
||||||
|
},
|
||||||
|
widthClassName: 'w-[160px]',
|
||||||
|
cell: (m) => {
|
||||||
|
const ts = Date.parse(String(m.createdAt ?? ''))
|
||||||
|
if (!Number.isFinite(ts) || ts <= 0) {
|
||||||
|
return <span className="text-xs text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = new Date(ts)
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-700 dark:text-gray-300 leading-tight">
|
||||||
|
<div className="tabular-nums">
|
||||||
|
{d.toLocaleDateString('de-DE')}
|
||||||
|
</div>
|
||||||
|
<div className="tabular-nums text-gray-500 dark:text-gray-400">
|
||||||
|
{d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@ -823,7 +901,7 @@ export default function ModelsTab() {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [q, tagFilter, viewMode])
|
}, [q, tagFilter, viewMode, sort])
|
||||||
|
|
||||||
const totalItems = sortedAll.length
|
const totalItems = sortedAll.length
|
||||||
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
||||||
@ -910,60 +988,190 @@ export default function ModelsTab() {
|
|||||||
header={
|
header={
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid gap-2 sm:flex sm:items-center sm:justify-between">
|
<div className="grid gap-2 sm:flex sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<div className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||||
Models <span className="text-gray-500 dark:text-gray-400">({filtered.length})</span>
|
Models <span className="text-gray-500 dark:text-gray-400">({filtered.length})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:hidden">
|
{/* Mobile */}
|
||||||
|
<div className="sm:hidden shrink-0">
|
||||||
<Button variant="secondary" size="md" onClick={openImport}>
|
<Button variant="secondary" size="md" onClick={openImport}>
|
||||||
Import
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop */}
|
||||||
|
<div className="hidden sm:block shrink-0">
|
||||||
|
<Button variant="secondary" size="md" onClick={openImport}>
|
||||||
|
Importieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
<div className="w-full sm:w-auto min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
{/* Mobile Layout: 2 Zeilen */}
|
||||||
<ButtonGroup
|
<div className="sm:hidden grid gap-2">
|
||||||
ariaLabel="Ansicht umschalten"
|
{/* Zeile 1: View + Sort */}
|
||||||
size="lg"
|
<div className="grid grid-cols-[auto,minmax(0,1fr)] gap-2 items-center min-w-0">
|
||||||
value={viewMode}
|
<div className="shrink-0">
|
||||||
onChange={(id) => {
|
<ButtonGroup
|
||||||
if (id === 'table' || id === 'gallery') setViewMode(id)
|
ariaLabel="Ansicht umschalten"
|
||||||
}}
|
size="lg"
|
||||||
items={[
|
className="flex w-full [&>button]:flex-1 [&>button]:min-w-0"
|
||||||
{
|
value={viewMode}
|
||||||
id: 'gallery',
|
onChange={(id) => {
|
||||||
label: 'Gallery',
|
if (id === 'table' || id === 'gallery') setViewMode(id)
|
||||||
icon: <GridIcon />,
|
}}
|
||||||
},
|
items={[
|
||||||
{
|
{
|
||||||
id: 'table',
|
id: 'gallery',
|
||||||
label: 'Tabelle',
|
label: 'Gallery',
|
||||||
icon: <TableIcon />,
|
icon: <GridIcon />,
|
||||||
},
|
},
|
||||||
]}
|
{
|
||||||
/>
|
id: 'table',
|
||||||
|
label: 'Tabelle',
|
||||||
|
icon: <TableIcon />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="hidden sm:block">
|
<div className="min-w-0">
|
||||||
<Button variant="secondary" size="md" onClick={openImport}>
|
{viewMode === 'gallery' ? (
|
||||||
Importieren
|
<>
|
||||||
</Button>
|
<label className="sr-only" htmlFor="models-gallery-sort-mobile">
|
||||||
|
Sortierung
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="models-gallery-sort-mobile"
|
||||||
|
value={gallerySortModeFromSort(sort)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = sortFromGallerySortMode(e.target.value as GallerySortMode)
|
||||||
|
setSort(next)
|
||||||
|
}}
|
||||||
|
className="
|
||||||
|
h-9 w-full min-w-0
|
||||||
|
rounded-md px-2 text-sm
|
||||||
|
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||||||
|
dark:bg-white/10 dark:text-white dark:ring-white/10 dark:[color-scheme:dark]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="created_desc">Hinzugefügt ↓</option>
|
||||||
|
<option value="created_asc">Hinzugefügt ↑</option>
|
||||||
|
<option value="model_asc">Model A→Z</option>
|
||||||
|
<option value="model_desc">Model Z→A</option>
|
||||||
|
<option value="videos_desc">Videos ↓</option>
|
||||||
|
<option value="videos_asc">Videos ↑</option>
|
||||||
|
<option value="tags_desc">Tags ↓</option>
|
||||||
|
<option value="tags_asc">Tags ↑</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="
|
||||||
|
h-9 w-full min-w-0
|
||||||
|
rounded-md px-3 text-sm
|
||||||
|
inline-flex items-center
|
||||||
|
bg-gray-50 text-gray-400 ring-1 ring-gray-200
|
||||||
|
dark:bg-white/5 dark:text-white/30 dark:ring-white/10
|
||||||
|
select-none
|
||||||
|
"
|
||||||
|
title="Sortierung in der Tabellenansicht über Spaltenkopf"
|
||||||
|
>
|
||||||
|
Sortierung
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zeile 2: Suche volle Breite */}
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Suchen…"
|
||||||
|
className="
|
||||||
|
w-full min-w-0
|
||||||
|
rounded-md px-3 py-2 text-sm
|
||||||
|
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||||||
|
dark:bg-white/10 dark:text-white dark:ring-white/10
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
{/* Desktop Layout: wie bisher */}
|
||||||
value={q}
|
<div className="hidden sm:flex items-center gap-2 min-w-0 sm:justify-end">
|
||||||
onChange={(e) => setQ(e.target.value)}
|
{viewMode === 'gallery' ? (
|
||||||
placeholder="Suchen…"
|
<div className="shrink-0">
|
||||||
className="
|
<label className="sr-only" htmlFor="models-gallery-sort">
|
||||||
w-full sm:w-[260px]
|
Sortierung
|
||||||
rounded-md px-3 py-2 text-sm
|
</label>
|
||||||
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
|
<select
|
||||||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
id="models-gallery-sort"
|
||||||
dark:bg-white/10 dark:text-white dark:ring-white/10
|
value={gallerySortModeFromSort(sort)}
|
||||||
"
|
onChange={(e) => {
|
||||||
/>
|
const next = sortFromGallerySortMode(e.target.value as GallerySortMode)
|
||||||
|
setSort(next)
|
||||||
|
}}
|
||||||
|
className="
|
||||||
|
h-9 w-auto
|
||||||
|
rounded-md px-3 text-sm
|
||||||
|
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||||||
|
dark:bg-white/10 dark:text-white dark:ring-white/10 dark:[color-scheme:dark]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="created_desc">Hinzugefügt ↓</option>
|
||||||
|
<option value="created_asc">Hinzugefügt ↑</option>
|
||||||
|
<option value="model_asc">Model A→Z</option>
|
||||||
|
<option value="model_desc">Model Z→A</option>
|
||||||
|
<option value="videos_desc">Videos ↓</option>
|
||||||
|
<option value="videos_asc">Videos ↑</option>
|
||||||
|
<option value="tags_desc">Tags ↓</option>
|
||||||
|
<option value="tags_asc">Tags ↑</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="shrink-0">
|
||||||
|
<ButtonGroup
|
||||||
|
ariaLabel="Ansicht umschalten"
|
||||||
|
size="lg"
|
||||||
|
value={viewMode}
|
||||||
|
onChange={(id) => {
|
||||||
|
if (id === 'table' || id === 'gallery') setViewMode(id)
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: 'gallery',
|
||||||
|
label: 'Gallery',
|
||||||
|
icon: <GridIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'table',
|
||||||
|
label: 'Tabelle',
|
||||||
|
icon: <TableIcon />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Suchen…"
|
||||||
|
className="
|
||||||
|
w-full sm:w-[260px]
|
||||||
|
rounded-md px-3 py-2 text-sm
|
||||||
|
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||||||
|
dark:bg-white/10 dark:text-white dark:ring-white/10
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1042,7 +1250,7 @@ export default function ModelsTab() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="group overflow-hidden rounded-md border border-gray-200 bg-slate-900/80 shadow-sm transition hover:shadow-md dark:border-white/10"
|
className="group h-full overflow-hidden rounded-md border border-gray-200 bg-slate-900/80 shadow-sm transition hover:shadow-md dark:border-white/10 flex flex-col"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative cursor-pointer bg-slate-950"
|
className="relative cursor-pointer bg-slate-950"
|
||||||
@ -1092,19 +1300,46 @@ export default function ModelsTab() {
|
|||||||
{/* dunkler Verlauf unten für bessere Lesbarkeit */}
|
{/* dunkler Verlauf unten für bessere Lesbarkeit */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
|
|
||||||
{/* Modelname im Bild unten links (statt nur im Footer dominant) */}
|
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
|
||||||
<div
|
<div
|
||||||
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.7)]"
|
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||||
title={m.modelKey}
|
title={m.modelKey}
|
||||||
>
|
>
|
||||||
{m.modelKey}
|
{m.modelKey}
|
||||||
</div>
|
</div>
|
||||||
{m.host ? (
|
{m.host ? (
|
||||||
<div className="truncate text-[11px] text-white/70">{m.host}</div>
|
<div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||||
|
{m.host}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats im Bild (unten rechts, untereinander, mit Icons) */}
|
||||||
|
<div className="pointer-events-none absolute bottom-2 right-2 z-10 flex flex-col items-end gap-1">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-0.5 py-0 text-[10px] font-semibold text-white/95 tabular-nums"
|
||||||
|
title="Videos"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🎬</span>
|
||||||
|
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||||
|
{videoCountsLoading ? '…' : videoCount}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-0.5 py-0 text-[10px] font-semibold text-white/95 tabular-nums"
|
||||||
|
title="Tags"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🏷️</span>
|
||||||
|
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||||
|
{tags.length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* oben links: Record Actions Overlay */}
|
{/* oben links: Record Actions Overlay */}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -1123,15 +1358,16 @@ export default function ModelsTab() {
|
|||||||
[&_button]:bg-transparent [&_button]:shadow-none
|
[&_button]:bg-transparent [&_button]:shadow-none
|
||||||
[&_button:hover]:bg-white/10
|
[&_button:hover]:bg-white/10
|
||||||
[&_button]:border-0
|
[&_button]:border-0
|
||||||
|
[&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* oben rechts: Status-Icons (nur Anzeige, ohne Hintergrund) */}
|
{/* oben rechts: Status-Icons (nur Anzeige, ohne Hintergrund) */}
|
||||||
<div className="absolute right-2 top-2 flex items-center gap-1.5 pointer-events-none select-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.75)]">
|
<div className="absolute right-2 top-2 flex items-center gap-1.5 pointer-events-none select-none">
|
||||||
{watch ? (
|
{watch ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center text-[18px] leading-none text-indigo-300"
|
className="inline-flex items-center justify-center text-xl leading-none text-indigo-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||||
title="Beobachtet"
|
title="Beobachtet"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
@ -1141,7 +1377,7 @@ export default function ModelsTab() {
|
|||||||
|
|
||||||
{fav ? (
|
{fav ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center text-[18px] leading-none text-amber-300"
|
className="inline-flex items-center justify-center text-xl leading-none text-amber-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||||
title="Favorit"
|
title="Favorit"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
@ -1151,7 +1387,7 @@ export default function ModelsTab() {
|
|||||||
|
|
||||||
{liked ? (
|
{liked ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center text-[18px] leading-none text-rose-300"
|
className="inline-flex items-center justify-center text-xl leading-none text-rose-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||||
title="Gefällt mir"
|
title="Gefällt mir"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
@ -1164,12 +1400,12 @@ export default function ModelsTab() {
|
|||||||
{(m.hot || m.keep) && (
|
{(m.hot || m.keep) && (
|
||||||
<div className="absolute left-2 top-2 flex flex-col gap-1">
|
<div className="absolute left-2 top-2 flex flex-col gap-1">
|
||||||
{m.hot ? (
|
{m.hot ? (
|
||||||
<span className="rounded bg-amber-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow">
|
<span className="rounded bg-amber-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||||
HOT
|
HOT
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{m.keep ? (
|
{m.keep ? (
|
||||||
<span className="rounded bg-indigo-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow">
|
<span className="rounded bg-indigo-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||||
KEEP
|
KEEP
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@ -1179,69 +1415,44 @@ export default function ModelsTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2">
|
<div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2 rounded-b-md flex-1">
|
||||||
{/* Zeile 1: Stats links, Actions rechts */}
|
{/* Mobile: kompakter, aber nicht gequetscht */}
|
||||||
<div className="flex items-center justify-between gap-2 text-[11px]">
|
<div className="sm:hidden">
|
||||||
<div className="flex items-center gap-1.5 min-w-0 text-slate-300">
|
{/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
|
||||||
<span className="inline-flex items-center rounded-md bg-white/5 px-1.5 py-0.5 tabular-nums">
|
|
||||||
{videoCountsLoading ? '…' : videoCount} Videos
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{tags.length > 0 ? (
|
|
||||||
<span className="inline-flex items-center rounded-md bg-white/5 px-1.5 py-0.5 tabular-nums">
|
|
||||||
{tags.length} Tags
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 shrink-0"
|
className="grid grid-cols-3 gap-1.5"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span
|
<Button
|
||||||
|
variant={watch ? 'soft' : 'secondary'}
|
||||||
|
size="xs"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
hideUntilHover && !watch
|
'h-8 min-w-0 px-0 shadow-none',
|
||||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
watch
|
||||||
: 'opacity-100'
|
? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30'
|
||||||
|
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
|
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
patch(m.id, { watched: !watch })
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
variant={watch ? 'soft' : 'secondary'}
|
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
|
||||||
size="xs"
|
|
||||||
rounded="full"
|
|
||||||
className={clsx(
|
|
||||||
'h-7 w-7 p-0 min-w-0',
|
|
||||||
watch
|
|
||||||
? 'bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/30 shadow-none'
|
|
||||||
: 'bg-white/5 text-indigo-200/80 shadow-none hover:bg-white/10 hover:text-indigo-200'
|
|
||||||
)}
|
|
||||||
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
patch(m.id, { watched: !watch })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'text-sm leading-none',
|
|
||||||
watch ? 'text-indigo-300' : 'text-slate-300 group-hover:text-indigo-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
👁
|
👁
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</span>
|
||||||
</span>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={fav ? 'soft' : 'secondary'}
|
variant={fav ? 'soft' : 'secondary'}
|
||||||
size="xs"
|
size="xs"
|
||||||
rounded="full"
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-7 w-7 p-0 min-w-0',
|
'h-8 min-w-0 px-0 shadow-none',
|
||||||
hideUntilHover && !fav ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
|
|
||||||
fav
|
fav
|
||||||
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 shadow-none'
|
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
|
||||||
: 'bg-white/5 text-amber-200/80 shadow-none hover:bg-white/10 hover:text-amber-200'
|
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -1250,26 +1461,21 @@ export default function ModelsTab() {
|
|||||||
else patch(m.id, { favorite: true, liked: false })
|
else patch(m.id, { favorite: true, liked: false })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
className={clsx(
|
<span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}>
|
||||||
'text-sm leading-none',
|
★
|
||||||
fav ? 'text-amber-300' : 'text-slate-300 group-hover:text-amber-200'
|
</span>
|
||||||
)}
|
|
||||||
>
|
|
||||||
★
|
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={liked ? 'soft' : 'secondary'}
|
variant={liked ? 'soft' : 'secondary'}
|
||||||
size="xs"
|
size="xs"
|
||||||
rounded="full"
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-7 w-7 p-0 min-w-0',
|
'h-8 min-w-0 px-0 shadow-none',
|
||||||
hideUntilHover && !liked ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
|
|
||||||
liked
|
liked
|
||||||
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 shadow-none'
|
? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30'
|
||||||
: 'bg-white/5 text-rose-200/80 shadow-none hover:bg-white/10 hover:text-rose-200'
|
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -1278,33 +1484,132 @@ export default function ModelsTab() {
|
|||||||
else patch(m.id, { liked: true, favorite: false })
|
else patch(m.id, { liked: true, favorite: false })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
className={clsx(
|
<span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}>
|
||||||
'text-sm leading-none',
|
♥
|
||||||
liked ? 'text-rose-300' : 'text-slate-300 group-hover:text-rose-200'
|
</span>
|
||||||
)}
|
|
||||||
>
|
|
||||||
♥
|
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zeile 2: Tags */}
|
||||||
|
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
|
||||||
|
{shownTags.length > 0 ? (
|
||||||
|
shownTags.map((t) => (
|
||||||
|
<TagBadge
|
||||||
|
key={`${m.id}:${t}`}
|
||||||
|
tag={t}
|
||||||
|
title={t}
|
||||||
|
active={activeTagSet.has(t.toLowerCase())}
|
||||||
|
onClick={toggleTagFilter}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zeile 2: Tags (immer vorhanden für gleiche Kartenhöhe) */}
|
{/* Desktop: ohne Footer-Stats (wie mobile Buttons) */}
|
||||||
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
|
<div className="hidden sm:block">
|
||||||
{shownTags.length > 0 ? (
|
{/* Zeile 1: Actions rechts, Style wie mobile */}
|
||||||
shownTags.map((t) => (
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<TagBadge
|
<div className="grid grid-cols-3 gap-1.5 w-full">
|
||||||
key={`${m.id}:${t}`}
|
<Button
|
||||||
tag={t}
|
variant={watch ? 'soft' : 'secondary'}
|
||||||
title={t}
|
size="xs"
|
||||||
active={activeTagSet.has(t.toLowerCase())}
|
className={clsx(
|
||||||
onClick={toggleTagFilter}
|
'h-8 min-w-0 px-0 shadow-none',
|
||||||
/>
|
hideUntilHover && !watch
|
||||||
))
|
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||||
) : (
|
: 'opacity-100',
|
||||||
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
|
watch
|
||||||
)}
|
? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30'
|
||||||
|
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
patch(m.id, { watched: !watch })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
|
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
|
||||||
|
👁
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={fav ? 'soft' : 'secondary'}
|
||||||
|
size="xs"
|
||||||
|
className={clsx(
|
||||||
|
'h-8 min-w-0 px-0 shadow-none',
|
||||||
|
hideUntilHover && !fav
|
||||||
|
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||||
|
: 'opacity-100',
|
||||||
|
fav
|
||||||
|
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
|
||||||
|
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (fav) patch(m.id, { favorite: false })
|
||||||
|
else patch(m.id, { favorite: true, liked: false })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
|
<span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={liked ? 'soft' : 'secondary'}
|
||||||
|
size="xs"
|
||||||
|
className={clsx(
|
||||||
|
'h-8 min-w-0 px-0 shadow-none',
|
||||||
|
hideUntilHover && !liked
|
||||||
|
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||||
|
: 'opacity-100',
|
||||||
|
liked
|
||||||
|
? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30'
|
||||||
|
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (liked) patch(m.id, { liked: false })
|
||||||
|
else patch(m.id, { liked: true, favorite: false })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
|
<span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}>
|
||||||
|
♥
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zeile 2: Tags (immer vorhanden für gleiche Kartenhöhe) */}
|
||||||
|
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
|
||||||
|
{shownTags.length > 0 ? (
|
||||||
|
shownTags.map((t) => (
|
||||||
|
<TagBadge
|
||||||
|
key={`${m.id}:${t}`}
|
||||||
|
tag={t}
|
||||||
|
title={t}
|
||||||
|
active={activeTagSet.has(t.toLowerCase())}
|
||||||
|
onClick={toggleTagFilter}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -702,6 +702,7 @@ export default function Player({
|
|||||||
|
|
||||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||||
const miniDesktop = mini && isDesktop
|
const miniDesktop = mini && isDesktop
|
||||||
|
const usePortal = expanded || miniDesktop
|
||||||
|
|
||||||
const WIN_KEY = 'player_window_v1'
|
const WIN_KEY = 'player_window_v1'
|
||||||
|
|
||||||
@ -745,14 +746,18 @@ export default function Player({
|
|||||||
React.useEffect(() => setMounted(true), [])
|
React.useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (!usePortal) {
|
||||||
|
setPortalTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let el = document.getElementById('player-root') as HTMLElement | null
|
let el = document.getElementById('player-root') as HTMLElement | null
|
||||||
if (!el) {
|
if (!el) {
|
||||||
el = document.createElement('div')
|
el = document.createElement('div')
|
||||||
el.id = 'player-root'
|
el.id = 'player-root'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Mobile: immer in <body>, damit "fixed bottom-0" am echten Viewport hängt
|
// Desktop / Expanded: im Top-Layer (Dialog) oder body
|
||||||
// ✅ Desktop: in den obersten offenen Dialog, damit er im Top-Layer vor dem Modal liegt
|
|
||||||
let host: HTMLElement | null = null
|
let host: HTMLElement | null = null
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
@ -767,7 +772,7 @@ export default function Player({
|
|||||||
el.style.zIndex = '2147483647'
|
el.style.zIndex = '2147483647'
|
||||||
|
|
||||||
setPortalTarget(el)
|
setPortalTarget(el)
|
||||||
}, [isDesktop])
|
}, [isDesktop, usePortal])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const p: any = playerRef.current
|
const p: any = playerRef.current
|
||||||
@ -1612,7 +1617,8 @@ export default function Player({
|
|||||||
if (job.status !== 'running') setStopPending(false)
|
if (job.status !== 'running') setStopPending(false)
|
||||||
}, [job.id, job.status])
|
}, [job.id, job.status])
|
||||||
|
|
||||||
if (!mounted || !portalTarget) return null
|
if (!mounted) return null
|
||||||
|
if (usePortal && !portalTarget) return null
|
||||||
|
|
||||||
const overlayBtn =
|
const overlayBtn =
|
||||||
'inline-flex items-center justify-center rounded-md p-2 transition ' +
|
'inline-flex items-center justify-center rounded-md p-2 transition ' +
|
||||||
@ -2110,7 +2116,7 @@ export default function Player({
|
|||||||
? { left: win.x, top: win.y, width: win.w, height: win.h }
|
? { left: win.x, top: win.y, width: win.w, height: win.h }
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return createPortal(
|
const content = (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
/* Live-Download: Progress/Seek-Bar ausblenden */
|
/* Live-Download: Progress/Seek-Bar ausblenden */
|
||||||
@ -2192,34 +2198,34 @@ export default function Player({
|
|||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{expanded || miniDesktop ? (
|
{expanded || miniDesktop ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed z-[2147483647]',
|
'fixed z-[2147483647]',
|
||||||
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
|
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
...(wrapStyle as any),
|
...(wrapStyle as any),
|
||||||
willChange: isResizing ? 'left, top, width, height' : undefined,
|
willChange: isResizing ? 'left, top, width, height' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{snapGhostEl}
|
{snapGhostEl}
|
||||||
|
|
||||||
{cardEl}
|
{cardEl}
|
||||||
|
|
||||||
{miniDesktop ? (
|
{miniDesktop ? (
|
||||||
<div className="pointer-events-none absolute inset-0">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
<div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />
|
<div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />
|
||||||
<div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} />
|
<div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} />
|
||||||
<div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} />
|
<div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} />
|
||||||
<div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />
|
<div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />
|
||||||
|
|
||||||
<div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} />
|
<div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} />
|
||||||
<div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} />
|
<div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} />
|
||||||
<div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} />
|
<div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} />
|
||||||
<div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
|
<div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
@ -2234,7 +2240,12 @@ export default function Player({
|
|||||||
{cardEl}
|
{cardEl}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>,
|
</>
|
||||||
portalTarget
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (usePortal) {
|
||||||
|
return createPortal(content, portalTarget!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import LabeledSwitch from './LabeledSwitch'
|
import LabeledSwitch from './LabeledSwitch'
|
||||||
import GenerateAssetsTask from './GenerateAssetsTask'
|
import Task from './Task'
|
||||||
import TaskList from './TaskList'
|
import TaskList from './TaskList'
|
||||||
import type { TaskItem } from './TaskList'
|
import type { TaskItem } from './TaskList'
|
||||||
|
|
||||||
@ -416,76 +416,81 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<Task
|
||||||
<div className="min-w-0 flex-1">
|
title="Assets-Generator"
|
||||||
<GenerateAssetsTask
|
description="Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste."
|
||||||
onFinished={onAssetsGenerated}
|
startLabel="Start"
|
||||||
onStart={(ac) => {
|
startingLabel="Starte…"
|
||||||
assetsAbortRef.current = ac
|
startUrl="/api/tasks/generate-assets"
|
||||||
setAssetsTask((t: TaskItem) => ({
|
stopUrl="/api/tasks/generate-assets"
|
||||||
...t,
|
sseUrl="/api/tasks/assets/stream"
|
||||||
status: 'running',
|
onFinished={onAssetsGenerated}
|
||||||
title: 'Assets generieren',
|
onStart={(ac) => {
|
||||||
text: '',
|
assetsAbortRef.current = ac
|
||||||
done: 0,
|
setAssetsTask((t: TaskItem) => ({
|
||||||
total: 0,
|
...t,
|
||||||
err: undefined,
|
status: 'running',
|
||||||
fading: false,
|
title: 'Assets generieren',
|
||||||
}))
|
text: '',
|
||||||
}}
|
done: 0,
|
||||||
onProgress={(p) => {
|
total: 0,
|
||||||
const fn = shortTaskFilename(p.currentFile)
|
err: undefined,
|
||||||
|
fading: false,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onProgress={(p) => {
|
||||||
|
const fn = shortTaskFilename(p.currentFile)
|
||||||
|
|
||||||
setAssetsTask((t: TaskItem) => ({
|
setAssetsTask((t: TaskItem) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
title: 'Assets generieren',
|
title: 'Assets generieren',
|
||||||
text: fn || '',
|
text: fn || '',
|
||||||
done: p.done,
|
done: p.done,
|
||||||
total: p.total,
|
total: p.total,
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
assetsAbortRef.current = null
|
assetsAbortRef.current = null
|
||||||
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
|
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
|
||||||
fadeOutTask(setAssetsTask)
|
fadeOutTask(setAssetsTask)
|
||||||
}}
|
}}
|
||||||
onCancelled={() => {
|
onCancelled={() => {
|
||||||
assetsAbortRef.current = null
|
assetsAbortRef.current = null
|
||||||
setAssetsTask((t: TaskItem) => ({
|
setAssetsTask((t: TaskItem) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
title: 'Assets generieren',
|
title: 'Assets generieren',
|
||||||
text: 'Abgebrochen.',
|
text: 'Abgebrochen.',
|
||||||
}))
|
}))
|
||||||
fadeOutTask(setAssetsTask)
|
fadeOutTask(setAssetsTask)
|
||||||
}}
|
}}
|
||||||
onError={(message) => {
|
onError={(message) => {
|
||||||
assetsAbortRef.current = null
|
assetsAbortRef.current = null
|
||||||
setAssetsTask((t: TaskItem) => ({
|
setAssetsTask((t: TaskItem) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
title: 'Assets generieren',
|
title: 'Assets generieren',
|
||||||
text: 'Fehler beim Generieren.',
|
text: 'Fehler beim Generieren.',
|
||||||
err: message,
|
err: message,
|
||||||
}))
|
}))
|
||||||
fadeOutTask(setAssetsTask)
|
fadeOutTask(setAssetsTask)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 flex items-center gap-2">
|
<Task
|
||||||
<Button
|
title="Aufräumen"
|
||||||
variant="secondary"
|
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
|
||||||
onClick={cleanupSmallDone}
|
startLabel="Aufräumen"
|
||||||
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
|
startingLabel="Läuft…"
|
||||||
className="h-9 px-3"
|
onTrigger={cleanupSmallDone}
|
||||||
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
|
busy={cleaning}
|
||||||
>
|
disabled={saving || !value.autoDeleteSmallDownloads}
|
||||||
{cleaning ? '…' : 'Aufräumen'}
|
onError={(message) => {
|
||||||
</Button>
|
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
|
||||||
</div>
|
setErr(message)
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -197,15 +197,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
dxRef.current = outDx
|
dxRef.current = outDx
|
||||||
setDx(outDx)
|
setDx(outDx)
|
||||||
|
|
||||||
let ok: boolean | void = true
|
// ✅ Action sofort starten, damit Parent die nächste Karte direkt zeigen kann.
|
||||||
|
// Animation läuft parallel weiter.
|
||||||
|
let actionPromise: Promise<boolean | void>
|
||||||
if (runAction) {
|
if (runAction) {
|
||||||
try {
|
actionPromise = Promise.resolve(
|
||||||
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
|
dir === 'right' ? onSwipeRight() : onSwipeLeft()
|
||||||
} catch {
|
).catch(() => false)
|
||||||
ok = false
|
} else {
|
||||||
}
|
actionPromise = Promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mindestens die Commit-Animation abwarten (für sauberes Gefühl),
|
||||||
|
// aber Action bereits parallel laufen lassen.
|
||||||
|
const animPromise = new Promise<void>((resolve) => {
|
||||||
|
window.setTimeout(resolve, commitMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
const [, ok] = await Promise.all([animPromise, actionPromise])
|
||||||
|
|
||||||
// wenn Aktion fehlschlägt => zurücksnappen
|
// wenn Aktion fehlschlägt => zurücksnappen
|
||||||
if (ok === false) {
|
if (ok === false) {
|
||||||
setAnimMs(snapMs)
|
setAnimMs(snapMs)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// frontend\src\components\ui\GenerateAssetsTask.tsx
|
// frontend\src\components\ui\Task.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
@ -22,6 +22,23 @@ type TaskState = {
|
|||||||
type Progress = { done: number; total: number; currentFile?: string }
|
type Progress = { done: number; total: number; currentFile?: string }
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
/** API-Endpunkte (optional, wenn onTrigger verwendet wird) */
|
||||||
|
startUrl?: string
|
||||||
|
stopUrl?: string
|
||||||
|
sseUrl?: string
|
||||||
|
|
||||||
|
/** Optional: lokaler Trigger statt API/SSE */
|
||||||
|
onTrigger?: () => Promise<void> | void
|
||||||
|
|
||||||
|
/** UI-Texte */
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
startLabel?: string
|
||||||
|
startingLabel?: string
|
||||||
|
disabled?: boolean
|
||||||
|
busy?: boolean
|
||||||
|
|
||||||
|
/** Callback-Hooks (nur für API/SSE-Variante relevant) */
|
||||||
onFinished?: () => void
|
onFinished?: () => void
|
||||||
onStart?: (ac: AbortController) => void
|
onStart?: (ac: AbortController) => void
|
||||||
onProgress?: (p: Progress) => void
|
onProgress?: (p: Progress) => void
|
||||||
@ -55,7 +72,17 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
|||||||
return data as T
|
return data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GenerateAssetsTask({
|
export default function Task({
|
||||||
|
startUrl,
|
||||||
|
stopUrl,
|
||||||
|
sseUrl,
|
||||||
|
onTrigger,
|
||||||
|
title = 'Task',
|
||||||
|
description = 'Startet eine Hintergrundaufgabe. Fortschritt & Abbrechen oben in der Taskliste.',
|
||||||
|
startLabel = 'Start',
|
||||||
|
startingLabel = 'Starte…',
|
||||||
|
disabled = false,
|
||||||
|
busy = false,
|
||||||
onFinished,
|
onFinished,
|
||||||
onStart,
|
onStart,
|
||||||
onProgress,
|
onProgress,
|
||||||
@ -87,10 +114,11 @@ export default function GenerateAssetsTask({
|
|||||||
}, [onError])
|
}, [onError])
|
||||||
|
|
||||||
async function stopInternal() {
|
async function stopInternal() {
|
||||||
|
if (!stopUrl) return
|
||||||
if (stopInFlightRef.current) return
|
if (stopInFlightRef.current) return
|
||||||
stopInFlightRef.current = true
|
stopInFlightRef.current = true
|
||||||
try {
|
try {
|
||||||
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
|
await fetch(stopUrl, { method: 'DELETE', cache: 'no-store' as any })
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
@ -147,7 +175,9 @@ export default function GenerateAssetsTask({
|
|||||||
|
|
||||||
// SSE: State + Progress nur nach oben (TaskList), kein UI hier
|
// SSE: State + Progress nur nach oben (TaskList), kein UI hier
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribeSSE<TaskState>('/api/tasks/assets/stream', 'state', (st) => {
|
if (!sseUrl) return
|
||||||
|
|
||||||
|
const unsub = subscribeSSE<TaskState>(sseUrl, 'state', (st) => {
|
||||||
setState(st)
|
setState(st)
|
||||||
|
|
||||||
if (st?.running) {
|
if (st?.running) {
|
||||||
@ -162,7 +192,7 @@ export default function GenerateAssetsTask({
|
|||||||
|
|
||||||
const errText = String(st?.error ?? '').trim()
|
const errText = String(st?.error ?? '').trim()
|
||||||
|
|
||||||
// ✅ Abbruch ist kein "Fehler"-Event für die UI
|
// Abbruch ist kein "Fehler"-Event für die UI
|
||||||
if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) {
|
if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) {
|
||||||
lastErrorRef.current = errText
|
lastErrorRef.current = errText
|
||||||
onErrorRef.current?.(errText)
|
onErrorRef.current?.(errText)
|
||||||
@ -170,9 +200,10 @@ export default function GenerateAssetsTask({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return () => unsub()
|
return () => unsub()
|
||||||
}, [])
|
}, [sseUrl])
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
|
if (busy) return
|
||||||
if (state?.running) return
|
if (state?.running) return
|
||||||
|
|
||||||
setStartError(null)
|
setStartError(null)
|
||||||
@ -180,11 +211,34 @@ export default function GenerateAssetsTask({
|
|||||||
cancelledRef.current = false
|
cancelledRef.current = false
|
||||||
lastErrorRef.current = ''
|
lastErrorRef.current = ''
|
||||||
|
|
||||||
// Controller vorbereiten, aber TaskList erst *nach* erfolgreichem Start armieren
|
// 1) Lokaler Task-Modus (z.B. Cleanup)
|
||||||
|
if (onTrigger) {
|
||||||
|
try {
|
||||||
|
await onTrigger()
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.message ?? String(e)
|
||||||
|
setStartError(msg)
|
||||||
|
onError?.(msg)
|
||||||
|
} finally {
|
||||||
|
setStarting(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) API/SSE-Task-Modus (wie Assets)
|
||||||
|
if (!startUrl) {
|
||||||
|
const msg = 'Task ist nicht konfiguriert (startUrl fehlt).'
|
||||||
|
setStartError(msg)
|
||||||
|
onError?.(msg)
|
||||||
|
setStarting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller vorbereiten, aber TaskList erst nach erfolgreichem Start armieren
|
||||||
const ac = ensureControllerCreated()
|
const ac = ensureControllerCreated()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' })
|
const st = await fetchJSON<TaskState>(startUrl, { method: 'POST' })
|
||||||
setState(st)
|
setState(st)
|
||||||
|
|
||||||
// TaskList jetzt aktivieren
|
// TaskList jetzt aktivieren
|
||||||
@ -215,10 +269,8 @@ export default function GenerateAssetsTask({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Assets-Generator</div>
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">{title}</div>
|
||||||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
|
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{description}</div>
|
||||||
Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{startError ? (
|
{startError ? (
|
||||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||||
@ -228,8 +280,8 @@ export default function GenerateAssetsTask({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Button variant="primary" onClick={start} disabled={starting || running}>
|
<Button variant="primary" onClick={start} disabled={disabled || busy || starting || running}>
|
||||||
{starting ? 'Starte…' : 'Start'}
|
{(starting || busy) ? startingLabel : startLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -22,6 +22,12 @@ export type Toast = {
|
|||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
imageAlt?: string
|
imageAlt?: string
|
||||||
durationMs?: number // auto close
|
durationMs?: number // auto close
|
||||||
|
onClick?: () => void
|
||||||
|
closeOnClick?: boolean // default true (bei klickbaren Toasts)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToastInternal = Toast & {
|
||||||
|
open: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToastContextValue = {
|
type ToastContextValue = {
|
||||||
@ -32,29 +38,64 @@ type ToastContextValue = {
|
|||||||
|
|
||||||
const ToastContext = React.createContext<ToastContextValue | null>(null)
|
const ToastContext = React.createContext<ToastContextValue | null>(null)
|
||||||
|
|
||||||
|
const TOAST_LEAVE_MS = 220
|
||||||
|
|
||||||
function iconFor(type: ToastType) {
|
function iconFor(type: ToastType) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success':
|
case 'success':
|
||||||
return { Icon: CheckCircleIcon, cls: 'text-emerald-500' }
|
return { Icon: CheckCircleIcon, cls: 'text-emerald-600 dark:text-emerald-400' }
|
||||||
case 'error':
|
case 'error':
|
||||||
return { Icon: XCircleIcon, cls: 'text-rose-500' }
|
return { Icon: XCircleIcon, cls: 'text-rose-600 dark:text-rose-400' }
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return { Icon: ExclamationTriangleIcon, cls: 'text-amber-500' }
|
return { Icon: ExclamationTriangleIcon, cls: 'text-amber-600 dark:text-amber-400' }
|
||||||
default:
|
default:
|
||||||
return { Icon: InformationCircleIcon, cls: 'text-sky-500' }
|
return { Icon: InformationCircleIcon, cls: 'text-sky-600 dark:text-sky-400' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function borderFor(type: ToastType) {
|
function borderFor(type: ToastType) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success':
|
case 'success':
|
||||||
return 'border-emerald-200/70 dark:border-emerald-400/20'
|
return 'border-emerald-200/80 dark:border-emerald-400/20'
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'border-rose-200/70 dark:border-rose-400/20'
|
return 'border-rose-200/80 dark:border-rose-400/20'
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return 'border-amber-200/70 dark:border-amber-400/20'
|
return 'border-amber-200/80 dark:border-amber-400/20'
|
||||||
default:
|
default:
|
||||||
return 'border-sky-200/70 dark:border-sky-400/20'
|
return 'border-sky-200/80 dark:border-sky-400/20'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function accentFor(type: ToastType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
line: 'bg-emerald-500 dark:bg-emerald-400',
|
||||||
|
progressTrack: 'bg-emerald-500/10 dark:bg-emerald-400/10',
|
||||||
|
progressFill: 'bg-emerald-500/70 dark:bg-emerald-400/70',
|
||||||
|
iconBg: 'bg-emerald-50 dark:bg-emerald-400/10',
|
||||||
|
}
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
line: 'bg-rose-500 dark:bg-rose-400',
|
||||||
|
progressTrack: 'bg-rose-500/10 dark:bg-rose-400/10',
|
||||||
|
progressFill: 'bg-rose-500/70 dark:bg-rose-400/70',
|
||||||
|
iconBg: 'bg-rose-50 dark:bg-rose-400/10',
|
||||||
|
}
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
line: 'bg-amber-500 dark:bg-amber-400',
|
||||||
|
progressTrack: 'bg-amber-500/10 dark:bg-amber-400/10',
|
||||||
|
progressFill: 'bg-amber-500/70 dark:bg-amber-400/70',
|
||||||
|
iconBg: 'bg-amber-50 dark:bg-amber-400/10',
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
line: 'bg-sky-500 dark:bg-sky-400',
|
||||||
|
progressTrack: 'bg-sky-500/10 dark:bg-sky-400/10',
|
||||||
|
progressFill: 'bg-sky-500/70 dark:bg-sky-400/70',
|
||||||
|
iconBg: 'bg-sky-50 dark:bg-sky-400/10',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,176 +127,416 @@ export function ToastProvider({
|
|||||||
defaultDurationMs?: number
|
defaultDurationMs?: number
|
||||||
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
|
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
|
||||||
}) {
|
}) {
|
||||||
const [toasts, setToasts] = React.useState<Toast[]>([])
|
const [toasts, setToasts] = React.useState<ToastInternal[]>([])
|
||||||
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true)
|
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true)
|
||||||
|
|
||||||
const loadNotificationSetting = React.useCallback(async () => {
|
const autoCloseTimersRef = React.useRef<Record<string, number>>({})
|
||||||
try {
|
const finalizeRemoveTimersRef = React.useRef<Record<string, number>>({})
|
||||||
const r = await fetch('/api/settings', { cache: 'no-store' })
|
|
||||||
if (!r.ok) return
|
const toastStartedAtRef = React.useRef<Record<string, number>>({})
|
||||||
const data = await r.json()
|
const toastRemainingMsRef = React.useRef<Record<string, number>>({})
|
||||||
setNotificationsEnabled(!!(data?.enableNotifications ?? true))
|
const toastPausedRef = React.useRef<Record<string, boolean>>({})
|
||||||
} catch {
|
|
||||||
// ignorieren -> default true
|
const clearTimersFor = React.useCallback((id: string) => {
|
||||||
|
const autoId = autoCloseTimersRef.current[id]
|
||||||
|
if (autoId) {
|
||||||
|
window.clearTimeout(autoId)
|
||||||
|
delete autoCloseTimersRef.current[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
const finId = finalizeRemoveTimersRef.current[id]
|
||||||
|
if (finId) {
|
||||||
|
window.clearTimeout(finId)
|
||||||
|
delete finalizeRemoveTimersRef.current[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
delete toastStartedAtRef.current[id]
|
||||||
|
delete toastRemainingMsRef.current[id]
|
||||||
|
delete toastPausedRef.current[id]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadNotificationSetting = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/settings', { cache: 'no-store' })
|
||||||
|
if (!r.ok) return
|
||||||
|
const data = await r.json()
|
||||||
|
setNotificationsEnabled(!!(data?.enableNotifications ?? true))
|
||||||
|
} catch {
|
||||||
|
// ignorieren -> default true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadNotificationSetting()
|
||||||
|
|
||||||
|
const onUpdated = () => loadNotificationSetting()
|
||||||
|
window.addEventListener('recorder-settings-updated', onUpdated)
|
||||||
|
return () => window.removeEventListener('recorder-settings-updated', onUpdated)
|
||||||
|
}, [loadNotificationSetting])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!notificationsEnabled) {
|
||||||
|
// Nur Fehler sichtbar lassen (animiert schließen)
|
||||||
|
setToasts((prev) => prev.map((t) => (t.type === 'error' ? t : { ...t, open: false })))
|
||||||
|
|
||||||
|
const ids = toasts.filter((t) => t.type !== 'error').map((t) => t.id)
|
||||||
|
ids.forEach((id) => {
|
||||||
|
clearTimersFor(id)
|
||||||
|
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
delete finalizeRemoveTimersRef.current[id]
|
||||||
|
}, TOAST_LEAVE_MS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [notificationsEnabled])
|
||||||
|
|
||||||
|
const remove = React.useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, open: false } : t)))
|
||||||
|
|
||||||
|
clearTimersFor(id)
|
||||||
|
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
delete finalizeRemoveTimersRef.current[id]
|
||||||
|
}, TOAST_LEAVE_MS)
|
||||||
|
},
|
||||||
|
[clearTimersFor]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clear = React.useCallback(() => {
|
||||||
|
setToasts((prev) => prev.map((t) => ({ ...t, open: false })))
|
||||||
|
|
||||||
|
Object.keys(autoCloseTimersRef.current).forEach((id) => {
|
||||||
|
window.clearTimeout(autoCloseTimersRef.current[id])
|
||||||
|
delete autoCloseTimersRef.current[id]
|
||||||
|
})
|
||||||
|
Object.keys(finalizeRemoveTimersRef.current).forEach((id) => {
|
||||||
|
window.clearTimeout(finalizeRemoveTimersRef.current[id])
|
||||||
|
delete finalizeRemoveTimersRef.current[id]
|
||||||
|
})
|
||||||
|
|
||||||
|
window.setTimeout(() => setToasts([]), TOAST_LEAVE_MS)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startAutoCloseTimer = React.useCallback(
|
||||||
|
(id: string, ms: number) => {
|
||||||
|
if (!ms || ms <= 0) return
|
||||||
|
|
||||||
|
// alten Timer sicher entfernen
|
||||||
|
const old = autoCloseTimersRef.current[id]
|
||||||
|
if (old) {
|
||||||
|
window.clearTimeout(old)
|
||||||
|
delete autoCloseTimersRef.current[id]
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
toastStartedAtRef.current[id] = Date.now()
|
||||||
// initial laden
|
toastRemainingMsRef.current[id] = ms
|
||||||
loadNotificationSetting()
|
toastPausedRef.current[id] = false
|
||||||
|
|
||||||
// nach "Speichern" in Settings neu laden
|
autoCloseTimersRef.current[id] = window.setTimeout(() => {
|
||||||
const onUpdated = () => loadNotificationSetting()
|
remove(id)
|
||||||
window.addEventListener('recorder-settings-updated', onUpdated)
|
delete autoCloseTimersRef.current[id]
|
||||||
return () => window.removeEventListener('recorder-settings-updated', onUpdated)
|
delete toastStartedAtRef.current[id]
|
||||||
}, [loadNotificationSetting])
|
delete toastRemainingMsRef.current[id]
|
||||||
|
delete toastPausedRef.current[id]
|
||||||
|
}, ms)
|
||||||
|
},
|
||||||
|
[remove]
|
||||||
|
)
|
||||||
|
|
||||||
// optional: wenn deaktiviert, alle aktuellen Toasts ausblenden
|
const push = React.useCallback(
|
||||||
React.useEffect(() => {
|
(t: Omit<Toast, 'id'>) => {
|
||||||
if (!notificationsEnabled) {
|
if (!notificationsEnabled && t.type !== 'error') return ''
|
||||||
// ✅ Nur nicht-Fehler ausblenden, Fehler dürfen bleiben
|
|
||||||
setToasts((prev) => prev.filter((t) => t.type === 'error'))
|
const id = uid()
|
||||||
|
const durationMs = t.durationMs ?? defaultDurationMs
|
||||||
|
|
||||||
|
setToasts((prev) => {
|
||||||
|
const next: ToastInternal[] = [{ ...t, id, durationMs, open: true }, ...prev]
|
||||||
|
|
||||||
|
const limit = Math.max(1, maxToasts)
|
||||||
|
const kept = next.slice(0, limit)
|
||||||
|
const dropped = next.slice(limit)
|
||||||
|
|
||||||
|
dropped.forEach((d) => clearTimersFor(d.id))
|
||||||
|
return kept
|
||||||
|
})
|
||||||
|
|
||||||
|
if (durationMs && durationMs > 0) {
|
||||||
|
startAutoCloseTimer(id, durationMs)
|
||||||
}
|
}
|
||||||
}, [notificationsEnabled])
|
|
||||||
|
|
||||||
const remove = React.useCallback((id: string) => {
|
return id
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
},
|
||||||
}, [])
|
[defaultDurationMs, maxToasts, notificationsEnabled, clearTimersFor, startAutoCloseTimer]
|
||||||
|
)
|
||||||
|
|
||||||
const clear = React.useCallback(() => setToasts([]), [])
|
const pauseAutoCloseTimer = React.useCallback((id: string) => {
|
||||||
|
if (toastPausedRef.current[id]) return
|
||||||
|
|
||||||
const push = React.useCallback(
|
const timerId = autoCloseTimersRef.current[id]
|
||||||
(t: Omit<Toast, 'id'>) => {
|
if (!timerId) return
|
||||||
// ✅ Errors IMMER zeigen, alles andere abhängig vom Toggle
|
|
||||||
if (!notificationsEnabled && t.type !== 'error') return ''
|
|
||||||
|
|
||||||
const id = uid()
|
window.clearTimeout(timerId)
|
||||||
const durationMs = t.durationMs ?? defaultDurationMs
|
delete autoCloseTimersRef.current[id]
|
||||||
|
|
||||||
setToasts((prev) => {
|
const startedAt = toastStartedAtRef.current[id] ?? Date.now()
|
||||||
const next = [{ ...t, id, durationMs }, ...prev]
|
const remaining = toastRemainingMsRef.current[id] ?? 0
|
||||||
return next.slice(0, Math.max(1, maxToasts))
|
const elapsed = Date.now() - startedAt
|
||||||
})
|
const nextRemaining = Math.max(0, remaining - elapsed)
|
||||||
|
|
||||||
if (durationMs && durationMs > 0) {
|
toastRemainingMsRef.current[id] = nextRemaining
|
||||||
window.setTimeout(() => remove(id), durationMs)
|
toastPausedRef.current[id] = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resumeAutoCloseTimer = React.useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (!toastPausedRef.current[id]) return
|
||||||
|
|
||||||
|
const remaining = toastRemainingMsRef.current[id] ?? 0
|
||||||
|
if (remaining <= 0) {
|
||||||
|
remove(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toastPausedRef.current[id] = false
|
||||||
|
toastStartedAtRef.current[id] = Date.now()
|
||||||
|
|
||||||
|
autoCloseTimersRef.current[id] = window.setTimeout(() => {
|
||||||
|
remove(id)
|
||||||
|
delete autoCloseTimersRef.current[id]
|
||||||
|
delete toastStartedAtRef.current[id]
|
||||||
|
delete toastRemainingMsRef.current[id]
|
||||||
|
delete toastPausedRef.current[id]
|
||||||
|
}, remaining)
|
||||||
|
},
|
||||||
|
[remove]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
|
||||||
|
Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const ctx = React.useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
|
||||||
|
|
||||||
|
const posCls =
|
||||||
|
position === 'top-right'
|
||||||
|
? 'items-start sm:items-start sm:justify-start'
|
||||||
|
: position === 'top-left'
|
||||||
|
? 'items-start sm:items-start sm:justify-start'
|
||||||
|
: position === 'bottom-left'
|
||||||
|
? 'items-end sm:items-end sm:justify-end'
|
||||||
|
: 'items-end sm:items-end sm:justify-end'
|
||||||
|
|
||||||
|
const alignCls = position.endsWith('left') ? 'sm:items-start' : 'sm:items-end'
|
||||||
|
const insetCls = position.startsWith('top') ? 'top-0 bottom-auto' : 'bottom-0 top-auto'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={ctx}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes toast-progress {
|
||||||
|
from { transform: scaleX(1); }
|
||||||
|
to { transform: scaleX(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return id
|
.toast-progress-bar {
|
||||||
},
|
animation-name: toast-progress;
|
||||||
[defaultDurationMs, maxToasts, remove, notificationsEnabled]
|
animation-timing-function: linear;
|
||||||
)
|
animation-fill-mode: forwards;
|
||||||
|
transform-origin: left center;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
const ctx = React.useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
|
{/* Live region */}
|
||||||
|
<div
|
||||||
|
aria-live="assertive"
|
||||||
|
className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')}
|
||||||
|
>
|
||||||
|
<div className={['flex w-full px-3 py-4 sm:px-6 sm:py-6', posCls].join(' ')}>
|
||||||
|
<div className={['flex w-full flex-col gap-2.5 sm:gap-3', alignCls].join(' ')}>
|
||||||
|
{toasts.map((t) => {
|
||||||
|
const { Icon, cls } = iconFor(t.type)
|
||||||
|
const accents = accentFor(t.type)
|
||||||
|
const title = (t.title || '').trim() || titleDefault(t.type)
|
||||||
|
const msg = (t.message || '').trim()
|
||||||
|
const img = (t.imageUrl || '').trim()
|
||||||
|
const imgAlt = (t.imageAlt || title).trim()
|
||||||
|
const isClickable = typeof t.onClick === 'function'
|
||||||
|
|
||||||
const posCls =
|
return (
|
||||||
position === 'top-right'
|
<Transition
|
||||||
? 'items-start sm:items-start sm:justify-start'
|
key={t.id}
|
||||||
: position === 'top-left'
|
appear
|
||||||
? 'items-start sm:items-start sm:justify-start'
|
show={t.open}
|
||||||
: position === 'bottom-left'
|
enter="transform transition ease-out duration-250"
|
||||||
? 'items-end sm:items-end sm:justify-end'
|
enterFrom="opacity-0 -translate-y-3 sm:-translate-y-2"
|
||||||
: 'items-end sm:items-end sm:justify-end'
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="transform transition ease-in duration-200"
|
||||||
const alignCls =
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
position.endsWith('left')
|
leaveTo="opacity-0 -translate-y-2 sm:-translate-y-3"
|
||||||
? 'sm:items-start'
|
>
|
||||||
: 'sm:items-end'
|
<div
|
||||||
|
role={isClickable ? 'button' : 'status'}
|
||||||
const insetCls =
|
className={[
|
||||||
position.startsWith('top')
|
'pointer-events-auto relative w-[22rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl',
|
||||||
? 'top-0 bottom-auto'
|
'border bg-white dark:bg-slate-900',
|
||||||
: 'bottom-0 top-auto'
|
'shadow-sm',
|
||||||
|
'ring-1 ring-black/5 dark:ring-white/5',
|
||||||
return (
|
isClickable
|
||||||
<ToastContext.Provider value={ctx}>
|
? 'cursor-pointer transition-[box-shadow,transform,background-color] duration-150 hover:bg-gray-50 dark:hover:bg-slate-800/90 hover:shadow-lg hover:ring-black/10 dark:hover:ring-white/10 active:scale-[0.998]'
|
||||||
{children}
|
: '',
|
||||||
|
borderFor(t.type),
|
||||||
{/* Live region */}
|
].join(' ')}
|
||||||
<div
|
onMouseEnter={() => pauseAutoCloseTimer(t.id)}
|
||||||
aria-live="assertive"
|
onMouseLeave={() => resumeAutoCloseTimer(t.id)}
|
||||||
className={[
|
onFocus={() => pauseAutoCloseTimer(t.id)}
|
||||||
'pointer-events-none fixed z-[80] inset-x-0',
|
onBlur={() => resumeAutoCloseTimer(t.id)}
|
||||||
insetCls,
|
onClick={() => {
|
||||||
].join(' ')}
|
if (!t.onClick) return
|
||||||
>
|
t.onClick()
|
||||||
<div className={['flex w-full px-4 py-6 sm:p-6', posCls].join(' ')}>
|
if (t.closeOnClick !== false) remove(t.id)
|
||||||
<div className={['flex w-full flex-col space-y-3', alignCls].join(' ')}>
|
}}
|
||||||
{toasts.map((t) => {
|
onKeyDown={(e) => {
|
||||||
const { Icon, cls } = iconFor(t.type)
|
if (!t.onClick) return
|
||||||
const title = (t.title || '').trim() || titleDefault(t.type)
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
const msg = (t.message || '').trim()
|
e.preventDefault()
|
||||||
const img = (t.imageUrl || '').trim()
|
t.onClick()
|
||||||
const imgAlt = (t.imageAlt || title).trim()
|
if (t.closeOnClick !== false) remove(t.id)
|
||||||
|
}
|
||||||
return (
|
}}
|
||||||
<Transition key={t.id} appear show={true}>
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
<div
|
aria-label={isClickable ? `${title} öffnen` : undefined}
|
||||||
className={[
|
>
|
||||||
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-xl',
|
{img ? (
|
||||||
'border bg-white/90 shadow-lg backdrop-blur',
|
// ===== Layout MIT Bild: Bild links edge-to-edge (oben/unten ohne Padding) =====
|
||||||
'outline-1 outline-black/5',
|
<div className="relative pr-12 sm:pr-14">
|
||||||
'dark:bg-gray-950/70 dark:-outline-offset-1 dark:outline-white/10',
|
<div className="flex items-stretch">
|
||||||
borderFor(t.type),
|
{/* Bild links: edge-to-edge */}
|
||||||
// animation classes (headlessui v2 data-*)
|
|
||||||
'transition data-closed:opacity-0 data-enter:transform data-enter:duration-200 data-enter:ease-out',
|
|
||||||
'data-closed:data-enter:translate-y-2 sm:data-closed:data-enter:translate-y-0',
|
|
||||||
position.endsWith('right')
|
|
||||||
? 'sm:data-closed:data-enter:translate-x-2'
|
|
||||||
: 'sm:data-closed:data-enter:-translate-x-2',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{img ? (
|
|
||||||
<div className="shrink-0">
|
|
||||||
<img
|
|
||||||
src={img}
|
|
||||||
alt={imgAlt}
|
|
||||||
loading="lazy"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
className={[
|
|
||||||
'h-12 w-12 rounded-lg object-cover',
|
|
||||||
'ring-1 ring-black/10 dark:ring-white/10',
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Icon className={['size-6', cls].join(' ')} aria-hidden="true" />
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={imgAlt}
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className={[
|
||||||
|
// größer + volle Höhe des Toast-Inhaltsbereichs visuell anliegend
|
||||||
|
'h-full min-h-[72px] w-16 sm:w-[72px]',
|
||||||
|
'object-cover object-center',
|
||||||
|
// nur links runden, damit es in den Toast passt
|
||||||
|
'rounded-l-xl',
|
||||||
|
// leichte Trennlinie rechts
|
||||||
|
'border-r border-black/5 dark:border-white/10',
|
||||||
|
'bg-gray-100 dark:bg-white/5',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
{/* Textbereich mit Padding */}
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="min-w-0 flex-1 py-3 pl-3 pr-2 sm:py-3.5 sm:pl-3.5 sm:pr-3">
|
||||||
|
<p className="truncate text-sm font-semibold leading-5 text-gray-900 dark:text-white">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{msg ? (
|
{msg ? (
|
||||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300 break-words">
|
<p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
|
||||||
{msg}
|
{msg}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(t.id)}
|
|
||||||
className="shrink-0 rounded-md text-gray-400 hover:text-gray-600 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:hover:text-white dark:focus:outline-indigo-500"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
<XMarkIcon aria-hidden="true" className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Close-Button vertikal mittig rechts */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
remove(t.id)
|
||||||
|
}}
|
||||||
|
title="Schließen"
|
||||||
|
className={[
|
||||||
|
'absolute right-2 top-1/2 -translate-y-1/2',
|
||||||
|
'inline-flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full',
|
||||||
|
'text-gray-400 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white',
|
||||||
|
'hover:bg-gray-100 dark:hover:bg-white/10',
|
||||||
|
'active:scale-[0.98] active:bg-gray-200/80 dark:active:bg-white/15',
|
||||||
|
'transition',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
|
||||||
|
'dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Schließen</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-5.5 sm:size-6" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</Transition>
|
// ===== Layout OHNE Bild: Icon mittig wie vorher =====
|
||||||
)
|
<div className="relative pr-12 py-2.5 sm:pr-14">
|
||||||
})}
|
<div className="flex items-center gap-3 px-3 sm:px-3.5">
|
||||||
</div>
|
<div className="shrink-0">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'inline-flex h-12 w-12 sm:h-14 sm:w-14 items-center justify-center rounded-full',
|
||||||
|
accents.iconBg,
|
||||||
|
'ring-1 ring-black/5 dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Icon className={['size-5.5 sm:size-6', cls].join(' ')} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold leading-5 text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{msg ? (
|
||||||
|
<p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
|
||||||
|
{msg}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close-Button vertikal mittig rechts */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
remove(t.id)
|
||||||
|
}}
|
||||||
|
title="Schließen"
|
||||||
|
className={[
|
||||||
|
'absolute right-2 top-1/2 -translate-y-1/2',
|
||||||
|
'inline-flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full',
|
||||||
|
'text-gray-400 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white',
|
||||||
|
'hover:bg-gray-100 dark:hover:bg-white/10',
|
||||||
|
'active:scale-[0.98] active:bg-gray-200/80 dark:active:bg-white/15',
|
||||||
|
'transition',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
|
||||||
|
'dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Schließen</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-5.5 sm:size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ToastContext.Provider>
|
</div>
|
||||||
)
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToast() {
|
export function useToast() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user