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 ---
|
||||
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()
|
||||
defer autostartSubsMu.Unlock()
|
||||
|
||||
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 {
|
||||
case ch <- paused:
|
||||
case ch <- state:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@ -40,8 +49,19 @@ func isAutostartPaused() bool {
|
||||
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) {
|
||||
old := isAutostartPaused()
|
||||
old := getAutostartStatePayload()
|
||||
|
||||
if v {
|
||||
atomic.StoreInt32(&autostartPaused, 1)
|
||||
@ -49,9 +69,11 @@ func setAutostartPaused(v bool) {
|
||||
atomic.StoreInt32(&autostartPaused, 0)
|
||||
}
|
||||
|
||||
// nur wenn sich der Zustand wirklich geändert hat -> pushen
|
||||
if old != v {
|
||||
broadcastAutostartPaused(v)
|
||||
newState := getAutostartStatePayload()
|
||||
|
||||
// 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) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"paused": isAutostartPaused(),
|
||||
})
|
||||
writeAutostartState(w)
|
||||
return
|
||||
|
||||
case http.MethodPost:
|
||||
@ -96,11 +114,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
setAutostartPaused(*val)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"paused": isAutostartPaused(),
|
||||
})
|
||||
writeAutostartState(w)
|
||||
return
|
||||
|
||||
default:
|
||||
@ -112,9 +126,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func writeAutostartState(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"paused": isAutostartPaused(),
|
||||
})
|
||||
_ = json.NewEncoder(w).Encode(getAutostartStatePayload())
|
||||
}
|
||||
|
||||
// GET /api/autostart/state
|
||||
@ -149,6 +161,13 @@ func autostartResumeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
writeAutostartState(w)
|
||||
return
|
||||
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)
|
||||
writeAutostartState(w)
|
||||
return
|
||||
@ -176,7 +195,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // wichtig falls Proxy/Nginx
|
||||
|
||||
ch := make(chan bool, 1)
|
||||
ch := make(chan autostartStatePayload, 1)
|
||||
|
||||
// subscribe
|
||||
autostartSubsMu.Lock()
|
||||
@ -191,10 +210,12 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
send := func(paused bool) {
|
||||
send := func(state autostartStatePayload) {
|
||||
payload := map[string]any{
|
||||
"paused": paused,
|
||||
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"paused": state.Paused,
|
||||
"pausedByUser": state.PausedByUser,
|
||||
"pausedByDisk": state.PausedByDisk,
|
||||
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(payload)
|
||||
@ -206,7 +227,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// initial state sofort senden
|
||||
send(isAutostartPaused())
|
||||
send(getAutostartStatePayload())
|
||||
|
||||
ctx := r.Context()
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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.
|
||||
func sumInFlightBytes() uint64 {
|
||||
var sum uint64
|
||||
minKeepBytes := minRelevantInFlightBytes()
|
||||
|
||||
jobsMu.Lock()
|
||||
defer jobsMu.Unlock()
|
||||
@ -219,10 +237,19 @@ func sumInFlightBytes() uint64 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Nimm die Datei, die gerade wächst.
|
||||
// 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.
|
||||
sum += inFlightBytesForJob(j)
|
||||
b := inFlightBytesForJob(j)
|
||||
|
||||
// ✅ Nur "relevante" Dateien berücksichtigen:
|
||||
// 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
|
||||
@ -230,8 +257,11 @@ func sumInFlightBytes() uint64 {
|
||||
|
||||
// startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser.
|
||||
// Bei wenig freiem Platz:
|
||||
// - Autostart pausieren
|
||||
// - laufende Jobs stoppen (nur Status=running und Phase leer)
|
||||
// - diskEmergency aktivieren (Autostart blockieren)
|
||||
// - laufende Jobs stoppen
|
||||
//
|
||||
// Bei Erholung (Resume-Schwelle):
|
||||
// - diskEmergency automatisch wieder freigeben
|
||||
func startDiskSpaceGuard() {
|
||||
t := time.NewTicker(diskGuardInterval)
|
||||
defer t.Stop()
|
||||
@ -259,41 +289,73 @@ func startDiskSpaceGuard() {
|
||||
// Pause = ceil((2 * inFlight) / GiB)
|
||||
// Resume = Pause + 3 GB
|
||||
// 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.
|
||||
// (Optional: Emergency zurücksetzen, damit Autostart wieder frei wird.)
|
||||
// ✅ diskEmergency NICHT sticky behalten.
|
||||
// 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 {
|
||||
// Kein Auto-Recovery:
|
||||
// Emergency bleibt aktiv, bis manuell zurückgesetzt wird.
|
||||
if wasEmergency {
|
||||
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
|
||||
}
|
||||
|
||||
// Wenn Emergency aktiv ist, niemals automatisch freigeben.
|
||||
// (Manueller Reset erforderlich)
|
||||
if atomic.LoadInt32(&diskEmergency) == 1 {
|
||||
isLowForPause := free < pauseNeed
|
||||
isHighEnoughForResume := free >= resumeNeed
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ✅ Normalzustand: solange free >= pauseNeed, nichts tun
|
||||
if free >= pauseNeed {
|
||||
continue
|
||||
}
|
||||
// ✅ Emergency ist aktiv: nur freigeben, wenn Resume-Schwelle erreicht ist
|
||||
if isHighEnoughForResume {
|
||||
atomic.StoreInt32(&diskEmergency, 0)
|
||||
broadcastAutostartPaused()
|
||||
|
||||
// ✅ Trigger: Notbremse aktivieren, Jobs stoppen
|
||||
atomic.StoreInt32(&diskEmergency, 1)
|
||||
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)
|
||||
fmt.Printf(
|
||||
"✅ [disk] Space recovered: free=%s (%dB) (>= %s, %dB, resume=%dGB, inFlight=%s, %dB) -> unblock autostart (path=%s)\n",
|
||||
formatBytesSI(u64ToI64(free)), free,
|
||||
formatBytesSI(u64ToI64(resumeNeed)), resumeNeed,
|
||||
resumeGB,
|
||||
formatBytesSI(u64ToI64(inFlight)), inFlight,
|
||||
dir,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@ -1802,6 +1802,38 @@ func max(a, b int) int {
|
||||
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) {
|
||||
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
|
||||
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)
|
||||
|
||||
// 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) {
|
||||
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
|
||||
return
|
||||
@ -2097,7 +2129,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := renameWithRetry(src, dst); err != nil {
|
||||
if err := renameWithRetryAggressive(src, dst); err != nil {
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
http.Error(w, "restore fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
||||
return
|
||||
@ -2199,7 +2231,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := renameWithRetry(src, dst); err != nil {
|
||||
if err := renameWithRetryAggressive(src, dst); err != nil {
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
||||
return
|
||||
@ -2337,7 +2369,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
||||
return
|
||||
@ -2436,7 +2468,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := renameWithRetry(src, dst); err != nil {
|
||||
if err := renameWithRetryAggressive(src, dst); err != nil {
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
|
||||
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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>App</title>
|
||||
<script type="module" crossorigin src="/assets/index-DNoPI-qJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B-X4TsOo.css">
|
||||
<script type="module" crossorigin src="/assets/index-BZ38s29o.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CZMtb58J.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -410,11 +410,6 @@ export default function App() {
|
||||
const donePrefetchRef = useRef<DonePrefetch | null>(null)
|
||||
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 prefetchDonePage = useCallback(async (pageToFetch: number) => {
|
||||
@ -450,8 +445,16 @@ export default function App() {
|
||||
}, [doneSort])
|
||||
|
||||
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 {
|
||||
// ✅ leichtgewichtiger Endpoint (siehe Backend unten)
|
||||
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
|
||||
if (!res.ok) return
|
||||
|
||||
@ -463,9 +466,14 @@ export default function App() {
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
doneCountInFlightRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doneCountInFlightRef = useRef(false)
|
||||
const doneCountLastAtRef = useRef(0)
|
||||
|
||||
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
|
||||
const finishedReloadTimerRef = useRef<number | null>(null)
|
||||
|
||||
@ -1236,80 +1244,6 @@ export default function App() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (selectedTab !== 'finished') return
|
||||
|
||||
@ -1498,18 +1432,17 @@ export default function App() {
|
||||
const e = ev as CustomEvent<{ delta?: number }>
|
||||
const delta = Number(e.detail?.delta ?? 0)
|
||||
|
||||
if (!Number.isFinite(delta) || delta === 0) {
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
return
|
||||
if (Number.isFinite(delta) && delta !== 0) {
|
||||
setDoneCount((c) => Math.max(0, c + delta))
|
||||
}
|
||||
|
||||
// ✅ Tabs sofort updaten (optimistisch)
|
||||
setDoneCount((c) => Math.max(0, c + delta))
|
||||
|
||||
// ✅ danach server-truth holen + ALL reload
|
||||
// Count darf immer aktualisiert werden (Badge/Header)
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
|
||||
// ✅ Nur Finished-Tab wirklich neu laden
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
requestFinishedReload()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||
@ -1570,124 +1503,160 @@ export default function App() {
|
||||
[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(
|
||||
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
|
||||
const file = baseName(job.output || '')
|
||||
if (!file) return
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
|
||||
)
|
||||
|
||||
try {
|
||||
// ✅ Wichtig: JSON-Response lesen, weil undoToken dort drin steckt
|
||||
const data = await apiJSON<{ undoToken?: string }>(
|
||||
`/api/record/delete?file=${encodeURIComponent(file)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
const data = await runFinishedFileAction<{ undoToken?: string }>({
|
||||
kind: 'delete',
|
||||
file,
|
||||
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(
|
||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
|
||||
)
|
||||
const need = DONE_PAGE_SIZE - filtered.length
|
||||
if (need <= 0) return filtered
|
||||
|
||||
window.setTimeout(() => {
|
||||
setDoneJobs((prev) => {
|
||||
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
|
||||
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
||||
const buf = donePrefetchRef.current
|
||||
|
||||
// ✅ sofort auffüllen, wenn wir Platz haben
|
||||
const need = DONE_PAGE_SIZE - filtered.length
|
||||
if (need <= 0) return filtered
|
||||
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
|
||||
return filtered
|
||||
}
|
||||
|
||||
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
||||
const buf = donePrefetchRef.current
|
||||
const next: RecordJob[] = [...filtered]
|
||||
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) {
|
||||
return filtered
|
||||
}
|
||||
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
|
||||
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]
|
||||
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
|
||||
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
|
||||
return next
|
||||
})
|
||||
|
||||
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
|
||||
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)
|
||||
}
|
||||
// ✅ Count sofort optimistisch runter
|
||||
setDoneCount((c) => Math.max(0, c - 1))
|
||||
|
||||
// buffer zurückschreiben (mit verkürzter items-Liste)
|
||||
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
|
||||
// ✅ Running-/Player-State bereinigen (falls offen)
|
||||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
// ✅ Count sofort optimistisch runter
|
||||
setDoneCount((c) => Math.max(0, c - 1))
|
||||
|
||||
// ✅ 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)
|
||||
// ✅ Prefetch wieder nachfüllen
|
||||
void prefetchDonePage(donePage + 1)
|
||||
}, 320)
|
||||
},
|
||||
onError: async () => {
|
||||
notify.error('Löschen fehlgeschlagen', file)
|
||||
},
|
||||
})
|
||||
|
||||
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
|
||||
return undoToken ? { undoToken } : {} // ✅ kein null mehr
|
||||
} catch (e: any) {
|
||||
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 undoToken ? { undoToken } : {}
|
||||
} catch {
|
||||
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(
|
||||
async (job: RecordJob) => {
|
||||
async (job: RecordJob): Promise<void | { ok: boolean; oldFile: string; newFile: string }> => {
|
||||
const file = baseName(job.output || '')
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
// ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
|
||||
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))
|
||||
|
||||
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
|
||||
@ -1695,30 +1664,38 @@ export default function App() {
|
||||
{ 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(
|
||||
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
|
||||
setPlayerJob((prev) => (prev ? { ...prev, output: apply(prev.output || '') } : prev))
|
||||
// ✅ Player nur updaten, wenn wirklich derselbe Job / dieselbe Datei
|
||||
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) =>
|
||||
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
|
||||
})
|
||||
)
|
||||
|
||||
// ✅ 3) jobs (/record/list) über ID (Fallback: basename)
|
||||
// ✅ jobs (/record/list) über ID (Fallback basename)
|
||||
setJobs((prev) =>
|
||||
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
|
||||
})
|
||||
)
|
||||
@ -1732,6 +1709,13 @@ export default function App() {
|
||||
[notify]
|
||||
)
|
||||
|
||||
const handleDeleteJob = useCallback(
|
||||
async (job: RecordJob): Promise<void> => {
|
||||
await handleDeleteJobWithUndo(job)
|
||||
},
|
||||
[handleDeleteJobWithUndo]
|
||||
)
|
||||
|
||||
// --- flags patch (wie bei dir) ---
|
||||
async function patchModelFlags(patch: any): Promise<any | null> {
|
||||
const res = await fetch('/api/models/flags', {
|
||||
@ -2471,7 +2455,7 @@ export default function App() {
|
||||
// ✅ war irgendwann schon mal online (vor diesem Poll)?
|
||||
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()
|
||||
|
||||
// immer merken: jetzt ist es online
|
||||
@ -2481,10 +2465,17 @@ export default function App() {
|
||||
const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow)
|
||||
if (becamePublicFromWaiting) {
|
||||
if (notificationsOn) {
|
||||
notify.info(name, 'ist wieder online.', {
|
||||
notify.info(modelName, 'ist wieder online.', {
|
||||
imageUrl,
|
||||
imageAlt: `${name} Vorschau`,
|
||||
imageAlt: `${modelName} Vorschau`,
|
||||
durationMs: 5500,
|
||||
onClick: () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('open-model-details', {
|
||||
detail: { modelKey: modelName },
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -2500,12 +2491,19 @@ export default function App() {
|
||||
// Startup-Spam vermeiden
|
||||
if (notificationsOn && !isInitial) {
|
||||
notify.info(
|
||||
name,
|
||||
modelName,
|
||||
cameBackFromOffline ? 'ist wieder online.' : 'ist online.',
|
||||
{
|
||||
imageUrl,
|
||||
imageAlt: `${name} Vorschau`,
|
||||
imageAlt: `${modelName} Vorschau`,
|
||||
durationMs: 5500,
|
||||
onClick: () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('open-model-details', {
|
||||
detail: { modelKey: modelName },
|
||||
})
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -2673,7 +2671,7 @@ export default function App() {
|
||||
|
||||
<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">
|
||||
<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="min-w-0">
|
||||
<div className="min-w-0">
|
||||
@ -2813,7 +2811,7 @@ export default function App() {
|
||||
</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="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" />
|
||||
</div>
|
||||
</div>
|
||||
@ -2849,6 +2847,7 @@ export default function App() {
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleLike={handleToggleLike}
|
||||
onToggleWatch={handleToggleWatch}
|
||||
onKeepJob={handleKeepJob}
|
||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
||||
teaserAudio={Boolean(recSettings.teaserAudio)}
|
||||
|
||||
@ -43,7 +43,14 @@ export default function ButtonGroup({
|
||||
const s = sizeMap[size]
|
||||
|
||||
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) => {
|
||||
const active = it.id === value
|
||||
const isFirst = idx === 0
|
||||
@ -59,16 +66,14 @@ export default function ButtonGroup({
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'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',
|
||||
isLast && 'rounded-r-md',
|
||||
|
||||
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-800 inset-ring-1 inset-ring-indigo-300 hover:bg-indigo-200 ' +
|
||||
'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 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',
|
||||
? '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'
|
||||
: 'bg-white text-gray-900 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:hover:bg-white/20',
|
||||
|
||||
// Disabled
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
|
||||
@ -26,7 +26,11 @@ type WaitingModelRow = {
|
||||
currentShow?: string // public / private / hidden / away / unknown
|
||||
}
|
||||
|
||||
type AutostartState = { paused?: boolean }
|
||||
type AutostartState = {
|
||||
paused?: boolean
|
||||
pausedByUser?: boolean
|
||||
pausedByDisk?: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
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({
|
||||
jobs,
|
||||
@ -725,15 +743,30 @@ export default function Downloads({
|
||||
const [stopAllBusy, setStopAllBusy] = useState(false)
|
||||
|
||||
const [watchedPaused, setWatchedPaused] = useState(false)
|
||||
const [, setWatchedPausedByUser] = useState(false)
|
||||
const [watchedPausedByDisk, setWatchedPausedByDisk] = useState(false)
|
||||
|
||||
const watchedPausedRef = useRef<boolean | null>(null)
|
||||
const watchedPausedByUserRef = useRef<boolean | null>(null)
|
||||
const watchedPausedByDiskRef = useRef<boolean | null>(null)
|
||||
|
||||
const [watchedBusy, setWatchedBusy] = useState(false)
|
||||
|
||||
const refreshWatchedState = useCallback(async () => {
|
||||
try {
|
||||
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
|
||||
const next = Boolean(s?.paused)
|
||||
watchedPausedRef.current = next
|
||||
setWatchedPaused(next)
|
||||
|
||||
const nextPaused = Boolean(s?.paused)
|
||||
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 {
|
||||
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen
|
||||
}
|
||||
@ -748,10 +781,24 @@ export default function Downloads({
|
||||
'/api/autostart/state/stream',
|
||||
'autostart',
|
||||
(data) => {
|
||||
const next = Boolean((data as any)?.paused)
|
||||
if (watchedPausedRef.current === next) return
|
||||
watchedPausedRef.current = next
|
||||
setWatchedPaused(next)
|
||||
const nextPaused = Boolean((data as any)?.paused)
|
||||
const nextPausedByUser = Boolean((data as any)?.pausedByUser)
|
||||
const nextPausedByDisk = Boolean((data as any)?.pausedByDisk)
|
||||
|
||||
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 () => {
|
||||
if (watchedBusy || watchedPaused) return
|
||||
|
||||
setWatchedBusy(true)
|
||||
try {
|
||||
await fetch('/api/autostart/pause', { method: 'POST' })
|
||||
setWatchedPaused(true)
|
||||
const res = await fetch('/api/autostart/pause', { method: 'POST' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
// State kommt normalerweise per SSE; fallback refresh:
|
||||
await refreshWatchedState()
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setWatchedBusy(false)
|
||||
}
|
||||
}, [watchedBusy, watchedPaused])
|
||||
}, [watchedBusy, watchedPaused, refreshWatchedState])
|
||||
|
||||
const resumeWatched = useCallback(async () => {
|
||||
if (watchedBusy || !watchedPaused) return
|
||||
// ✅ Bei Disk-Notbremse kein Resume erlauben
|
||||
if (watchedBusy || !watchedPaused || watchedPausedByDisk) return
|
||||
|
||||
setWatchedBusy(true)
|
||||
try {
|
||||
await fetch('/api/autostart/resume', { method: 'POST' })
|
||||
setWatchedPaused(false)
|
||||
const res = await fetch('/api/autostart/resume', { method: 'POST' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
// State kommt normalerweise per SSE; fallback refresh:
|
||||
await refreshWatchedState()
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setWatchedBusy(false)
|
||||
}
|
||||
}, [watchedBusy, watchedPaused])
|
||||
}, [watchedBusy, watchedPaused, watchedPausedByDisk, refreshWatchedState])
|
||||
|
||||
// ✅ Merkt sich: für diese Jobs wurde "Stop" bereits angefordert (z.B. via "Alle stoppen")
|
||||
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
|
||||
@ -1333,14 +1389,21 @@ export default function Downloads({
|
||||
<Button
|
||||
size="sm"
|
||||
variant={watchedPaused ? 'secondary' : 'primary'}
|
||||
disabled={watchedBusy}
|
||||
disabled={watchedBusy || watchedPausedByDisk}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (watchedPausedByDisk) return
|
||||
void (watchedPaused ? resumeWatched() : pauseWatched())
|
||||
}}
|
||||
className="hidden sm:inline-flex"
|
||||
title={watchedPaused ? 'Autostart fortsetzen' : 'Autostart pausieren'}
|
||||
title={
|
||||
watchedPausedByDisk
|
||||
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
|
||||
: watchedPaused
|
||||
? 'Autostart fortsetzen'
|
||||
: 'Autostart pausieren'
|
||||
}
|
||||
leadingIcon={
|
||||
watchedPaused
|
||||
? <PauseIcon className="size-4 shrink-0" />
|
||||
@ -1377,8 +1440,9 @@ export default function Downloads({
|
||||
<div className="mt-3 grid gap-4">
|
||||
{downloadJobRows.length > 0 ? (
|
||||
<>
|
||||
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||
Downloads ({downloadJobRows.length})
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span>Downloads ({downloadJobRows.length})</span>
|
||||
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
|
||||
</div>
|
||||
{downloadJobRows.map((r) => (
|
||||
<DownloadsCardRow
|
||||
@ -1455,8 +1519,9 @@ export default function Downloads({
|
||||
<div className="mt-3 space-y-4">
|
||||
{downloadJobRows.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Downloads ({downloadJobRows.length})
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<span>Downloads ({downloadJobRows.length})</span>
|
||||
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
|
||||
</div>
|
||||
<Table
|
||||
rows={downloadJobRows}
|
||||
|
||||
@ -57,6 +57,7 @@ type Props = {
|
||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||
onKeepJob?: (job: RecordJob) => void | Promise<void>
|
||||
doneTotal: number
|
||||
page: number
|
||||
pageSize: number
|
||||
@ -195,6 +196,112 @@ const sizeBytesOf = (job: RecordJob): number | 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({
|
||||
jobs,
|
||||
doneJobs,
|
||||
@ -207,6 +314,7 @@ export default function FinishedDownloads({
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
onToggleWatch,
|
||||
onKeepJob,
|
||||
doneTotal,
|
||||
page,
|
||||
pageSize,
|
||||
@ -226,6 +334,8 @@ export default function FinishedDownloads({
|
||||
|
||||
const notify = useNotify()
|
||||
|
||||
const mutationQueue = useMutationQueue()
|
||||
|
||||
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
||||
const [teaserKey, setTeaserKey] = 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 refillInFlightRef = React.useRef(false)
|
||||
const refillQueuedWhileInFlightRef = React.useRef(false)
|
||||
|
||||
type UndoAction =
|
||||
| { 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 fetchAllDoneJobs = useCallback(
|
||||
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 {
|
||||
const res = await fetch(
|
||||
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
|
||||
@ -405,7 +523,7 @@ export default function FinishedDownloads({
|
||||
setOverrideDoneJobs(items)
|
||||
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (shouldShowLoading) setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[sortMode, includeKeep]
|
||||
@ -458,6 +576,12 @@ export default function FinishedDownloads({
|
||||
|
||||
const finishRefill = () => {
|
||||
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
|
||||
@ -966,149 +1090,296 @@ export default function FinishedDownloads({
|
||||
|
||||
const releasePlayingFile = useCallback(
|
||||
async (file: string, opts?: { close?: boolean }) => {
|
||||
// 1) App-/Overlay-Player freigeben
|
||||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||||
if (opts?.close) {
|
||||
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(
|
||||
async (job: RecordJob): Promise<boolean> => {
|
||||
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise<boolean> => {
|
||||
const file = baseName(job.output || '')
|
||||
const key = keyFor(job)
|
||||
|
||||
if (!file) {
|
||||
notify.error('Löschen nicht möglich', 'Kein Dateiname gefunden – kann nicht löschen.')
|
||||
return false
|
||||
}
|
||||
if (deletingKeys.has(key)) return false
|
||||
|
||||
markDeleting(key, true)
|
||||
try {
|
||||
await releasePlayingFile(file, { close: true })
|
||||
|
||||
// ✅ Wenn App-Handler vorhanden: den benutzen
|
||||
// (WICHTIG für Undo: onDeleteJob sollte idealerweise {undoToken} zurückgeben)
|
||||
if (onDeleteJob) {
|
||||
const r = await onDeleteJob(job)
|
||||
const undoToken = (r as any)?.undoToken
|
||||
|
||||
if (typeof undoToken === 'string' && undoToken) {
|
||||
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
|
||||
} else {
|
||||
setLastAction(null)
|
||||
// optional: nicht als "error" melden, eher info/warn
|
||||
// notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.')
|
||||
const res = await runFileMutation({
|
||||
kind: 'delete',
|
||||
job,
|
||||
file,
|
||||
rowKey: key,
|
||||
setBusy: (v) => markDeleting(key, v),
|
||||
isBusyNow: () => deletingKeys.has(key),
|
||||
optimisticRemove: true,
|
||||
alreadyRemoved: opts?.alreadyRemoved,
|
||||
labels: {
|
||||
invalidTitle: 'Löschen nicht möglich',
|
||||
invalidBody: 'Kein Dateiname gefunden – kann nicht löschen.',
|
||||
inUseTitle: 'Löschen fehlgeschlagen',
|
||||
failTitle: 'Löschen fehlgeschlagen',
|
||||
failPrefix: file,
|
||||
},
|
||||
run: async () => {
|
||||
if (onDeleteJob) {
|
||||
return await withFileReleaseRetry(
|
||||
file,
|
||||
async () => await onDeleteJob(job),
|
||||
{ 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()
|
||||
emitCountHint(-1)
|
||||
// animateRemove queued already queueRefill(), aber extra ist ok:
|
||||
// queueRefill()
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
return res.ok
|
||||
},
|
||||
[
|
||||
baseName,
|
||||
keyFor,
|
||||
deletingKeys,
|
||||
markDeleting,
|
||||
releasePlayingFile,
|
||||
onDeleteJob,
|
||||
animateRemove,
|
||||
notify,
|
||||
restoreRow,
|
||||
withFileReleaseRetry,
|
||||
runFileMutation,
|
||||
queueRefill,
|
||||
emitCountHint,
|
||||
]
|
||||
)
|
||||
|
||||
const keepVideo = useCallback(
|
||||
async (job: RecordJob) => {
|
||||
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => {
|
||||
const file = baseName(job.output || '')
|
||||
const key = keyFor(job)
|
||||
|
||||
if (!file) {
|
||||
notify.error('Keep nicht möglich', 'Kein Dateiname gefunden – kann nicht behalten.')
|
||||
return false
|
||||
}
|
||||
if (keepingKeys.has(key) || deletingKeys.has(key)) return false
|
||||
const res = await runFileMutation({
|
||||
kind: 'keep',
|
||||
job,
|
||||
file,
|
||||
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)
|
||||
try {
|
||||
await releasePlayingFile(file, { close: true })
|
||||
const r = await withFileReleaseRetry(
|
||||
file,
|
||||
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' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
|
||||
|
||||
// ✅ Backend liefert ggf. newFile (uniqueDestPath)
|
||||
const data = (await res.json().catch(() => null)) as any
|
||||
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
|
||||
queueRefill()
|
||||
emitCountHint(includeKeep ? 0 : -1)
|
||||
},
|
||||
})
|
||||
|
||||
// ✅ Undo-Info merken
|
||||
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)
|
||||
}
|
||||
return res.ok
|
||||
},
|
||||
[
|
||||
baseName,
|
||||
keyFor,
|
||||
markKeeping,
|
||||
keepingKeys,
|
||||
deletingKeys,
|
||||
markKeeping,
|
||||
releasePlayingFile,
|
||||
animateRemove,
|
||||
notify,
|
||||
withFileReleaseRetry,
|
||||
runFileMutation,
|
||||
queueRefill,
|
||||
emitCountHint,
|
||||
includeKeep,
|
||||
@ -1250,86 +1521,185 @@ export default function FinishedDownloads({
|
||||
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(
|
||||
async (job: RecordJob) => {
|
||||
async (job: RecordJob): Promise<void> => {
|
||||
const currentFile = baseName(job.output || '')
|
||||
if (!currentFile) {
|
||||
notify.error('HOT nicht möglich', 'Kein Dateiname gefunden – kann nicht HOT togglen.')
|
||||
return
|
||||
}
|
||||
const key = keyFor(job)
|
||||
|
||||
// genau "HOT " Prefix
|
||||
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 optimisticNew = toggledName(oldFile)
|
||||
|
||||
// Optimistik sofort anwenden (UI snappy)
|
||||
applyRename(oldFile, optimisticNew)
|
||||
await runFileMutation({
|
||||
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 {
|
||||
await releasePlayingFile(oldFile, { close: true })
|
||||
if (onToggleHot) {
|
||||
const r = await onToggleHot(job)
|
||||
return r as any
|
||||
}
|
||||
|
||||
// ✅ 1) Wenn du einen externen Handler hast:
|
||||
// -> ideal: er gibt {oldFile,newFile} zurück (optional)
|
||||
if (onToggleHot) {
|
||||
const r = (await onToggleHot(job)) as any
|
||||
const r = await withFileReleaseRetry(
|
||||
oldFile,
|
||||
async () =>
|
||||
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
|
||||
const apiOld = typeof r?.oldFile === 'string' ? r.oldFile : ''
|
||||
const apiNew = typeof r?.newFile === 'string' ? r.newFile : ''
|
||||
if (apiOld && apiNew) applyServerTruth(apiOld, apiNew)
|
||||
return (await r.json().catch(() => null)) as any
|
||||
},
|
||||
onSuccess: async (data: any) => {
|
||||
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)
|
||||
setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew })
|
||||
if (apiOld && apiNew && apiOld !== apiNew) {
|
||||
applyRename(apiOld, apiNew)
|
||||
}
|
||||
|
||||
if (sortMode === 'file_asc' || sortMode === 'file_desc') {
|
||||
setLastAction({ kind: 'hot', currentFile: apiNew })
|
||||
|
||||
if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
|
||||
queueRefill()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ 2) Fallback: Backend direkt (wichtig: oldFile verwenden!)
|
||||
const res = await fetch(
|
||||
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const data = (await res.json().catch(() => null)) as any
|
||||
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
|
||||
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
|
||||
|
||||
// Server-Truth über Optimistik drüberziehen (falls der Server anders entschieden hat)
|
||||
if (apiOld !== apiNew) applyServerTruth(apiOld, apiNew)
|
||||
|
||||
// ✅ 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]
|
||||
},
|
||||
onError: async () => {
|
||||
// Rename-Optimistik rollback
|
||||
clearRenamePair(oldFile, optimisticNew)
|
||||
setLastAction(null)
|
||||
},
|
||||
})
|
||||
},
|
||||
[
|
||||
baseName,
|
||||
keyFor,
|
||||
hotBusyKeys,
|
||||
markHotBusy,
|
||||
runFileMutation,
|
||||
applyRename,
|
||||
clearRenamePair,
|
||||
onToggleHot,
|
||||
withFileReleaseRetry,
|
||||
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(
|
||||
(job: RecordJob): RecordJob => {
|
||||
const out = norm(job.output || '')
|
||||
@ -1415,9 +1785,7 @@ export default function FinishedDownloads({
|
||||
}
|
||||
|
||||
if (detail.phase === 'success') {
|
||||
// delete final bestätigt
|
||||
markDeleting(key, false)
|
||||
queueRefill()
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -1428,13 +1796,18 @@ export default function FinishedDownloads({
|
||||
|
||||
useEffect(() => {
|
||||
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()
|
||||
}
|
||||
|
||||
window.addEventListener('finished-downloads:reload', onReload as any)
|
||||
return () => window.removeEventListener('finished-downloads:reload', onReload as any)
|
||||
}, [queueRefill /* oder fetchAllDoneJobs */])
|
||||
window.addEventListener('finished-downloads:reload', onReload as EventListener)
|
||||
return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener)
|
||||
}, [queueRefill])
|
||||
|
||||
useEffect(() => {
|
||||
const onExternalRename = (ev: Event) => {
|
||||
@ -1634,9 +2007,12 @@ export default function FinishedDownloads({
|
||||
// ✅ Hooks immer zuerst – unabhängig von rows
|
||||
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(() => {
|
||||
if (!isSmall) return
|
||||
if (view !== 'cards') return
|
||||
if (!isSmall || view !== 'cards') return
|
||||
|
||||
const top = pageRows[0]
|
||||
if (!top) {
|
||||
@ -1645,7 +2021,15 @@ export default function FinishedDownloads({
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
@ -1763,9 +2147,8 @@ export default function FinishedDownloads({
|
||||
{/* Views */}
|
||||
|
||||
<Button
|
||||
size={isSmall ? 'sm' : 'md'}
|
||||
size='md'
|
||||
variant="soft"
|
||||
className={isSmall ? 'h-9' : 'h-10'}
|
||||
disabled={!lastAction || undoing}
|
||||
onClick={undoLastAction}
|
||||
title={
|
||||
@ -2023,7 +2406,7 @@ export default function FinishedDownloads({
|
||||
) : (
|
||||
<>
|
||||
{view === 'cards' && (
|
||||
<div className={isSmall ? 'mt-8' : ''}>
|
||||
<div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
|
||||
<FinishedDownloadsCardsView
|
||||
rows={pageRows}
|
||||
isSmall={isSmall}
|
||||
@ -2067,6 +2450,9 @@ export default function FinishedDownloads({
|
||||
onToggleTagFilter={toggleTagFilter}
|
||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||
enqueueKeepVideo={enqueueKeepVideo}
|
||||
enqueueToggleHot={enqueueToggleHotVideo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -2112,6 +2498,9 @@ export default function FinishedDownloads({
|
||||
onToggleWatch={onToggleWatch}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||
enqueueKeepVideo={enqueueKeepVideo}
|
||||
enqueueToggleHot={enqueueToggleHotVideo}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -2150,6 +2539,9 @@ export default function FinishedDownloads({
|
||||
activeTagSet={activeTagSet}
|
||||
onToggleTagFilter={toggleTagFilter}
|
||||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||||
enqueueDeleteVideo={enqueueDeleteVideo}
|
||||
enqueueKeepVideo={enqueueKeepVideo}
|
||||
enqueueToggleHot={enqueueToggleHotVideo}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -84,6 +84,10 @@ type Props = {
|
||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleLike?: (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[] => {
|
||||
@ -157,6 +161,72 @@ function chooseSpriteGrid(count: number): [number, number] {
|
||||
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({
|
||||
rows,
|
||||
isSmall,
|
||||
@ -204,6 +274,10 @@ export default function FinishedDownloadsCardsView({
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
onToggleWatch,
|
||||
|
||||
enqueueDeleteVideo,
|
||||
enqueueKeepVideo,
|
||||
enqueueToggleHot,
|
||||
}: Props) {
|
||||
|
||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
||||
@ -324,6 +398,10 @@ export default function FinishedDownloadsCardsView({
|
||||
isDecorative?: boolean
|
||||
forceLoadStill?: boolean
|
||||
mobileStackTopOnlyVideo?: boolean
|
||||
disableScrubber?: boolean
|
||||
blur?: boolean
|
||||
animateUnblurOnMount?: boolean
|
||||
preloadTeaserWhenStill?: boolean
|
||||
}
|
||||
) => {
|
||||
const k = keyFor(j)
|
||||
@ -346,7 +424,7 @@ export default function FinishedDownloadsCardsView({
|
||||
opts?.forceStill
|
||||
? false
|
||||
: opts?.mobileStackTopOnlyVideo
|
||||
? (teaserPlayback === 'all' || (teaserPlayback === 'hover' ? teaserKey === k : false))
|
||||
? (teaserPlayback === 'still' ? false : true)
|
||||
: (teaserPlayback === 'all'
|
||||
? true
|
||||
: teaserPlayback === 'hover'
|
||||
@ -525,145 +603,169 @@ export default function FinishedDownloadsCardsView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Card shell keeps backgrounds consistent */}
|
||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||
{/* Preview */}
|
||||
<div
|
||||
id={inlineDomId}
|
||||
ref={
|
||||
opts?.disableInline || opts?.isDecorative
|
||||
? undefined
|
||||
: registerTeaserHost(k)
|
||||
}
|
||||
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||
onMouseEnter={
|
||||
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
|
||||
}
|
||||
onMouseLeave={() => {
|
||||
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
|
||||
clearScrubActiveIndex(k)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (isSmall || opts?.disableInline) return
|
||||
startInline(k)
|
||||
}}
|
||||
>
|
||||
{/* media */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
|
||||
{!inlineActive ? (
|
||||
<div
|
||||
className={
|
||||
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
|
||||
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
|
||||
(inlineActive ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
>
|
||||
<CardBlurWrapper
|
||||
blurred={opts?.blur}
|
||||
animateUnblurOnMount={opts?.animateUnblurOnMount}
|
||||
>
|
||||
{/* Card shell keeps backgrounds consistent */}
|
||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||
{/* Preview */}
|
||||
<div
|
||||
id={inlineDomId}
|
||||
ref={
|
||||
opts?.disableInline || opts?.isDecorative
|
||||
? undefined
|
||||
: registerTeaserHost(k)
|
||||
}
|
||||
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||
onMouseEnter={
|
||||
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
|
||||
}
|
||||
onMouseLeave={() => {
|
||||
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
|
||||
clearScrubActiveIndex(k)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (isSmall || opts?.disableInline) return
|
||||
startInline(k)
|
||||
}}
|
||||
>
|
||||
{/* media */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
|
||||
{!inlineActive ? (
|
||||
<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)]"
|
||||
title={[
|
||||
dur,
|
||||
resObj ? `${resObj.w}×${resObj.h}` : resLabel || '',
|
||||
size,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ')}
|
||||
className={
|
||||
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
|
||||
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
|
||||
(inlineActive ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
>
|
||||
<span>{dur}</span>
|
||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||
{resLabel ? <span>{resLabel}</span> : null}
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{size}</span>
|
||||
<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)]"
|
||||
title={[
|
||||
dur,
|
||||
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>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||
className="h-full w-full"
|
||||
variant="fill"
|
||||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
||||
onDuration={handleDuration}
|
||||
showPopover={false}
|
||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||
animated={allowTeaserAnimation}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
clipSeconds={1}
|
||||
thumbSamples={18}
|
||||
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
||||
inlineNonce={inlineNonce}
|
||||
inlineControls={inlineActive}
|
||||
inlineLoop={false}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
alwaysLoadStill={forceLoadStill}
|
||||
teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall}
|
||||
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'}
|
||||
scrubProgressRatio={scrubProgressRatio}
|
||||
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
|
||||
/>
|
||||
|
||||
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
|
||||
{hasSpriteScrubber && spriteUrl ? (
|
||||
<img
|
||||
src={spriteUrl}
|
||||
alt=""
|
||||
className="hidden"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
aria-hidden="true"
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||
className="h-full w-full"
|
||||
variant="fill"
|
||||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
||||
onDuration={handleDuration}
|
||||
showPopover={false}
|
||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||
animated={allowTeaserAnimation}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
clipSeconds={1}
|
||||
thumbSamples={18}
|
||||
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
||||
inlineNonce={inlineNonce}
|
||||
inlineControls={inlineActive}
|
||||
inlineLoop={false}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
alwaysLoadStill={forceLoadStill}
|
||||
teaserPreloadEnabled={
|
||||
opts?.mobileStackTopOnlyVideo
|
||||
? true
|
||||
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
|
||||
}
|
||||
teaserPreloadRootMargin={
|
||||
opts?.preloadTeaserWhenStill
|
||||
? '1200px 0px'
|
||||
: (isSmall ? '900px 0px' : '700px 0px')
|
||||
}
|
||||
scrubProgressRatio={scrubProgressRatio}
|
||||
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
|
||||
{hasSpriteScrubber && spriteUrl ? (
|
||||
<img
|
||||
src={spriteUrl}
|
||||
alt=""
|
||||
className="hidden"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
|
||||
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
|
||||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||||
<div className="h-full w-full" style={spriteFrameStyle} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
|
||||
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
|
||||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||||
<div className="h-full w-full" style={spriteFrameStyle} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
|
||||
{!opts?.isDecorative && !inlineActive && scrubberCount > 1 ? (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||||
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
|
||||
}
|
||||
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
|
||||
{!opts?.isDecorative && !opts?.disableScrubber && !inlineActive && scrubberCount > 1 ? (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||||
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
|
||||
}
|
||||
|
||||
// Zielsekunde aus Scrubber ableiten
|
||||
const seconds =
|
||||
scrubberStepSeconds > 0
|
||||
? index * scrubberStepSeconds
|
||||
: 0
|
||||
// Zielsekunde aus Scrubber ableiten
|
||||
const seconds =
|
||||
scrubberStepSeconds > 0
|
||||
? index * scrubberStepSeconds
|
||||
: 0
|
||||
|
||||
// 1) bevorzugt: direkt inline an Position starten (falls Parent das unterstützt)
|
||||
if (startInlineAt) {
|
||||
startInlineAt(k, seconds, inlineDomId)
|
||||
// 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
|
||||
// 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(() => {
|
||||
if (!tryAutoplayInline(inlineDomId)) {
|
||||
requestAnimationFrame(() => {
|
||||
@ -671,99 +773,88 @@ export default function FinishedDownloadsCardsView({
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2) Fallback: inline normal starten (ohne exakten Seek)
|
||||
startInline(k)
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryAutoplayInline(inlineDomId)) {
|
||||
requestAnimationFrame(() => {
|
||||
tryAutoplayInline(inlineDomId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 3) Optionaler Fallback auf bestehenden Handler (wenn du dort OpenPlayerAt machst)
|
||||
// handleScrubberClickIndex(j, index, scrubberCount)
|
||||
}}
|
||||
stepSeconds={scrubberStepSeconds}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
// 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) */}
|
||||
<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="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
|
||||
{/* Footer / Meta (wie Gallery strukturiert) */}
|
||||
<div className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<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">
|
||||
{isHot ? (
|
||||
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
|
||||
HOT
|
||||
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
||||
{isHot ? (
|
||||
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{stripHotPrefix(fileRaw) || '—'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{stripHotPrefix(fileRaw) || '—'}
|
||||
</span>
|
||||
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
|
||||
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
|
||||
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
|
||||
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
|
||||
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
|
||||
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
|
||||
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta + Actions (nicht im Video) */}
|
||||
<div
|
||||
className="mt-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Actions: volle Breite */}
|
||||
<div className="w-full">
|
||||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||
<RecordJobActions
|
||||
job={j}
|
||||
variant="table"
|
||||
busy={busy}
|
||||
collapseToMenu
|
||||
compact={false}
|
||||
isHot={isHot}
|
||||
isFavorite={isFav}
|
||||
isLiked={isLiked}
|
||||
isWatching={isWatching}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleHot={onToggleHot}
|
||||
onKeep={keepVideo}
|
||||
onDelete={deleteVideo}
|
||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
|
||||
className="w-full gap-1.5"
|
||||
/>
|
||||
{/* Meta + Actions (nicht im Video) */}
|
||||
<div
|
||||
className="mt-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Actions: volle Breite */}
|
||||
<div className="w-full">
|
||||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||
<RecordJobActions
|
||||
job={j}
|
||||
variant="table"
|
||||
busy={busy}
|
||||
collapseToMenu
|
||||
compact={false}
|
||||
isHot={isHot}
|
||||
isFavorite={isFav}
|
||||
isLiked={isLiked}
|
||||
isWatching={isWatching}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
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>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<TagOverflowRow
|
||||
rowKey={k}
|
||||
tags={tags}
|
||||
activeTagSet={activeTagSet}
|
||||
lower={lower}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
/>
|
||||
{/* Tags */}
|
||||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<TagOverflowRow
|
||||
rowKey={k}
|
||||
tags={tags}
|
||||
activeTagSet={activeTagSet}
|
||||
lower={lower}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
</CardBlurWrapper>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -782,17 +873,17 @@ export default function FinishedDownloadsCardsView({
|
||||
// Sichtbarer Stack bleibt bei 3 Karten
|
||||
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
|
||||
|
||||
// Für Preload (Still-Previews) verwenden wir ALLE Rows auf Mobile
|
||||
const mobileAllRows = isSmall ? rows : []
|
||||
// ✅ Mobile-Preload stark begrenzen (sonst zu viele hidden <FinishedVideoPreview/> Mounts)
|
||||
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
|
||||
const stackPeekOffsetPx = 15
|
||||
|
||||
// zusätzlicher Abstand ÜBER dem Stack (zum vorherigen Element)
|
||||
const stackTopGapPx = 24
|
||||
|
||||
// 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 (
|
||||
<div className="relative">
|
||||
@ -809,109 +900,135 @@ export default function FinishedDownloadsCardsView({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
|
||||
{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”) */}
|
||||
<div
|
||||
className="relative overflow-visible"
|
||||
style={{
|
||||
minHeight: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
|
||||
paddingTop: `${stackExtraTopPx + stackTopGapPx}px`,
|
||||
paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
|
||||
}}
|
||||
>
|
||||
{mobileVisibleStackRows
|
||||
.map((j, idx) => {
|
||||
const isTop = idx === 0
|
||||
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(
|
||||
j,
|
||||
isTop
|
||||
? {
|
||||
forceLoadStill: true,
|
||||
mobileStackTopOnlyVideo: true,
|
||||
}
|
||||
: {
|
||||
{(() => {
|
||||
const visible = mobileVisibleStackRows
|
||||
const topRow = visible[0]
|
||||
const backRows = visible.slice(1)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hintere Karten zuerst (absolut, dekorativ) */}
|
||||
{backRows
|
||||
.map((j, backIdx) => {
|
||||
const idx = backIdx + 1 // 1,2...
|
||||
const { k, cardInner } = renderCardItem(j, {
|
||||
forceStill: true,
|
||||
disableInline: true,
|
||||
disablePreviewHover: true,
|
||||
isDecorative: true,
|
||||
forceLoadStill: 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
|
||||
blur: true,
|
||||
preloadTeaserWhenStill: true,
|
||||
})
|
||||
|
||||
// untere Karten nur Deko (keine Interaktion)
|
||||
if (!isTop) {
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
className="absolute inset-x-0 top-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 20 - depth,
|
||||
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
|
||||
opacity,
|
||||
transformOrigin: 'top center',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="relative">
|
||||
{cardInner}
|
||||
{/* leichtes Frosting, damit klar ist: nur Vorschau */}
|
||||
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
|
||||
const depth = idx
|
||||
const y = -(depth * stackPeekOffsetPx)
|
||||
const scale = 1 - depth * 0.03
|
||||
const opacity = 1 - depth * 0.14
|
||||
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
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"
|
||||
style={{
|
||||
zIndex: 20 - depth,
|
||||
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
|
||||
opacity,
|
||||
transformOrigin: 'top center',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 */}
|
||||
)
|
||||
})() : null}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Hidden preloader: lädt für ALLE weiteren Mobile-Rows nur das Still-Preview */}
|
||||
{mobileAllRows.length > mobileStackDepth ? (
|
||||
{/* ✅ Hidden preloader (mobile): nur wenige nächste Cards, sonst UI träge */}
|
||||
{mobileStillPreloadRows.length > 0 ? (
|
||||
<div className="sr-only" aria-hidden="true">
|
||||
{mobileAllRows.slice(mobileStackDepth).map((j) => {
|
||||
{mobileStillPreloadRows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
|
||||
return (
|
||||
@ -922,9 +1039,9 @@ export default function FinishedDownloadsCardsView({
|
||||
className="h-full w-full"
|
||||
showPopover={false}
|
||||
blur={Boolean(blurPreviews)}
|
||||
|
||||
// ✅ Nur Previewbild laden – kein Teaser-Video
|
||||
animated={false}
|
||||
teaserPreloadEnabled={true}
|
||||
teaserPreloadRootMargin="1200px 0px"
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
inlineVideo={false}
|
||||
@ -932,27 +1049,14 @@ export default function FinishedDownloadsCardsView({
|
||||
inlineLoop={false}
|
||||
muted={true}
|
||||
popoverMuted={true}
|
||||
|
||||
assetNonce={assetNonce ?? 0}
|
||||
|
||||
// ✅ Still immer laden
|
||||
alwaysLoadStill
|
||||
|
||||
// ✅ Für Hidden-Preloader kein Teaser-Video vorladen
|
||||
teaserPreloadEnabled={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : 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>
|
||||
|
||||
@ -72,6 +72,11 @@ type Props = {
|
||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleWatch?: (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 {
|
||||
@ -177,6 +182,9 @@ export default function FinishedDownloadsGalleryView({
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
onToggleWatch,
|
||||
enqueueDeleteVideo,
|
||||
enqueueKeepVideo,
|
||||
enqueueToggleHot,
|
||||
}: Props) {
|
||||
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
|
||||
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
|
||||
@ -649,9 +657,27 @@ export default function FinishedDownloadsGalleryView({
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleHot={onToggleHot}
|
||||
onKeep={keepVideo}
|
||||
onDelete={deleteVideo}
|
||||
onToggleHot={async (job) => {
|
||||
if (enqueueToggleHot) {
|
||||
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']}
|
||||
className="w-full gap-1.5"
|
||||
/>
|
||||
|
||||
@ -73,6 +73,11 @@ type Props = {
|
||||
|
||||
deleteVideo: (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({
|
||||
@ -120,6 +125,9 @@ export default function FinishedDownloadsTableView({
|
||||
|
||||
deleteVideo,
|
||||
keepVideo,
|
||||
enqueueDeleteVideo,
|
||||
enqueueKeepVideo,
|
||||
enqueueToggleHot,
|
||||
}: Props) {
|
||||
const [sort, setSort] = React.useState<SortState>(null)
|
||||
|
||||
@ -427,9 +435,31 @@ export default function FinishedDownloadsTableView({
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleHot={onToggleHot}
|
||||
onKeep={keepVideo}
|
||||
onDelete={deleteVideo}
|
||||
onToggleHot={
|
||||
onToggleHot
|
||||
? 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']}
|
||||
className="flex items-center justify-end gap-1"
|
||||
/>
|
||||
|
||||
@ -636,10 +636,8 @@ export default function FinishedVideoPreview({
|
||||
}
|
||||
|
||||
;(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 j = byId ?? byFile
|
||||
if (!aborted && j) setFetchedMeta(j)
|
||||
if (!aborted && byFile) setFetchedMeta(byFile)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
|
||||
@ -25,6 +25,16 @@ type ParsedModel = {
|
||||
type ImportKind = 'liked' | 'favorite'
|
||||
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 = {
|
||||
id: 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() }
|
||||
}
|
||||
|
||||
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() {
|
||||
return (
|
||||
<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 [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
|
||||
key: 'model',
|
||||
direction: 'asc',
|
||||
key: 'createdAt',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
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',
|
||||
header: '',
|
||||
@ -823,7 +901,7 @@ export default function ModelsTab() {
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1)
|
||||
}, [q, tagFilter, viewMode])
|
||||
}, [q, tagFilter, viewMode, sort])
|
||||
|
||||
const totalItems = sortedAll.length
|
||||
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
||||
@ -910,60 +988,190 @@ export default function ModelsTab() {
|
||||
header={
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-2 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden">
|
||||
{/* Mobile */}
|
||||
<div className="sm:hidden shrink-0">
|
||||
<Button variant="secondary" size="md" onClick={openImport}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden sm:block shrink-0">
|
||||
<Button variant="secondary" size="md" onClick={openImport}>
|
||||
Importieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="w-full sm:w-auto min-w-0">
|
||||
{/* Mobile Layout: 2 Zeilen */}
|
||||
<div className="sm:hidden grid gap-2">
|
||||
{/* Zeile 1: View + Sort */}
|
||||
<div className="grid grid-cols-[auto,minmax(0,1fr)] gap-2 items-center min-w-0">
|
||||
<div className="shrink-0">
|
||||
<ButtonGroup
|
||||
ariaLabel="Ansicht umschalten"
|
||||
size="lg"
|
||||
className="flex w-full [&>button]:flex-1 [&>button]:min-w-0"
|
||||
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>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<Button variant="secondary" size="md" onClick={openImport}>
|
||||
Importieren
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
{viewMode === 'gallery' ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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
|
||||
"
|
||||
/>
|
||||
{/* Desktop Layout: wie bisher */}
|
||||
<div className="hidden sm:flex items-center gap-2 min-w-0 sm:justify-end">
|
||||
{viewMode === 'gallery' ? (
|
||||
<div className="shrink-0">
|
||||
<label className="sr-only" htmlFor="models-gallery-sort">
|
||||
Sortierung
|
||||
</label>
|
||||
<select
|
||||
id="models-gallery-sort"
|
||||
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>
|
||||
|
||||
@ -1042,7 +1250,7 @@ export default function ModelsTab() {
|
||||
return (
|
||||
<div
|
||||
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
|
||||
className="relative cursor-pointer bg-slate-950"
|
||||
@ -1092,19 +1300,46 @@ export default function ModelsTab() {
|
||||
{/* 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" />
|
||||
|
||||
{/* Modelname im Bild unten links (statt nur im Footer dominant) */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5">
|
||||
{/* 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 pr-18 sm:pr-2.5">
|
||||
<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}
|
||||
>
|
||||
{m.modelKey}
|
||||
</div>
|
||||
{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}
|
||||
</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 */}
|
||||
<div
|
||||
className={clsx(
|
||||
@ -1123,15 +1358,16 @@ export default function ModelsTab() {
|
||||
[&_button]:bg-transparent [&_button]:shadow-none
|
||||
[&_button:hover]:bg-white/10
|
||||
[&_button]:border-0
|
||||
[&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@ -1141,7 +1377,7 @@ export default function ModelsTab() {
|
||||
|
||||
{fav ? (
|
||||
<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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@ -1151,7 +1387,7 @@ export default function ModelsTab() {
|
||||
|
||||
{liked ? (
|
||||
<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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@ -1164,12 +1400,12 @@ export default function ModelsTab() {
|
||||
{(m.hot || m.keep) && (
|
||||
<div className="absolute left-2 top-2 flex flex-col gap-1">
|
||||
{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
|
||||
</span>
|
||||
) : null}
|
||||
{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
|
||||
</span>
|
||||
) : null}
|
||||
@ -1179,69 +1415,44 @@ export default function ModelsTab() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2">
|
||||
{/* Zeile 1: Stats links, Actions rechts */}
|
||||
<div className="flex items-center justify-between gap-2 text-[11px]">
|
||||
<div className="flex items-center gap-1.5 min-w-0 text-slate-300">
|
||||
<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 className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2 rounded-b-md flex-1">
|
||||
{/* Mobile: kompakter, aber nicht gequetscht */}
|
||||
<div className="sm:hidden">
|
||||
{/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
|
||||
<div
|
||||
className="flex items-center gap-1 shrink-0"
|
||||
className="grid grid-cols-3 gap-1.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span
|
||||
<Button
|
||||
variant={watch ? 'soft' : 'secondary'}
|
||||
size="xs"
|
||||
className={clsx(
|
||||
hideUntilHover && !watch
|
||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||
: 'opacity-100'
|
||||
'h-8 min-w-0 px-0 shadow-none',
|
||||
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 })
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={watch ? 'soft' : 'secondary'}
|
||||
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 className="inline-flex items-center justify-center gap-1">
|
||||
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
|
||||
👁
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={fav ? 'soft' : 'secondary'}
|
||||
size="xs"
|
||||
rounded="full"
|
||||
className={clsx(
|
||||
'h-7 w-7 p-0 min-w-0',
|
||||
hideUntilHover && !fav ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
|
||||
'h-8 min-w-0 px-0 shadow-none',
|
||||
fav
|
||||
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 shadow-none'
|
||||
: 'bg-white/5 text-amber-200/80 shadow-none hover:bg-white/10 hover:text-amber-200'
|
||||
? '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) => {
|
||||
@ -1250,26 +1461,21 @@ export default function ModelsTab() {
|
||||
else patch(m.id, { favorite: true, liked: false })
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-sm leading-none',
|
||||
fav ? 'text-amber-300' : 'text-slate-300 group-hover:text-amber-200'
|
||||
)}
|
||||
>
|
||||
★
|
||||
<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"
|
||||
rounded="full"
|
||||
className={clsx(
|
||||
'h-7 w-7 p-0 min-w-0',
|
||||
hideUntilHover && !liked ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
|
||||
'h-8 min-w-0 px-0 shadow-none',
|
||||
liked
|
||||
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 shadow-none'
|
||||
: 'bg-white/5 text-rose-200/80 shadow-none hover:bg-white/10 hover:text-rose-200'
|
||||
? '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) => {
|
||||
@ -1278,33 +1484,132 @@ export default function ModelsTab() {
|
||||
else patch(m.id, { liked: true, favorite: false })
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-sm leading-none',
|
||||
liked ? 'text-rose-300' : 'text-slate-300 group-hover:text-rose-200'
|
||||
)}
|
||||
>
|
||||
♥
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{/* Desktop: ohne Footer-Stats (wie mobile Buttons) */}
|
||||
<div className="hidden sm:block">
|
||||
{/* Zeile 1: Actions rechts, Style wie mobile */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="grid grid-cols-3 gap-1.5 w-full">
|
||||
<Button
|
||||
variant={watch ? 'soft' : 'secondary'}
|
||||
size="xs"
|
||||
className={clsx(
|
||||
'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',
|
||||
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>
|
||||
|
||||
@ -702,6 +702,7 @@ export default function Player({
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||
const miniDesktop = mini && isDesktop
|
||||
const usePortal = expanded || miniDesktop
|
||||
|
||||
const WIN_KEY = 'player_window_v1'
|
||||
|
||||
@ -745,14 +746,18 @@ export default function Player({
|
||||
React.useEffect(() => setMounted(true), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!usePortal) {
|
||||
setPortalTarget(null)
|
||||
return
|
||||
}
|
||||
|
||||
let el = document.getElementById('player-root') as HTMLElement | null
|
||||
if (!el) {
|
||||
el = document.createElement('div')
|
||||
el.id = 'player-root'
|
||||
}
|
||||
|
||||
// ✅ Mobile: immer in <body>, damit "fixed bottom-0" am echten Viewport hängt
|
||||
// ✅ Desktop: in den obersten offenen Dialog, damit er im Top-Layer vor dem Modal liegt
|
||||
// Desktop / Expanded: im Top-Layer (Dialog) oder body
|
||||
let host: HTMLElement | null = null
|
||||
|
||||
if (isDesktop) {
|
||||
@ -767,7 +772,7 @@ export default function Player({
|
||||
el.style.zIndex = '2147483647'
|
||||
|
||||
setPortalTarget(el)
|
||||
}, [isDesktop])
|
||||
}, [isDesktop, usePortal])
|
||||
|
||||
React.useEffect(() => {
|
||||
const p: any = playerRef.current
|
||||
@ -1612,7 +1617,8 @@ export default function Player({
|
||||
if (job.status !== 'running') setStopPending(false)
|
||||
}, [job.id, job.status])
|
||||
|
||||
if (!mounted || !portalTarget) return null
|
||||
if (!mounted) return null
|
||||
if (usePortal && !portalTarget) return null
|
||||
|
||||
const overlayBtn =
|
||||
'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 }
|
||||
: undefined
|
||||
|
||||
return createPortal(
|
||||
const content = (
|
||||
<>
|
||||
<style>{`
|
||||
/* Live-Download: Progress/Seek-Bar ausblenden */
|
||||
@ -2192,34 +2198,34 @@ export default function Player({
|
||||
`}</style>
|
||||
|
||||
{expanded || miniDesktop ? (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-[2147483647]',
|
||||
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
|
||||
)}
|
||||
style={{
|
||||
...(wrapStyle as any),
|
||||
willChange: isResizing ? 'left, top, width, height' : undefined,
|
||||
}}
|
||||
>
|
||||
{snapGhostEl}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-[2147483647]',
|
||||
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
|
||||
)}
|
||||
style={{
|
||||
...(wrapStyle as any),
|
||||
willChange: isResizing ? 'left, top, width, height' : undefined,
|
||||
}}
|
||||
>
|
||||
{snapGhostEl}
|
||||
|
||||
{cardEl}
|
||||
{cardEl}
|
||||
|
||||
{miniDesktop ? (
|
||||
<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 -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 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />
|
||||
{miniDesktop ? (
|
||||
<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 -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 -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 -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 -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<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 -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>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="
|
||||
@ -2234,7 +2240,12 @@ export default function Player({
|
||||
{cardEl}
|
||||
</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 Card from './Card'
|
||||
import LabeledSwitch from './LabeledSwitch'
|
||||
import GenerateAssetsTask from './GenerateAssetsTask'
|
||||
import Task from './Task'
|
||||
import TaskList from './TaskList'
|
||||
import type { TaskItem } from './TaskList'
|
||||
|
||||
@ -416,76 +416,81 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<GenerateAssetsTask
|
||||
onFinished={onAssetsGenerated}
|
||||
onStart={(ac) => {
|
||||
assetsAbortRef.current = ac
|
||||
setAssetsTask((t: TaskItem) => ({
|
||||
...t,
|
||||
status: 'running',
|
||||
title: 'Assets generieren',
|
||||
text: '',
|
||||
done: 0,
|
||||
total: 0,
|
||||
err: undefined,
|
||||
fading: false,
|
||||
}))
|
||||
}}
|
||||
onProgress={(p) => {
|
||||
const fn = shortTaskFilename(p.currentFile)
|
||||
<Task
|
||||
title="Assets-Generator"
|
||||
description="Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste."
|
||||
startLabel="Start"
|
||||
startingLabel="Starte…"
|
||||
startUrl="/api/tasks/generate-assets"
|
||||
stopUrl="/api/tasks/generate-assets"
|
||||
sseUrl="/api/tasks/assets/stream"
|
||||
onFinished={onAssetsGenerated}
|
||||
onStart={(ac) => {
|
||||
assetsAbortRef.current = ac
|
||||
setAssetsTask((t: TaskItem) => ({
|
||||
...t,
|
||||
status: 'running',
|
||||
title: 'Assets generieren',
|
||||
text: '',
|
||||
done: 0,
|
||||
total: 0,
|
||||
err: undefined,
|
||||
fading: false,
|
||||
}))
|
||||
}}
|
||||
onProgress={(p) => {
|
||||
const fn = shortTaskFilename(p.currentFile)
|
||||
|
||||
setAssetsTask((t: TaskItem) => ({
|
||||
...t,
|
||||
status: 'running',
|
||||
title: 'Assets generieren',
|
||||
text: fn || '',
|
||||
done: p.done,
|
||||
total: p.total,
|
||||
}))
|
||||
}}
|
||||
onDone={() => {
|
||||
assetsAbortRef.current = null
|
||||
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
|
||||
fadeOutTask(setAssetsTask)
|
||||
}}
|
||||
onCancelled={() => {
|
||||
assetsAbortRef.current = null
|
||||
setAssetsTask((t: TaskItem) => ({
|
||||
...t,
|
||||
status: 'cancelled',
|
||||
title: 'Assets generieren',
|
||||
text: 'Abgebrochen.',
|
||||
}))
|
||||
fadeOutTask(setAssetsTask)
|
||||
}}
|
||||
onError={(message) => {
|
||||
assetsAbortRef.current = null
|
||||
setAssetsTask((t: TaskItem) => ({
|
||||
...t,
|
||||
status: 'error',
|
||||
title: 'Assets generieren',
|
||||
text: 'Fehler beim Generieren.',
|
||||
err: message,
|
||||
}))
|
||||
fadeOutTask(setAssetsTask)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
setAssetsTask((t: TaskItem) => ({
|
||||
...t,
|
||||
status: 'running',
|
||||
title: 'Assets generieren',
|
||||
text: fn || '',
|
||||
done: p.done,
|
||||
total: p.total,
|
||||
}))
|
||||
}}
|
||||
onDone={() => {
|
||||
assetsAbortRef.current = null
|
||||
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
|
||||
fadeOutTask(setAssetsTask)
|
||||
}}
|
||||
onCancelled={() => {
|
||||
assetsAbortRef.current = null
|
||||
setAssetsTask((t: TaskItem) => ({
|
||||
...t,
|
||||
status: 'cancelled',
|
||||
title: 'Assets generieren',
|
||||
text: 'Abgebrochen.',
|
||||
}))
|
||||
fadeOutTask(setAssetsTask)
|
||||
}}
|
||||
onError={(message) => {
|
||||
assetsAbortRef.current = null
|
||||
setAssetsTask((t: TaskItem) => ({
|
||||
...t,
|
||||
status: 'error',
|
||||
title: 'Assets generieren',
|
||||
text: 'Fehler beim Generieren.',
|
||||
err: message,
|
||||
}))
|
||||
fadeOutTask(setAssetsTask)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={cleanupSmallDone}
|
||||
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
|
||||
className="h-9 px-3"
|
||||
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
|
||||
>
|
||||
{cleaning ? '…' : 'Aufräumen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Task
|
||||
title="Aufräumen"
|
||||
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
|
||||
startLabel="Aufräumen"
|
||||
startingLabel="Läuft…"
|
||||
onTrigger={cleanupSmallDone}
|
||||
busy={cleaning}
|
||||
disabled={saving || !value.autoDeleteSmallDownloads}
|
||||
onError={(message) => {
|
||||
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
|
||||
setErr(message)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -196,16 +196,26 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
const outDx = dir === 'right' ? w + 40 : -(w + 40)
|
||||
dxRef.current = 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) {
|
||||
try {
|
||||
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
actionPromise = Promise.resolve(
|
||||
dir === 'right' ? onSwipeRight() : onSwipeLeft()
|
||||
).catch(() => 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
|
||||
if (ok === false) {
|
||||
setAnimMs(snapMs)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// frontend\src\components\ui\GenerateAssetsTask.tsx
|
||||
// frontend\src\components\ui\Task.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
@ -22,6 +22,23 @@ type TaskState = {
|
||||
type Progress = { done: number; total: number; currentFile?: string }
|
||||
|
||||
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
|
||||
onStart?: (ac: AbortController) => void
|
||||
onProgress?: (p: Progress) => void
|
||||
@ -55,7 +72,17 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<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,
|
||||
onStart,
|
||||
onProgress,
|
||||
@ -87,10 +114,11 @@ export default function GenerateAssetsTask({
|
||||
}, [onError])
|
||||
|
||||
async function stopInternal() {
|
||||
if (!stopUrl) return
|
||||
if (stopInFlightRef.current) return
|
||||
stopInFlightRef.current = true
|
||||
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 {
|
||||
// ignore
|
||||
} finally {
|
||||
@ -147,7 +175,9 @@ export default function GenerateAssetsTask({
|
||||
|
||||
// SSE: State + Progress nur nach oben (TaskList), kein UI hier
|
||||
useEffect(() => {
|
||||
const unsub = subscribeSSE<TaskState>('/api/tasks/assets/stream', 'state', (st) => {
|
||||
if (!sseUrl) return
|
||||
|
||||
const unsub = subscribeSSE<TaskState>(sseUrl, 'state', (st) => {
|
||||
setState(st)
|
||||
|
||||
if (st?.running) {
|
||||
@ -162,7 +192,7 @@ export default function GenerateAssetsTask({
|
||||
|
||||
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) {
|
||||
lastErrorRef.current = errText
|
||||
onErrorRef.current?.(errText)
|
||||
@ -170,9 +200,10 @@ export default function GenerateAssetsTask({
|
||||
})
|
||||
|
||||
return () => unsub()
|
||||
}, [])
|
||||
}, [sseUrl])
|
||||
|
||||
async function start() {
|
||||
if (busy) return
|
||||
if (state?.running) return
|
||||
|
||||
setStartError(null)
|
||||
@ -180,11 +211,34 @@ export default function GenerateAssetsTask({
|
||||
cancelledRef.current = false
|
||||
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()
|
||||
|
||||
try {
|
||||
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' })
|
||||
const st = await fetchJSON<TaskState>(startUrl, { method: 'POST' })
|
||||
setState(st)
|
||||
|
||||
// TaskList jetzt aktivieren
|
||||
@ -215,10 +269,8 @@ export default function GenerateAssetsTask({
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Assets-Generator</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste.
|
||||
</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">{description}</div>
|
||||
|
||||
{startError ? (
|
||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||
@ -228,10 +280,10 @@ export default function GenerateAssetsTask({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<Button variant="primary" onClick={start} disabled={starting || running}>
|
||||
{starting ? 'Starte…' : 'Start'}
|
||||
<Button variant="primary" onClick={start} disabled={disabled || busy || starting || running}>
|
||||
{(starting || busy) ? startingLabel : startLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// frontend\src\components\ui\ToastProvider.tsx
|
||||
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
@ -22,6 +22,12 @@ export type Toast = {
|
||||
imageUrl?: string
|
||||
imageAlt?: string
|
||||
durationMs?: number // auto close
|
||||
onClick?: () => void
|
||||
closeOnClick?: boolean // default true (bei klickbaren Toasts)
|
||||
}
|
||||
|
||||
type ToastInternal = Toast & {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
type ToastContextValue = {
|
||||
@ -32,29 +38,64 @@ type ToastContextValue = {
|
||||
|
||||
const ToastContext = React.createContext<ToastContextValue | null>(null)
|
||||
|
||||
const TOAST_LEAVE_MS = 220
|
||||
|
||||
function iconFor(type: ToastType) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return { Icon: CheckCircleIcon, cls: 'text-emerald-500' }
|
||||
return { Icon: CheckCircleIcon, cls: 'text-emerald-600 dark:text-emerald-400' }
|
||||
case 'error':
|
||||
return { Icon: XCircleIcon, cls: 'text-rose-500' }
|
||||
return { Icon: XCircleIcon, cls: 'text-rose-600 dark:text-rose-400' }
|
||||
case 'warning':
|
||||
return { Icon: ExclamationTriangleIcon, cls: 'text-amber-500' }
|
||||
return { Icon: ExclamationTriangleIcon, cls: 'text-amber-600 dark:text-amber-400' }
|
||||
default:
|
||||
return { Icon: InformationCircleIcon, cls: 'text-sky-500' }
|
||||
return { Icon: InformationCircleIcon, cls: 'text-sky-600 dark:text-sky-400' }
|
||||
}
|
||||
}
|
||||
|
||||
function borderFor(type: ToastType) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'border-emerald-200/70 dark:border-emerald-400/20'
|
||||
return 'border-emerald-200/80 dark:border-emerald-400/20'
|
||||
case 'error':
|
||||
return 'border-rose-200/70 dark:border-rose-400/20'
|
||||
return 'border-rose-200/80 dark:border-rose-400/20'
|
||||
case 'warning':
|
||||
return 'border-amber-200/70 dark:border-amber-400/20'
|
||||
return 'border-amber-200/80 dark:border-amber-400/20'
|
||||
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,180 +127,420 @@ export function ToastProvider({
|
||||
defaultDurationMs?: number
|
||||
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
|
||||
}) {
|
||||
const [toasts, setToasts] = React.useState<Toast[]>([])
|
||||
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true)
|
||||
const [toasts, setToasts] = React.useState<ToastInternal[]>([])
|
||||
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true)
|
||||
|
||||
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
|
||||
const autoCloseTimersRef = React.useRef<Record<string, number>>({})
|
||||
const finalizeRemoveTimersRef = React.useRef<Record<string, number>>({})
|
||||
|
||||
const toastStartedAtRef = React.useRef<Record<string, number>>({})
|
||||
const toastRemainingMsRef = React.useRef<Record<string, number>>({})
|
||||
const toastPausedRef = React.useRef<Record<string, boolean>>({})
|
||||
|
||||
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(() => {
|
||||
// initial laden
|
||||
loadNotificationSetting()
|
||||
toastStartedAtRef.current[id] = Date.now()
|
||||
toastRemainingMsRef.current[id] = ms
|
||||
toastPausedRef.current[id] = false
|
||||
|
||||
// nach "Speichern" in Settings neu laden
|
||||
const onUpdated = () => loadNotificationSetting()
|
||||
window.addEventListener('recorder-settings-updated', onUpdated)
|
||||
return () => window.removeEventListener('recorder-settings-updated', onUpdated)
|
||||
}, [loadNotificationSetting])
|
||||
autoCloseTimersRef.current[id] = window.setTimeout(() => {
|
||||
remove(id)
|
||||
delete autoCloseTimersRef.current[id]
|
||||
delete toastStartedAtRef.current[id]
|
||||
delete toastRemainingMsRef.current[id]
|
||||
delete toastPausedRef.current[id]
|
||||
}, ms)
|
||||
},
|
||||
[remove]
|
||||
)
|
||||
|
||||
// optional: wenn deaktiviert, alle aktuellen Toasts ausblenden
|
||||
React.useEffect(() => {
|
||||
if (!notificationsEnabled) {
|
||||
// ✅ Nur nicht-Fehler ausblenden, Fehler dürfen bleiben
|
||||
setToasts((prev) => prev.filter((t) => t.type === 'error'))
|
||||
const push = React.useCallback(
|
||||
(t: Omit<Toast, 'id'>) => {
|
||||
if (!notificationsEnabled && t.type !== 'error') return ''
|
||||
|
||||
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) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
return 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(
|
||||
(t: Omit<Toast, 'id'>) => {
|
||||
// ✅ Errors IMMER zeigen, alles andere abhängig vom Toggle
|
||||
if (!notificationsEnabled && t.type !== 'error') return ''
|
||||
const timerId = autoCloseTimersRef.current[id]
|
||||
if (!timerId) return
|
||||
|
||||
const id = uid()
|
||||
const durationMs = t.durationMs ?? defaultDurationMs
|
||||
window.clearTimeout(timerId)
|
||||
delete autoCloseTimersRef.current[id]
|
||||
|
||||
setToasts((prev) => {
|
||||
const next = [{ ...t, id, durationMs }, ...prev]
|
||||
return next.slice(0, Math.max(1, maxToasts))
|
||||
})
|
||||
const startedAt = toastStartedAtRef.current[id] ?? Date.now()
|
||||
const remaining = toastRemainingMsRef.current[id] ?? 0
|
||||
const elapsed = Date.now() - startedAt
|
||||
const nextRemaining = Math.max(0, remaining - elapsed)
|
||||
|
||||
if (durationMs && durationMs > 0) {
|
||||
window.setTimeout(() => remove(id), durationMs)
|
||||
toastRemainingMsRef.current[id] = nextRemaining
|
||||
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
|
||||
},
|
||||
[defaultDurationMs, maxToasts, remove, notificationsEnabled]
|
||||
)
|
||||
.toast-progress-bar {
|
||||
animation-name: toast-progress;
|
||||
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 =
|
||||
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}
|
||||
|
||||
{/* Live region */}
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className={[
|
||||
'pointer-events-none fixed z-[80] inset-x-0',
|
||||
insetCls,
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex w-full px-4 py-6 sm:p-6', posCls].join(' ')}>
|
||||
<div className={['flex w-full flex-col space-y-3', alignCls].join(' ')}>
|
||||
{toasts.map((t) => {
|
||||
const { Icon, cls } = iconFor(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()
|
||||
|
||||
return (
|
||||
<Transition key={t.id} appear show={true}>
|
||||
<div
|
||||
className={[
|
||||
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-xl',
|
||||
'border bg-white/90 shadow-lg backdrop-blur',
|
||||
'outline-1 outline-black/5',
|
||||
'dark:bg-gray-950/70 dark:-outline-offset-1 dark:outline-white/10',
|
||||
borderFor(t.type),
|
||||
// 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>
|
||||
) : (
|
||||
return (
|
||||
<Transition
|
||||
key={t.id}
|
||||
appear
|
||||
show={t.open}
|
||||
enter="transform transition ease-out duration-250"
|
||||
enterFrom="opacity-0 -translate-y-3 sm:-translate-y-2"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transform transition ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-2 sm:-translate-y-3"
|
||||
>
|
||||
<div
|
||||
role={isClickable ? 'button' : 'status'}
|
||||
className={[
|
||||
'pointer-events-auto relative w-[22rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl',
|
||||
'border bg-white dark:bg-slate-900',
|
||||
'shadow-sm',
|
||||
'ring-1 ring-black/5 dark:ring-white/5',
|
||||
isClickable
|
||||
? '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]'
|
||||
: '',
|
||||
borderFor(t.type),
|
||||
].join(' ')}
|
||||
onMouseEnter={() => pauseAutoCloseTimer(t.id)}
|
||||
onMouseLeave={() => resumeAutoCloseTimer(t.id)}
|
||||
onFocus={() => pauseAutoCloseTimer(t.id)}
|
||||
onBlur={() => resumeAutoCloseTimer(t.id)}
|
||||
onClick={() => {
|
||||
if (!t.onClick) return
|
||||
t.onClick()
|
||||
if (t.closeOnClick !== false) remove(t.id)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!t.onClick) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
t.onClick()
|
||||
if (t.closeOnClick !== false) remove(t.id)
|
||||
}
|
||||
}}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
aria-label={isClickable ? `${title} öffnen` : undefined}
|
||||
>
|
||||
{img ? (
|
||||
// ===== Layout MIT Bild: Bild links edge-to-edge (oben/unten ohne Padding) =====
|
||||
<div className="relative pr-12 sm:pr-14">
|
||||
<div className="flex items-stretch">
|
||||
{/* Bild links: edge-to-edge */}
|
||||
<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 className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{/* Textbereich mit Padding */}
|
||||
<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}
|
||||
</p>
|
||||
|
||||
{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}
|
||||
</p>
|
||||
) : null}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
// ===== 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 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>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = React.useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user