This commit is contained in:
Linrador 2026-02-25 15:00:33 +01:00
parent 160544a65d
commit ae67c817ac
26 changed files with 3070 additions and 1629 deletions

View File

@ -16,16 +16,25 @@ var autostartPaused int32 // 0=false, 1=true
// --- SSE subscribers für Autostart-State --- // --- SSE subscribers für Autostart-State ---
var autostartSubsMu sync.Mutex var autostartSubsMu sync.Mutex
var autostartSubs = map[chan bool]struct{}{}
func broadcastAutostartPaused(paused bool) { type autostartStatePayload struct {
Paused bool `json:"paused"`
PausedByUser bool `json:"pausedByUser"`
PausedByDisk bool `json:"pausedByDisk"`
}
var autostartSubs = map[chan autostartStatePayload]struct{}{}
func broadcastAutostartPaused() {
state := getAutostartStatePayload()
autostartSubsMu.Lock() autostartSubsMu.Lock()
defer autostartSubsMu.Unlock() defer autostartSubsMu.Unlock()
for ch := range autostartSubs { for ch := range autostartSubs {
// non-blocking: wenn Client langsam ist, droppen wir Updates (neuester Zustand kommt eh wieder) // non-blocking: wenn Client langsam ist, droppen wir Updates
select { select {
case ch <- paused: case ch <- state:
default: default:
} }
} }
@ -40,8 +49,19 @@ func isAutostartPaused() bool {
return atomic.LoadInt32(&diskEmergency) == 1 return atomic.LoadInt32(&diskEmergency) == 1
} }
func getAutostartStatePayload() autostartStatePayload {
userPaused := atomic.LoadInt32(&autostartPaused) == 1
diskPaused := atomic.LoadInt32(&diskEmergency) == 1
return autostartStatePayload{
Paused: userPaused || diskPaused,
PausedByUser: userPaused,
PausedByDisk: diskPaused,
}
}
func setAutostartPaused(v bool) { func setAutostartPaused(v bool) {
old := isAutostartPaused() old := getAutostartStatePayload()
if v { if v {
atomic.StoreInt32(&autostartPaused, 1) atomic.StoreInt32(&autostartPaused, 1)
@ -49,9 +69,11 @@ func setAutostartPaused(v bool) {
atomic.StoreInt32(&autostartPaused, 0) atomic.StoreInt32(&autostartPaused, 0)
} }
// nur wenn sich der Zustand wirklich geändert hat -> pushen newState := getAutostartStatePayload()
if old != v {
broadcastAutostartPaused(v) // nur wenn sich der relevante sichtbare Zustand geändert hat -> pushen
if old != newState {
broadcastAutostartPaused()
} }
} }
@ -62,11 +84,7 @@ type autostartPauseReq struct {
func autostartPauseHandler(w http.ResponseWriter, r *http.Request) { func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
w.Header().Set("Content-Type", "application/json") writeAutostartState(w)
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
return return
case http.MethodPost: case http.MethodPost:
@ -96,11 +114,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
setAutostartPaused(*val) setAutostartPaused(*val)
w.Header().Set("Content-Type", "application/json") writeAutostartState(w)
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
return return
default: default:
@ -112,9 +126,7 @@ func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
func writeAutostartState(w http.ResponseWriter) { func writeAutostartState(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(getAutostartStatePayload())
"paused": isAutostartPaused(),
})
} }
// GET /api/autostart/state // GET /api/autostart/state
@ -149,6 +161,13 @@ func autostartResumeHandler(w http.ResponseWriter, r *http.Request) {
writeAutostartState(w) writeAutostartState(w)
return return
case http.MethodPost: case http.MethodPost:
// ✅ Resume blocken, solange Disk-Notbremse aktiv ist
if atomic.LoadInt32(&diskEmergency) == 1 {
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "Autostart durch Speicherplatz-Notbremse gesperrt", http.StatusConflict)
return
}
setAutostartPaused(false) setAutostartPaused(false)
writeAutostartState(w) writeAutostartState(w)
return return
@ -176,7 +195,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // wichtig falls Proxy/Nginx w.Header().Set("X-Accel-Buffering", "no") // wichtig falls Proxy/Nginx
ch := make(chan bool, 1) ch := make(chan autostartStatePayload, 1)
// subscribe // subscribe
autostartSubsMu.Lock() autostartSubsMu.Lock()
@ -191,10 +210,12 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
close(ch) close(ch)
}() }()
send := func(paused bool) { send := func(state autostartStatePayload) {
payload := map[string]any{ payload := map[string]any{
"paused": paused, "paused": state.Paused,
"ts": time.Now().UTC().Format(time.RFC3339Nano), "pausedByUser": state.PausedByUser,
"pausedByDisk": state.PausedByDisk,
"ts": time.Now().UTC().Format(time.RFC3339Nano),
} }
b, _ := json.Marshal(payload) b, _ := json.Marshal(payload)
@ -206,7 +227,7 @@ func autostartStateStreamHandler(w http.ResponseWriter, r *http.Request) {
} }
// initial state sofort senden // initial state sofort senden
send(isAutostartPaused()) send(getAutostartStatePayload())
ctx := r.Context() ctx := r.Context()
hb := time.NewTicker(15 * time.Second) hb := time.NewTicker(15 * time.Second)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -173,6 +173,23 @@ func inFlightBytesForJob(j *RecordJob) uint64 {
return sizeOfPathBestEffort(j.Output) return sizeOfPathBestEffort(j.Output)
} }
func minRelevantInFlightBytes() uint64 {
s := getSettings()
// Nur wenn Auto-Delete kleine Downloads aktiv ist und eine sinnvolle Schwelle gesetzt ist
if !s.AutoDeleteSmallDownloads {
return 0
}
mb := s.AutoDeleteSmallDownloadsBelowMB
if mb <= 0 {
return 0
}
// MB -> Bytes (MiB passend zum restlichen Code mit GiB)
return uint64(mb) * 1024 * 1024
}
const giB = uint64(1024 * 1024 * 1024) const giB = uint64(1024 * 1024 * 1024)
// computeDiskThresholds: // computeDiskThresholds:
@ -207,6 +224,7 @@ func computeDiskThresholds() (pauseGB int, resumeGB int, inFlight uint64, pauseN
// Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve. // Idee: Für TS->MP4 Peak brauchst du grob nochmal die Größe der aktuellen Datei als Reserve.
func sumInFlightBytes() uint64 { func sumInFlightBytes() uint64 {
var sum uint64 var sum uint64
minKeepBytes := minRelevantInFlightBytes()
jobsMu.Lock() jobsMu.Lock()
defer jobsMu.Unlock() defer jobsMu.Unlock()
@ -219,10 +237,19 @@ func sumInFlightBytes() uint64 {
continue continue
} }
// Nimm die Datei, die gerade wächst. b := inFlightBytesForJob(j)
// In deinem System ist das typischerweise j.Output (TS oder temporäres Ziel).
// Falls du ein separates Feld für "TempTS" o.ä. hast: hier ergänzen. // ✅ Nur "relevante" Dateien berücksichtigen:
sum += inFlightBytesForJob(j) // Wenn Auto-Delete kleine Downloads aktiv ist, zählen wir nur Jobs,
// deren aktuelle Dateigröße bereits über der Schwelle liegt.
//
// Hinweis: Ein Job kann später noch über die Schwelle wachsen.
// Diese Logik ist bewusst "weniger konservativ", so wie gewünscht.
if minKeepBytes > 0 && b > 0 && b < minKeepBytes {
continue
}
sum += b
} }
return sum return sum
@ -230,8 +257,11 @@ func sumInFlightBytes() uint64 {
// startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser. // startDiskSpaceGuard läuft im Backend und reagiert auch ohne offenen Browser.
// Bei wenig freiem Platz: // Bei wenig freiem Platz:
// - Autostart pausieren // - diskEmergency aktivieren (Autostart blockieren)
// - laufende Jobs stoppen (nur Status=running und Phase leer) // - laufende Jobs stoppen
//
// Bei Erholung (Resume-Schwelle):
// - diskEmergency automatisch wieder freigeben
func startDiskSpaceGuard() { func startDiskSpaceGuard() {
t := time.NewTicker(diskGuardInterval) t := time.NewTicker(diskGuardInterval)
defer t.Stop() defer t.Stop()
@ -259,41 +289,73 @@ func startDiskSpaceGuard() {
// Pause = ceil((2 * inFlight) / GiB) // Pause = ceil((2 * inFlight) / GiB)
// Resume = Pause + 3 GB // Resume = Pause + 3 GB
// pauseNeed/resumeNeed sind die benötigten freien Bytes // pauseNeed/resumeNeed sind die benötigten freien Bytes
pauseGB, resumeGB, inFlight, pauseNeed, _ := computeDiskThresholds() pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
// Wenn nichts läuft, gibt es nichts zu reservieren. // ✅ diskEmergency NICHT sticky behalten.
// (Optional: Emergency zurücksetzen, damit Autostart wieder frei wird.) // Stattdessen dynamisch mit Hysterese setzen/löschen:
//
// - triggern bei free < pauseNeed
// - freigeben erst bei free >= resumeNeed
//
// So kann die Notbremse später erneut greifen.
wasEmergency := atomic.LoadInt32(&diskEmergency) == 1
// Wenn aktuell nichts läuft, brauchen wir keine Reservierung.
// Dann diskEmergency freigeben (falls gesetzt), damit Autostart wieder möglich ist.
// (User-Pause bleibt davon unberührt.)
if inFlight == 0 { if inFlight == 0 {
// Kein Auto-Recovery: if wasEmergency {
// Emergency bleibt aktiv, bis manuell zurückgesetzt wird. atomic.StoreInt32(&diskEmergency, 0)
broadcastAutostartPaused()
fmt.Printf("✅ [disk] Emergency cleared (no in-flight jobs). free=%s (%dB) path=%s\n",
formatBytesSI(u64ToI64(free)), free, dir,
)
}
continue continue
} }
// Wenn Emergency aktiv ist, niemals automatisch freigeben. isLowForPause := free < pauseNeed
// (Manueller Reset erforderlich) isHighEnoughForResume := free >= resumeNeed
if atomic.LoadInt32(&diskEmergency) == 1 {
if !wasEmergency {
// Normalzustand: nur triggern, wenn unter Pause-Schwelle
if !isLowForPause {
continue
}
atomic.StoreInt32(&diskEmergency, 1)
broadcastAutostartPaused()
fmt.Printf(
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n",
formatBytesSI(u64ToI64(free)), free,
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed,
pauseGB, resumeGB,
formatBytesSI(u64ToI64(inFlight)), inFlight,
dir,
)
stopped := stopAllStoppableJobs()
if stopped > 0 {
fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped)
}
continue continue
} }
// ✅ Normalzustand: solange free >= pauseNeed, nichts tun // ✅ Emergency ist aktiv: nur freigeben, wenn Resume-Schwelle erreicht ist
if free >= pauseNeed { if isHighEnoughForResume {
continue atomic.StoreInt32(&diskEmergency, 0)
} broadcastAutostartPaused()
// ✅ Trigger: Notbremse aktivieren, Jobs stoppen fmt.Printf(
atomic.StoreInt32(&diskEmergency, 1) "✅ [disk] Space recovered: free=%s (%dB) (>= %s, %dB, resume=%dGB, inFlight=%s, %dB) -> unblock autostart (path=%s)\n",
fmt.Printf( formatBytesSI(u64ToI64(free)), free,
"🛑 [disk] Low space: free=%s (%dB) (< %s, %dB, pause=%dGB resume=%dGB, inFlight=%s, %dB) -> stop jobs + block autostart via diskEmergency (path=%s)\n", formatBytesSI(u64ToI64(resumeNeed)), resumeNeed,
formatBytesSI(u64ToI64(free)), free, resumeGB,
formatBytesSI(u64ToI64(pauseNeed)), pauseNeed, formatBytesSI(u64ToI64(inFlight)), inFlight,
pauseGB, resumeGB, dir,
formatBytesSI(u64ToI64(inFlight)), inFlight, )
dir,
)
stopped := stopAllStoppableJobs()
if stopped > 0 {
fmt.Printf("🛑 [disk] Stop requested for %d job(s)\n", stopped)
} }
} }
} }

Binary file not shown.

View File

@ -1802,6 +1802,38 @@ func max(a, b int) int {
return b return b
} }
func renameWithRetryAggressive(src, dst string) error {
// Mehrere kurze Versuche + leichtes Backoff
var lastErr error
delays := []time.Duration{
80 * time.Millisecond,
140 * time.Millisecond,
220 * time.Millisecond,
320 * time.Millisecond,
450 * time.Millisecond,
650 * time.Millisecond,
}
for i, d := range delays {
if err := os.Rename(src, dst); err == nil {
return nil
} else {
lastErr = err
// nur bei Windows SharingViolation lohnt Retry wirklich
if runtime.GOOS != "windows" || !isSharingViolation(err) {
return err
}
}
// Vor letztem Sleep nicht mehr warten
if i < len(delays)-1 {
time.Sleep(d)
}
}
return lastErr
}
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE // Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
if r.Method != http.MethodPost && r.Method != http.MethodDelete { if r.Method != http.MethodPost && r.Method != http.MethodDelete {
@ -1938,7 +1970,7 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
// (wir encoden den Token nicht neu — wir speichern Trashname separat in last.json) // (wir encoden den Token nicht neu — wir speichern Trashname separat in last.json)
// move mit retry (Windows file-lock robust) // move mit retry (Windows file-lock robust)
if err := renameWithRetry(target, dst); err != nil { if err := renameWithRetryAggressive(target, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict) http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
return return
@ -2097,7 +2129,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := renameWithRetry(src, dst); err != nil { if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "restore fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict) http.Error(w, "restore fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return return
@ -2199,7 +2231,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := renameWithRetry(src, dst); err != nil { if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict) http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return return
@ -2337,7 +2369,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
} }
// rename mit retry (Windows file-lock) // rename mit retry (Windows file-lock)
if err := renameWithRetry(src, dst); err != nil { if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict) http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
return return
@ -2436,7 +2468,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := renameWithRetry(src, dst); err != nil { if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict) http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
return return

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-DNoPI-qJ.js"></script> <script type="module" crossorigin src="/assets/index-BZ38s29o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B-X4TsOo.css"> <link rel="stylesheet" crossorigin href="/assets/index-CZMtb58J.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -410,11 +410,6 @@ export default function App() {
const donePrefetchRef = useRef<DonePrefetch | null>(null) const donePrefetchRef = useRef<DonePrefetch | null>(null)
const donePrefetchInFlightRef = useRef(false) const donePrefetchInFlightRef = useRef(false)
// ✅ verhindert "pending forever": immer nur 1 done-fetch gleichzeitig
const doneFetchAbortRef = useRef<AbortController | null>(null)
const doneFetchInFlightRef = useRef(false)
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}` const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
const prefetchDonePage = useCallback(async (pageToFetch: number) => { const prefetchDonePage = useCallback(async (pageToFetch: number) => {
@ -450,8 +445,16 @@ export default function App() {
}, [doneSort]) }, [doneSort])
const loadDoneCount = useCallback(async () => { const loadDoneCount = useCallback(async () => {
const now = Date.now()
// ✅ harte Dedupe-Schranke gegen Bursts
if (doneCountInFlightRef.current) return
if (now - doneCountLastAtRef.current < 500) return
doneCountInFlightRef.current = true
doneCountLastAtRef.current = now
try { try {
// ✅ leichtgewichtiger Endpoint (siehe Backend unten)
const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any }) const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
if (!res.ok) return if (!res.ok) return
@ -463,9 +466,14 @@ export default function App() {
setLastHeaderUpdateAtMs(Date.now()) setLastHeaderUpdateAtMs(Date.now())
} catch { } catch {
// ignore // ignore
} finally {
doneCountInFlightRef.current = false
} }
}, []) }, [])
const doneCountInFlightRef = useRef(false)
const doneCountLastAtRef = useRef(0)
// ✅ sagt FinishedDownloads: "bitte ALL neu laden" // ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const finishedReloadTimerRef = useRef<number | null>(null) const finishedReloadTimerRef = useRef<number | null>(null)
@ -1236,80 +1244,6 @@ export default function App() {
} }
}, [loadDoneCount]) }, [loadDoneCount])
const refreshDoneNow = useCallback(
async (preferPage?: number) => {
// ✅ wenn noch ein done-fetch läuft: abbrechen (sonst stauen sich Requests)
if (doneFetchInFlightRef.current) {
doneFetchAbortRef.current?.abort()
}
const ac = new AbortController()
doneFetchAbortRef.current = ac
doneFetchInFlightRef.current = true
try {
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const res = await fetch(
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
setDoneCount(count)
const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
const target = Math.min(Math.max(1, wanted), maxPage)
if (target !== donePage) setDonePage(target)
// Wenn wir auf eine andere Page clampen mussten: die richtige Page nachladen
if (target !== wanted) {
const res2 = await fetch(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res2.ok) throw new Error(`HTTP ${res2.status}`)
const data2 = await res2.json().catch(() => null)
const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : []
setDoneJobs(items2)
} else {
setDoneJobs(items)
}
setLastHeaderUpdateAtMs(Date.now())
} catch (e: any) {
// Abort ist ok
if (String(e?.name) !== 'AbortError') {
// optional: console.debug('[DONE] refresh failed', e)
}
} finally {
// ✅ Nur der "aktuelle" Request darf den InFlight-Status zurücksetzen.
// Sonst kann ein älterer (abgebrochener) Request einen neueren überschreiben.
const isCurrent = doneFetchAbortRef.current === ac
if (isCurrent) {
doneFetchAbortRef.current = null
doneFetchInFlightRef.current = false
}
}
},
[donePage, doneSort]
)
useEffect(() => { useEffect(() => {
if (selectedTab !== 'finished') return if (selectedTab !== 'finished') return
@ -1498,18 +1432,17 @@ export default function App() {
const e = ev as CustomEvent<{ delta?: number }> const e = ev as CustomEvent<{ delta?: number }>
const delta = Number(e.detail?.delta ?? 0) const delta = Number(e.detail?.delta ?? 0)
if (!Number.isFinite(delta) || delta === 0) { if (Number.isFinite(delta) && delta !== 0) {
void loadDoneCount() setDoneCount((c) => Math.max(0, c + delta))
requestFinishedReload()
return
} }
// ✅ Tabs sofort updaten (optimistisch) // Count darf immer aktualisiert werden (Badge/Header)
setDoneCount((c) => Math.max(0, c + delta))
// ✅ danach server-truth holen + ALL reload
void loadDoneCount() void loadDoneCount()
requestFinishedReload()
// ✅ Nur Finished-Tab wirklich neu laden
if (selectedTabRef.current === 'finished') {
requestFinishedReload()
}
} }
window.addEventListener('finished-downloads:count-hint', onHint as EventListener) window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
@ -1570,124 +1503,160 @@ export default function App() {
[startUrl, notify] [startUrl, notify]
) )
type FinishedFileActionKind = 'delete' | 'keep' | 'rename'
async function runFinishedFileAction<T>(opts: {
kind: FinishedFileActionKind
file: string
run: () => Promise<T>
onSuccess?: (result: T) => void | Promise<void>
onError?: (err: unknown) => void | Promise<void>
}) {
const { kind, file, run, onSuccess, onError } = opts
// Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row)
if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
)
}
try {
const result = await run()
if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
)
}
await onSuccess?.(result)
return result
} catch (e) {
if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
)
}
await onError?.(e)
throw e
}
}
const handleDeleteJobWithUndo = useCallback( const handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string }> => { async (job: RecordJob): Promise<void | { undoToken?: string }> => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
if (!file) return if (!file) return
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
)
try { try {
// ✅ Wichtig: JSON-Response lesen, weil undoToken dort drin steckt const data = await runFinishedFileAction<{ undoToken?: string }>({
const data = await apiJSON<{ undoToken?: string }>( kind: 'delete',
`/api/record/delete?file=${encodeURIComponent(file)}`, file,
{ method: 'POST' } run: () =>
) apiJSON<{ undoToken?: string }>(
`/api/record/delete?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
),
onSuccess: async () => {
window.setTimeout(() => {
// ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch)
setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
window.dispatchEvent( const need = DONE_PAGE_SIZE - filtered.length
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } }) if (need <= 0) return filtered
)
window.setTimeout(() => { const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
setDoneJobs((prev) => { const buf = donePrefetchRef.current
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
// ✅ sofort auffüllen, wenn wir Platz haben if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
const need = DONE_PAGE_SIZE - filtered.length return filtered
if (need <= 0) return filtered }
const prefetchKey = makePrefetchKey(donePage + 1, doneSort) const next: RecordJob[] = [...filtered]
const buf = donePrefetchRef.current const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) { while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
return filtered const cand = buf.items.shift()!
} const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
const next: RecordJob[] = [...filtered] donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim())) return next
})
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) { // ✅ Count sofort optimistisch runter
const cand = buf.items.shift()! setDoneCount((c) => Math.max(0, c - 1))
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
// buffer zurückschreiben (mit verkürzter items-Liste) // ✅ Running-/Player-State bereinigen (falls offen)
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts } setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
return next // ✅ Prefetch wieder nachfüllen
}) void prefetchDonePage(donePage + 1)
}, 320)
// ✅ Count sofort optimistisch runter },
setDoneCount((c) => Math.max(0, c - 1)) onError: async () => {
notify.error('Löschen fehlgeschlagen', file)
// ✅ Player / jobs cleanup wie bei dir },
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) })
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Buffer direkt wieder nachfüllen (background)
void prefetchDonePage(donePage + 1)
}, 320)
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : '' const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
return undoToken ? { undoToken } : {} // ✅ kein null mehr return undoToken ? { undoToken } : {}
} catch (e: any) { } catch {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
)
notify.error('Löschen fehlgeschlagen: ', file)
return // ✅ void statt null
}
},
[notify, refreshDoneNow]
)
const handleDeleteJob = useCallback(
async (job: RecordJob): Promise<void> => {
await handleDeleteJobWithUndo(job)
},
[handleDeleteJobWithUndo]
)
const handleKeepJob = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } }))
try {
await apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } }))
window.setTimeout(() => {
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320)
} catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
notify.error('Keep fehlgeschlagen', file)
return return
} }
}, },
[notify] [runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage]
)
const handleKeepJob = useCallback(
async (job: RecordJob): Promise<void> => {
const file = baseName(job.output || '')
if (!file) return
try {
await runFinishedFileAction({
kind: 'keep',
file,
run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }),
onSuccess: async () => {
window.setTimeout(() => {
// ✅ Entfernt aus Finished + Running + Player
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Count ebenfalls runter (Keep entfernt den Eintrag aus "finished")
setDoneCount((c) => Math.max(0, c - 1))
// Optional: nächste Seite vorladen (schadet nicht)
void prefetchDonePage(donePage + 1)
}, 320)
},
onError: async () => {
notify.error('Keep fehlgeschlagen', file)
},
})
} catch {
return
}
},
[runFinishedFileAction, notify, donePage, prefetchDonePage]
) )
const handleToggleHot = useCallback( const handleToggleHot = useCallback(
async (job: RecordJob) => { async (job: RecordJob): Promise<void | { ok: boolean; oldFile: string; newFile: string }> => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
if (!file) return if (!file) return
try { try {
// ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird // ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } })) window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
// kurze Pause hilft in der Praxis, wenn Video.js/Browser noch “dran” hängt
await new Promise((r) => window.setTimeout(r, 60)) await new Promise((r) => window.setTimeout(r, 60))
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>( const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
@ -1695,30 +1664,38 @@ export default function App() {
{ method: 'POST' } { method: 'POST' }
) )
// ✅ FinishedDownloads lokal syncen (wenn Rename außerhalb der Liste passiert, z.B. im Player) const oldFile = baseName(res.oldFile || file) || file
const newFile = baseName(res.newFile || '') || ''
if (!newFile) throw new Error('Backend lieferte keinen neuen Dateinamen zurück')
// ✅ FinishedDownloads lokal synchronisieren (wichtig bei Rename aus Player/Details)
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('finished-downloads:rename', { new CustomEvent('finished-downloads:rename', {
detail: { oldFile: res.oldFile, newFile: res.newFile }, detail: { oldFile, newFile },
}) })
) )
const apply = (out: string) => replaceBasename(out || '', res.newFile) const apply = (out: string) => replaceBasename(out || '', newFile)
// ✅ 1) Player immer updaten // ✅ Player nur updaten, wenn wirklich derselbe Job / dieselbe Datei
setPlayerJob((prev) => (prev ? { ...prev, output: apply(prev.output || '') } : prev)) setPlayerJob((prev) => {
if (!prev) return prev
const match = prev.id === job.id || baseName(prev.output || '') === oldFile
return match ? { ...prev, output: apply(prev.output || '') } : prev
})
// ✅ 2) doneJobs über ID (Fallback: basename) // ✅ doneJobs über ID (Fallback basename)
setDoneJobs((prev) => setDoneJobs((prev) =>
prev.map((j) => { prev.map((j) => {
const match = j.id === job.id || baseName(j.output || '') === file const match = j.id === job.id || baseName(j.output || '') === oldFile
return match ? { ...j, output: apply(j.output || '') } : j return match ? { ...j, output: apply(j.output || '') } : j
}) })
) )
// ✅ 3) jobs (/record/list) über ID (Fallback: basename) // ✅ jobs (/record/list) über ID (Fallback basename)
setJobs((prev) => setJobs((prev) =>
prev.map((j) => { prev.map((j) => {
const match = j.id === job.id || baseName(j.output || '') === file const match = j.id === job.id || baseName(j.output || '') === oldFile
return match ? { ...j, output: apply(j.output || '') } : j return match ? { ...j, output: apply(j.output || '') } : j
}) })
) )
@ -1732,6 +1709,13 @@ export default function App() {
[notify] [notify]
) )
const handleDeleteJob = useCallback(
async (job: RecordJob): Promise<void> => {
await handleDeleteJobWithUndo(job)
},
[handleDeleteJobWithUndo]
)
// --- flags patch (wie bei dir) --- // --- flags patch (wie bei dir) ---
async function patchModelFlags(patch: any): Promise<any | null> { async function patchModelFlags(patch: any): Promise<any | null> {
const res = await fetch('/api/models/flags', { const res = await fetch('/api/models/flags', {
@ -2471,7 +2455,7 @@ export default function App() {
// ✅ war irgendwann schon mal online (vor diesem Poll)? // ✅ war irgendwann schon mal online (vor diesem Poll)?
const hadEverBeenOnline = Boolean(everOnline[keyLower]) const hadEverBeenOnline = Boolean(everOnline[keyLower])
const name = String((room as any)?.username ?? keyLower).trim() || keyLower const modelName = String((room as any)?.username ?? keyLower).trim() || keyLower
const imageUrl = String((room as any)?.image_url ?? '').trim() const imageUrl = String((room as any)?.image_url ?? '').trim()
// immer merken: jetzt ist es online // immer merken: jetzt ist es online
@ -2481,10 +2465,17 @@ export default function App() {
const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow) const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow)
if (becamePublicFromWaiting) { if (becamePublicFromWaiting) {
if (notificationsOn) { if (notificationsOn) {
notify.info(name, 'ist wieder online.', { notify.info(modelName, 'ist wieder online.', {
imageUrl, imageUrl,
imageAlt: `${name} Vorschau`, imageAlt: `${modelName} Vorschau`,
durationMs: 5500, durationMs: 5500,
onClick: () => {
window.dispatchEvent(
new CustomEvent('open-model-details', {
detail: { modelKey: modelName },
})
)
},
}) })
} }
@ -2500,12 +2491,19 @@ export default function App() {
// Startup-Spam vermeiden // Startup-Spam vermeiden
if (notificationsOn && !isInitial) { if (notificationsOn && !isInitial) {
notify.info( notify.info(
name, modelName,
cameBackFromOffline ? 'ist wieder online.' : 'ist online.', cameBackFromOffline ? 'ist wieder online.' : 'ist online.',
{ {
imageUrl, imageUrl,
imageAlt: `${name} Vorschau`, imageAlt: `${modelName} Vorschau`,
durationMs: 5500, durationMs: 5500,
onClick: () => {
window.dispatchEvent(
new CustomEvent('open-model-details', {
detail: { modelKey: modelName },
})
)
},
} }
) )
} }
@ -2673,7 +2671,7 @@ export default function App() {
<div className="relative"> <div className="relative">
<header className="z-30 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10"> <header className="z-30 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3 sm:py-4 space-y-2 sm:space-y-3"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3">
<div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4"> <div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="min-w-0"> <div className="min-w-0">
@ -2813,7 +2811,7 @@ export default function App() {
</header> </header>
<div className="sm:hidden sticky top-0 z-20 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60"> <div className="sm:hidden sticky top-0 z-20 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
<div className="mx-auto max-w-7xl px-4 py-2"> <div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" /> <Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div> </div>
</div> </div>
@ -2849,6 +2847,7 @@ export default function App() {
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike} onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch} onToggleWatch={handleToggleWatch}
onKeepJob={handleKeepJob}
blurPreviews={Boolean(recSettings.blurPreviews)} blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'} teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)} teaserAudio={Boolean(recSettings.teaserAudio)}

View File

@ -43,7 +43,14 @@ export default function ButtonGroup({
const s = sizeMap[size] const s = sizeMap[size]
return ( return (
<span className={cn('isolate inline-flex rounded-md shadow-xs dark:shadow-none', className)} role="group" aria-label={ariaLabel}> <span
className={cn(
'isolate inline-flex rounded-md shadow-xs dark:shadow-none ring-1 ring-gray-300 dark:ring-gray-700 overflow-hidden',
className
)}
role="group"
aria-label={ariaLabel}
>
{items.map((it, idx) => { {items.map((it, idx) => {
const active = it.id === value const active = it.id === value
const isFirst = idx === 0 const isFirst = idx === 0
@ -59,16 +66,14 @@ export default function ButtonGroup({
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors', 'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors',
!isFirst && '-ml-px', !isFirst && 'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700',
isFirst && 'rounded-l-md', isFirst && 'rounded-l-md',
isLast && 'rounded-r-md', isLast && 'rounded-r-md',
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white) // Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
active active
? 'bg-indigo-100 text-indigo-800 inset-ring-1 inset-ring-indigo-300 hover:bg-indigo-200 ' + ? 'bg-indigo-100 text-indigo-800 hover:bg-indigo-200 dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50'
'dark:bg-indigo-500/40 dark:text-indigo-100 dark:inset-ring-indigo-400/50 dark:hover:bg-indigo-500/50' : 'bg-white text-gray-900 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:hover:bg-white/20',
: 'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
// Disabled // Disabled
'disabled:opacity-50 disabled:cursor-not-allowed', 'disabled:opacity-50 disabled:cursor-not-allowed',

View File

@ -26,7 +26,11 @@ type WaitingModelRow = {
currentShow?: string // public / private / hidden / away / unknown currentShow?: string // public / private / hidden / away / unknown
} }
type AutostartState = { paused?: boolean } type AutostartState = {
paused?: boolean
pausedByUser?: boolean
pausedByDisk?: boolean
}
type Props = { type Props = {
jobs: RecordJob[] jobs: RecordJob[]
@ -704,6 +708,20 @@ const isTerminalStatus = (status?: unknown) => {
) )
} }
function DiskEmergencyBadge() {
return (
<span
className="
inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold
bg-red-100 text-red-800 ring-1 ring-red-200
dark:bg-red-500/15 dark:text-red-200 dark:ring-red-400/25
"
title="Speicherplatz-Notbremse aktiv: Autostart gesperrt und Downloads wurden gestoppt"
>
Speicherplatz-Notbremse aktiv
</span>
)
}
export default function Downloads({ export default function Downloads({
jobs, jobs,
@ -725,15 +743,30 @@ export default function Downloads({
const [stopAllBusy, setStopAllBusy] = useState(false) const [stopAllBusy, setStopAllBusy] = useState(false)
const [watchedPaused, setWatchedPaused] = useState(false) const [watchedPaused, setWatchedPaused] = useState(false)
const [, setWatchedPausedByUser] = useState(false)
const [watchedPausedByDisk, setWatchedPausedByDisk] = useState(false)
const watchedPausedRef = useRef<boolean | null>(null) const watchedPausedRef = useRef<boolean | null>(null)
const watchedPausedByUserRef = useRef<boolean | null>(null)
const watchedPausedByDiskRef = useRef<boolean | null>(null)
const [watchedBusy, setWatchedBusy] = useState(false) const [watchedBusy, setWatchedBusy] = useState(false)
const refreshWatchedState = useCallback(async () => { const refreshWatchedState = useCallback(async () => {
try { try {
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any }) const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
const next = Boolean(s?.paused)
watchedPausedRef.current = next const nextPaused = Boolean(s?.paused)
setWatchedPaused(next) const nextPausedByUser = Boolean(s?.pausedByUser)
const nextPausedByDisk = Boolean(s?.pausedByDisk)
watchedPausedRef.current = nextPaused
watchedPausedByUserRef.current = nextPausedByUser
watchedPausedByDiskRef.current = nextPausedByDisk
setWatchedPaused(nextPaused)
setWatchedPausedByUser(nextPausedByUser)
setWatchedPausedByDisk(nextPausedByDisk)
} catch { } catch {
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen // wenn Endpoint (noch) nicht da ist: nichts kaputt machen
} }
@ -748,10 +781,24 @@ export default function Downloads({
'/api/autostart/state/stream', '/api/autostart/state/stream',
'autostart', 'autostart',
(data) => { (data) => {
const next = Boolean((data as any)?.paused) const nextPaused = Boolean((data as any)?.paused)
if (watchedPausedRef.current === next) return const nextPausedByUser = Boolean((data as any)?.pausedByUser)
watchedPausedRef.current = next const nextPausedByDisk = Boolean((data as any)?.pausedByDisk)
setWatchedPaused(next)
const unchanged =
watchedPausedRef.current === nextPaused &&
watchedPausedByUserRef.current === nextPausedByUser &&
watchedPausedByDiskRef.current === nextPausedByDisk
if (unchanged) return
watchedPausedRef.current = nextPaused
watchedPausedByUserRef.current = nextPausedByUser
watchedPausedByDiskRef.current = nextPausedByDisk
setWatchedPaused(nextPaused)
setWatchedPausedByUser(nextPausedByUser)
setWatchedPausedByDisk(nextPausedByDisk)
} }
) )
@ -762,29 +809,38 @@ export default function Downloads({
const pauseWatched = useCallback(async () => { const pauseWatched = useCallback(async () => {
if (watchedBusy || watchedPaused) return if (watchedBusy || watchedPaused) return
setWatchedBusy(true) setWatchedBusy(true)
try { try {
await fetch('/api/autostart/pause', { method: 'POST' }) const res = await fetch('/api/autostart/pause', { method: 'POST' })
setWatchedPaused(true) if (!res.ok) throw new Error(`HTTP ${res.status}`)
// State kommt normalerweise per SSE; fallback refresh:
await refreshWatchedState()
} catch { } catch {
// ignore // ignore
} finally { } finally {
setWatchedBusy(false) setWatchedBusy(false)
} }
}, [watchedBusy, watchedPaused]) }, [watchedBusy, watchedPaused, refreshWatchedState])
const resumeWatched = useCallback(async () => { const resumeWatched = useCallback(async () => {
if (watchedBusy || !watchedPaused) return // ✅ Bei Disk-Notbremse kein Resume erlauben
if (watchedBusy || !watchedPaused || watchedPausedByDisk) return
setWatchedBusy(true) setWatchedBusy(true)
try { try {
await fetch('/api/autostart/resume', { method: 'POST' }) const res = await fetch('/api/autostart/resume', { method: 'POST' })
setWatchedPaused(false) if (!res.ok) throw new Error(`HTTP ${res.status}`)
// State kommt normalerweise per SSE; fallback refresh:
await refreshWatchedState()
} catch { } catch {
// ignore // ignore
} finally { } finally {
setWatchedBusy(false) setWatchedBusy(false)
} }
}, [watchedBusy, watchedPaused]) }, [watchedBusy, watchedPaused, watchedPausedByDisk, refreshWatchedState])
// ✅ Merkt sich: für diese Jobs wurde "Stop" bereits angefordert (z.B. via "Alle stoppen") // ✅ Merkt sich: für diese Jobs wurde "Stop" bereits angefordert (z.B. via "Alle stoppen")
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({}) const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
@ -1333,14 +1389,21 @@ export default function Downloads({
<Button <Button
size="sm" size="sm"
variant={watchedPaused ? 'secondary' : 'primary'} variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy} disabled={watchedBusy || watchedPausedByDisk}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (watchedPausedByDisk) return
void (watchedPaused ? resumeWatched() : pauseWatched()) void (watchedPaused ? resumeWatched() : pauseWatched())
}} }}
className="hidden sm:inline-flex" className="hidden sm:inline-flex"
title={watchedPaused ? 'Autostart fortsetzen' : 'Autostart pausieren'} title={
watchedPausedByDisk
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
: watchedPaused
? 'Autostart fortsetzen'
: 'Autostart pausieren'
}
leadingIcon={ leadingIcon={
watchedPaused watchedPaused
? <PauseIcon className="size-4 shrink-0" /> ? <PauseIcon className="size-4 shrink-0" />
@ -1377,8 +1440,9 @@ export default function Downloads({
<div className="mt-3 grid gap-4"> <div className="mt-3 grid gap-4">
{downloadJobRows.length > 0 ? ( {downloadJobRows.length > 0 ? (
<> <>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200"> <div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
Downloads ({downloadJobRows.length}) <span>Downloads ({downloadJobRows.length})</span>
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
</div> </div>
{downloadJobRows.map((r) => ( {downloadJobRows.map((r) => (
<DownloadsCardRow <DownloadsCardRow
@ -1455,8 +1519,9 @@ export default function Downloads({
<div className="mt-3 space-y-4"> <div className="mt-3 space-y-4">
{downloadJobRows.length > 0 ? ( {downloadJobRows.length > 0 ? (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white"> <div className="mb-2 flex flex-wrap items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
Downloads ({downloadJobRows.length}) <span>Downloads ({downloadJobRows.length})</span>
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
</div> </div>
<Table <Table
rows={downloadJobRows} rows={downloadJobRows}

View File

@ -57,6 +57,7 @@ type Props = {
onToggleFavorite?: (job: RecordJob) => void | Promise<void> onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void> onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void> onToggleWatch?: (job: RecordJob) => void | Promise<void>
onKeepJob?: (job: RecordJob) => void | Promise<void>
doneTotal: number doneTotal: number
page: number page: number
pageSize: number pageSize: number
@ -195,6 +196,112 @@ const sizeBytesOf = (job: RecordJob): number | null => {
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
} }
function sleep(ms: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
}
function errorTextOf(err: unknown): string {
if (err instanceof Error) return err.message || String(err)
return String(err ?? '')
}
function looksLikeFileInUseError(err: unknown): boolean {
const s = errorTextOf(err).toLowerCase()
return (
s.includes('wird gerade verwendet') ||
s.includes('wird gerade abgespielt') ||
s.includes('sharing violation') ||
s.includes('used by another process') ||
s.includes('file in use') ||
s.includes('409')
)
}
async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit) {
const res = await fetch(input, init)
if (!res.ok) {
const text = await res.text().catch(() => '')
const msg = (text || `HTTP ${res.status}`).trim()
throw new Error(msg)
}
return res
}
type QueuedMutationTask = {
id: string
run: () => Promise<void>
}
function useMutationQueue() {
const queueRef = React.useRef<QueuedMutationTask[]>([])
const runningRef = React.useRef(false)
const scheduledRef = React.useRef(false)
// verhindert Doppel-Klick/Doppel-Swipe auf dieselbe Aktion
const pendingIdsRef = React.useRef<Set<string>>(new Set())
const schedulePump = React.useCallback(() => {
if (scheduledRef.current) return
scheduledRef.current = true
const kick = () => {
scheduledRef.current = false
void pump()
}
// best-effort "im Hintergrund"
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
;(window as any).requestIdleCallback(kick, { timeout: 250 })
} else {
setTimeout(kick, 0)
}
}, [])
const pump = React.useCallback(async () => {
if (runningRef.current) return
runningRef.current = true
try {
while (queueRef.current.length > 0) {
const task = queueRef.current.shift()
if (!task) continue
try {
await task.run()
} finally {
pendingIdsRef.current.delete(task.id)
}
// Yield zwischen Tasks -> UI bleibt responsiver
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
} finally {
runningRef.current = false
// falls währenddessen neue Tasks reinkamen
if (queueRef.current.length > 0) {
schedulePump()
}
}
}, [schedulePump])
const enqueue = React.useCallback((id: string, run: () => Promise<void>) => {
if (!id) return false
if (pendingIdsRef.current.has(id)) return false
pendingIdsRef.current.add(id)
queueRef.current.push({ id, run })
schedulePump()
return true
}, [schedulePump])
const isQueued = React.useCallback((id: string) => {
return pendingIdsRef.current.has(id)
}, [])
return { enqueue, isQueued }
}
export default function FinishedDownloads({ export default function FinishedDownloads({
jobs, jobs,
doneJobs, doneJobs,
@ -207,6 +314,7 @@ export default function FinishedDownloads({
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
onKeepJob,
doneTotal, doneTotal,
page, page,
pageSize, pageSize,
@ -226,6 +334,8 @@ export default function FinishedDownloads({
const notify = useNotify() const notify = useNotify()
const mutationQueue = useMutationQueue()
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map()) const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
const [teaserKey, setTeaserKey] = React.useState<string | null>(null) const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null) const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
@ -240,6 +350,7 @@ export default function FinishedDownloads({
const [isLoading, setIsLoading] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false)
const refillInFlightRef = React.useRef(false) const refillInFlightRef = React.useRef(false)
const refillQueuedWhileInFlightRef = React.useRef(false)
type UndoAction = type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' } | { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
@ -382,12 +493,19 @@ export default function FinishedDownloads({
}) })
}, []) }, [])
const globalFilterActive = searchTokens.length > 0 || activeTagSet.size > 0 // ✅ Mobile/UX: globales "all=1" erst bei sinnvoller Suche triggern
const searchActiveForGlobalFetch =
activeTagSet.size > 0 ||
searchTokens.some((t) => t.length >= 2)
const globalFilterActive = searchActiveForGlobalFetch
const effectiveAllMode = globalFilterActive || allMode const effectiveAllMode = globalFilterActive || allMode
const fetchAllDoneJobs = useCallback( const fetchAllDoneJobs = useCallback(
async (signal?: AbortSignal) => { async (signal?: AbortSignal) => {
setIsLoading(true) // ✅ Nur sichtbares Loading zeigen, wenn wir noch keine Override-Daten haben
const shouldShowLoading = overrideDoneJobs == null
if (shouldShowLoading) setIsLoading(true)
try { try {
const res = await fetch( const res = await fetch(
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`, `/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
@ -405,7 +523,7 @@ export default function FinishedDownloads({
setOverrideDoneJobs(items) setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length) setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
} finally { } finally {
setIsLoading(false) if (shouldShowLoading) setIsLoading(false)
} }
}, },
[sortMode, includeKeep] [sortMode, includeKeep]
@ -458,6 +576,12 @@ export default function FinishedDownloads({
const finishRefill = () => { const finishRefill = () => {
refillInFlightRef.current = false refillInFlightRef.current = false
// ✅ Nachlauf-Reload ausführen, falls während des laufenden Refills ein Event kam
if (refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = false
queueRefill()
}
} }
// ✅ Refill läuft // ✅ Refill läuft
@ -966,149 +1090,296 @@ export default function FinishedDownloads({
const releasePlayingFile = useCallback( const releasePlayingFile = useCallback(
async (file: string, opts?: { close?: boolean }) => { async (file: string, opts?: { close?: boolean }) => {
// 1) App-/Overlay-Player freigeben
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } })) window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
if (opts?.close) { if (opts?.close) {
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } })) window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
} }
await new Promise((r) => window.setTimeout(r, 250))
// 2) Einmal auf den nächsten Frame warten (React/DOM cleanup)
await new Promise<void>((r) => requestAnimationFrame(() => r()))
// 3) Nochmals release senden (hilft bei race zwischen close/unmount)
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
// 4) Windows/Filesystem braucht manchmal einen Moment bis Handles wirklich frei sind
await new Promise((r) => window.setTimeout(r, 260))
}, },
[] []
) )
const withFileReleaseRetry = useCallback(
async <T,>(
file: string,
run: () => Promise<T>,
opts?: { close?: boolean; attempts?: number; baseDelayMs?: number }
): Promise<T> => {
const attempts = Math.max(1, opts?.attempts ?? 4)
const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)
let lastErr: unknown
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
// vor JEDEM Versuch freigeben (nicht nur einmal)
await releasePlayingFile(file, { close: opts?.close ?? true })
// kurzer Tick extra (DOM/video cleanup, OS handle release)
if (attempt > 1) {
await sleep(baseDelayMs * attempt)
} else {
await sleep(80)
}
return await run()
} catch (e) {
lastErr = e
// nur bei "Datei in Verwendung" retryen
if (!looksLikeFileInUseError(e) || attempt >= attempts) {
throw e
}
// nächster Versuch
continue
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? 'Unbekannter Fehler'))
},
[releasePlayingFile]
)
type FileMutationKind = 'delete' | 'keep' | 'rename'
type RunFileMutationOptions<T> = {
kind: FileMutationKind
job: RecordJob
file: string
rowKey: string
// UI / State
setBusy?: (v: boolean) => void
isBusyNow?: () => boolean
optimisticRemove?: boolean
alreadyRemoved?: boolean
// Ausführung
run: () => Promise<T>
// Hooks
onSuccess?: (result: T) => Promise<void> | void
onError?: (err: unknown) => Promise<void> | void
// Messages
labels: {
invalidTitle: string
invalidBody: string
inUseTitle: string
failTitle: string
failPrefix?: string
}
}
const runFileMutation = useCallback(
async <T,>(opts: RunFileMutationOptions<T>): Promise<{ ok: boolean; result?: T }> => {
const {
file,
rowKey,
setBusy,
isBusyNow,
optimisticRemove,
alreadyRemoved,
run,
onSuccess,
onError,
labels,
} = opts
if (!file) {
notify.error(labels.invalidTitle, labels.invalidBody)
return { ok: false }
}
if (isBusyNow?.()) return { ok: false }
setBusy?.(true)
try {
if (optimisticRemove && !alreadyRemoved) {
animateRemove(rowKey)
}
const result = await run()
await onSuccess?.(result)
return { ok: true, result }
} catch (e: any) {
// Optimistik zurückrollen
if (optimisticRemove) {
restoreRow(rowKey)
}
await onError?.(e)
if (looksLikeFileInUseError(e)) {
notify.error(labels.inUseTitle, `${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.`)
} else {
const suffix = e?.message ? `${String(e.message)}` : ''
notify.error(labels.failTitle, `${labels.failPrefix ?? file}${suffix}`)
}
return { ok: false }
} finally {
setBusy?.(false)
}
},
[notify, animateRemove, restoreRow]
)
const deleteVideo = useCallback( const deleteVideo = useCallback(
async (job: RecordJob): Promise<boolean> => { async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise<boolean> => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
const key = keyFor(job) const key = keyFor(job)
if (!file) { const res = await runFileMutation({
notify.error('Löschen nicht möglich', 'Kein Dateiname gefunden kann nicht löschen.') kind: 'delete',
return false job,
} file,
if (deletingKeys.has(key)) return false rowKey: key,
setBusy: (v) => markDeleting(key, v),
markDeleting(key, true) isBusyNow: () => deletingKeys.has(key),
try { optimisticRemove: true,
await releasePlayingFile(file, { close: true }) alreadyRemoved: opts?.alreadyRemoved,
labels: {
// ✅ Wenn App-Handler vorhanden: den benutzen invalidTitle: 'Löschen nicht möglich',
// (WICHTIG für Undo: onDeleteJob sollte idealerweise {undoToken} zurückgeben) invalidBody: 'Kein Dateiname gefunden kann nicht löschen.',
if (onDeleteJob) { inUseTitle: 'Löschen fehlgeschlagen',
const r = await onDeleteJob(job) failTitle: 'Löschen fehlgeschlagen',
const undoToken = (r as any)?.undoToken failPrefix: file,
},
if (typeof undoToken === 'string' && undoToken) { run: async () => {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key }) if (onDeleteJob) {
} else { return await withFileReleaseRetry(
setLastAction(null) file,
// optional: nicht als "error" melden, eher info/warn async () => await onDeleteJob(job),
// notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.') { close: true, attempts: 4, baseDelayMs: 220 }
)
}
const r = await withFileReleaseRetry(
file,
async () =>
await fetchWithTextError(`/api/record/delete?file=${encodeURIComponent(file)}`, {
method: 'POST',
}),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
return (await r.json().catch(() => null)) as any
},
onSuccess: async (result: any) => {
// Fall 1: externer Handler (App) liefert { undoToken }
if (onDeleteJob) {
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else {
setLastAction(null)
}
return
}
// Fall 2: lokaler API-Call (liefert from + undoToken)
const from = (result?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
} else {
setLastAction(null)
} }
// ✅ OPTIMISTIK + Pagination refill + count hint
animateRemove(key)
queueRefill() queueRefill()
emitCountHint(-1) emitCountHint(-1)
// animateRemove queued already queueRefill(), aber extra ist ok: },
// queueRefill() })
return true return res.ok
}
// Fallback: Backend direkt
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
// ✅ Backend liefert undoToken (Trash)
const data = (await res.json().catch(() => null)) as any
const from = (data?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
else setLastAction(null)
animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(-1)
return true
} catch (e: any) {
// ✅ falls irgendwo (z.B. via External-Event) schon optimistisch entfernt wurde: zurückrollen
restoreRow(key)
notify.error('Löschen fehlgeschlagen: ', file)
return false
} finally {
markDeleting(key, false)
}
}, },
[ [
baseName,
keyFor,
deletingKeys, deletingKeys,
markDeleting, markDeleting,
releasePlayingFile,
onDeleteJob, onDeleteJob,
animateRemove, withFileReleaseRetry,
notify, runFileMutation,
restoreRow,
queueRefill, queueRefill,
emitCountHint, emitCountHint,
] ]
) )
const keepVideo = useCallback( const keepVideo = useCallback(
async (job: RecordJob) => { async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
const key = keyFor(job) const key = keyFor(job)
if (!file) { const res = await runFileMutation({
notify.error('Keep nicht möglich', 'Kein Dateiname gefunden kann nicht behalten.') kind: 'keep',
return false job,
} file,
if (keepingKeys.has(key) || deletingKeys.has(key)) return false rowKey: key,
setBusy: (v) => markKeeping(key, v),
isBusyNow: () => keepingKeys.has(key) || deletingKeys.has(key),
optimisticRemove: true,
alreadyRemoved: opts?.alreadyRemoved,
labels: {
invalidTitle: 'Keep nicht möglich',
invalidBody: 'Kein Dateiname gefunden kann nicht behalten.',
inUseTitle: 'Keep fehlgeschlagen',
failTitle: 'Keep fehlgeschlagen',
failPrefix: file,
},
run: async () => {
if (onKeepJob) {
return await withFileReleaseRetry(
file,
async () => await onKeepJob(job),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
}
markKeeping(key, true) const r = await withFileReleaseRetry(
try { file,
await releasePlayingFile(file, { close: true }) async () =>
await fetchWithTextError(`/api/record/keep?file=${encodeURIComponent(file)}`, {
method: 'POST',
}),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
return (await r.json().catch(() => null)) as any
},
onSuccess: async (data: any) => {
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }) setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
// ✅ Backend liefert ggf. newFile (uniqueDestPath) queueRefill()
const data = (await res.json().catch(() => null)) as any emitCountHint(includeKeep ? 0 : -1)
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file },
})
// ✅ Undo-Info merken return res.ok
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
// ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(includeKeep ? 0 : -1)
return true
} catch (e: any) {
notify.error('Keep fehlgeschlagen', file)
return false
} finally {
markKeeping(key, false)
}
}, },
[ [
baseName,
keyFor,
markKeeping,
keepingKeys, keepingKeys,
deletingKeys, deletingKeys,
markKeeping, withFileReleaseRetry,
releasePlayingFile, runFileMutation,
animateRemove,
notify,
queueRefill, queueRefill,
emitCountHint, emitCountHint,
includeKeep, includeKeep,
@ -1250,86 +1521,185 @@ export default function FinishedDownloads({
applyRename, applyRename,
]) ])
const [hotBusyKeys, setHotBusyKeys] = React.useState<Set<string>>(() => new Set())
const markHotBusy = useCallback((key: string, value: boolean) => {
setHotBusyKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const toggleHotVideo = useCallback( const toggleHotVideo = useCallback(
async (job: RecordJob) => { async (job: RecordJob): Promise<void> => {
const currentFile = baseName(job.output || '') const currentFile = baseName(job.output || '')
if (!currentFile) { const key = keyFor(job)
notify.error('HOT nicht möglich', 'Kein Dateiname gefunden kann nicht HOT togglen.')
return
}
// genau "HOT " Prefix
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`) const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
// Server-Truth anwenden (inkl. duration-key move via applyRename)
const applyServerTruth = (apiOld: string, apiNew: string) => {
if (!apiOld || !apiNew || apiOld === apiNew) return
applyRename(apiOld, apiNew)
}
const oldFile = currentFile const oldFile = currentFile
const optimisticNew = toggledName(oldFile) const optimisticNew = toggledName(oldFile)
// Optimistik sofort anwenden (UI snappy) await runFileMutation({
applyRename(oldFile, optimisticNew) kind: 'rename',
job,
file: currentFile,
rowKey: key,
setBusy: (v) => markHotBusy(key, v),
isBusyNow: () => hotBusyKeys.has(key),
optimisticRemove: false,
labels: {
invalidTitle: 'HOT nicht möglich',
invalidBody: 'Kein Dateiname gefunden kann nicht HOT togglen.',
inUseTitle: 'HOT umbenennen fehlgeschlagen',
failTitle: 'HOT umbenennen fehlgeschlagen',
failPrefix: oldFile,
},
run: async () => {
// Optimistik sofort anwenden
applyRename(oldFile, optimisticNew)
try { if (onToggleHot) {
await releasePlayingFile(oldFile, { close: true }) const r = await onToggleHot(job)
return r as any
}
// ✅ 1) Wenn du einen externen Handler hast: const r = await withFileReleaseRetry(
// -> ideal: er gibt {oldFile,newFile} zurück (optional) oldFile,
if (onToggleHot) { async () =>
const r = (await onToggleHot(job)) as any await fetchWithTextError(
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
{ method: 'POST' }
),
{ close: true, attempts: 4, baseDelayMs: 220 }
)
// Wenn Handler Server-Truth liefert, übernehmen, sonst Optimistik behalten return (await r.json().catch(() => null)) as any
const apiOld = typeof r?.oldFile === 'string' ? r.oldFile : '' },
const apiNew = typeof r?.newFile === 'string' ? r.newFile : '' onSuccess: async (data: any) => {
if (apiOld && apiNew) applyServerTruth(apiOld, apiNew) const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
// ✅ Undo erst jetzt setzen (nach Erfolg) if (apiOld && apiNew && apiOld !== apiNew) {
setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew }) applyRename(apiOld, apiNew)
}
if (sortMode === 'file_asc' || sortMode === 'file_desc') { setLastAction({ kind: 'hot', currentFile: apiNew })
if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
queueRefill() queueRefill()
} }
return },
} onError: async () => {
// Rename-Optimistik rollback
// ✅ 2) Fallback: Backend direkt (wichtig: oldFile verwenden!) clearRenamePair(oldFile, optimisticNew)
const res = await fetch( setLastAction(null)
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`, },
{ method: 'POST' } })
) },
[
if (!res.ok) { baseName,
const text = await res.text().catch(() => '') keyFor,
throw new Error(text || `HTTP ${res.status}`) hotBusyKeys,
} markHotBusy,
runFileMutation,
const data = (await res.json().catch(() => null)) as any applyRename,
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile clearRenamePair,
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew onToggleHot,
withFileReleaseRetry,
// Server-Truth über Optimistik drüberziehen (falls der Server anders entschieden hat) queueRefill,
if (apiOld !== apiNew) applyServerTruth(apiOld, apiNew) sortMode,
]
// ✅ Undo nach Erfolg
setLastAction({ kind: 'hot', currentFile: apiNew })
queueRefill()
} catch (e: any) {
// ❌ Rollback, weil Optimistik schon angewendet wurde
clearRenamePair(oldFile, optimisticNew)
// und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist)
setLastAction(null)
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
}
},
[notify, applyRename, clearRenamePair, releasePlayingFile, onToggleHot, queueRefill, sortMode]
) )
const enqueueDeleteVideo = useCallback((job: RecordJob): boolean => {
const key = keyFor(job)
const file = baseName(job.output || '')
if (!key || !file) return false
// bereits aktiv? dann nicht nochmal
if (deletingKeys.has(key) || keepingKeys.has(key)) return false
// sofort visuelles Busy (leichtgewichtig)
markDeleting(key, true)
// ✅ sofort raus aus dem Stack (optimistisch)
animateRemove(key)
const qid = `delete:${key}`
const accepted = mutationQueue.enqueue(qid, async () => {
try {
await deleteVideo(job, { alreadyRemoved: true })
} finally {
// deleteVideo setzt markDeleting(false) selbst im finally,
// daher hier nichts zusätzlich nötig.
}
})
if (!accepted) {
restoreRow(key) // ✅ macht markDeleting false + removing/deleted rollback
}
return accepted
}, [
mutationQueue,
keyFor,
baseName,
deletingKeys,
keepingKeys,
markDeleting,
deleteVideo,
])
const enqueueKeepVideo = useCallback((job: RecordJob): boolean => {
const key = keyFor(job)
const file = baseName(job.output || '')
if (!key || !file) return false
if (keepingKeys.has(key) || deletingKeys.has(key)) return false
markKeeping(key, true)
// ✅ sofort aus dem sichtbaren Stack raus
animateRemove(key)
const qid = `keep:${key}`
const accepted = mutationQueue.enqueue(qid, async () => {
try {
await keepVideo(job, { alreadyRemoved: true })
} finally {
// keepVideo macht markKeeping(false) im finally
}
})
if (!accepted) {
restoreRow(key)
}
return accepted
}, [
mutationQueue,
keyFor,
baseName,
keepingKeys,
deletingKeys,
markKeeping,
keepVideo,
])
const enqueueToggleHotVideo = useCallback((job: RecordJob): boolean => {
const key = keyFor(job)
if (!key) return false
if (hotBusyKeys.has(key)) return false
const qid = `hot:${key}`
return mutationQueue.enqueue(qid, async () => {
await toggleHotVideo(job)
})
}, [mutationQueue, keyFor, toggleHotVideo, hotBusyKeys])
const applyRenamedOutput = useCallback( const applyRenamedOutput = useCallback(
(job: RecordJob): RecordJob => { (job: RecordJob): RecordJob => {
const out = norm(job.output || '') const out = norm(job.output || '')
@ -1415,9 +1785,7 @@ export default function FinishedDownloads({
} }
if (detail.phase === 'success') { if (detail.phase === 'success') {
// delete final bestätigt
markDeleting(key, false) markDeleting(key, false)
queueRefill()
return return
} }
} }
@ -1428,13 +1796,18 @@ export default function FinishedDownloads({
useEffect(() => { useEffect(() => {
const onReload = () => { const onReload = () => {
if (refillInFlightRef.current) return // ✅ Wenn gerade ein Refill läuft, Reload nicht verlieren, sondern merken
if (refillInFlightRef.current) {
refillQueuedWhileInFlightRef.current = true
return
}
queueRefill() queueRefill()
} }
window.addEventListener('finished-downloads:reload', onReload as any) window.addEventListener('finished-downloads:reload', onReload as EventListener)
return () => window.removeEventListener('finished-downloads:reload', onReload as any) return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener)
}, [queueRefill /* oder fetchAllDoneJobs */]) }, [queueRefill])
useEffect(() => { useEffect(() => {
const onExternalRename = (ev: Event) => { const onExternalRename = (ev: Event) => {
@ -1634,9 +2007,12 @@ export default function FinishedDownloads({
// ✅ Hooks immer zuerst unabhängig von rows // ✅ Hooks immer zuerst unabhängig von rows
const isSmall = useMediaQuery('(max-width: 639px)') const isSmall = useMediaQuery('(max-width: 639px)')
// ✅ Mobile-Offsets für Cards-Ansicht (zentral steuerbar)
const cardsMobileOffsetTopClass = 'mt-10'
const cardsMobileOffsetBottomClass = 'mb-2' // bei Bedarf z. B. 'mb-4'
useEffect(() => { useEffect(() => {
if (!isSmall) return if (!isSmall || view !== 'cards') return
if (view !== 'cards') return
const top = pageRows[0] const top = pageRows[0]
if (!top) { if (!top) {
@ -1645,7 +2021,15 @@ export default function FinishedDownloads({
} }
const topKey = keyFor(top) const topKey = keyFor(top)
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
// ✅ Erst mal kein sofortiger Teaser-Start auf der frisch promoted Card
setTeaserKey((prev) => (prev === topKey ? prev : null))
const t = window.setTimeout(() => {
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
}, 140) // 100180ms testen
return () => window.clearTimeout(t)
}, [isSmall, view, pageRows, keyFor]) }, [isSmall, view, pageRows, keyFor])
useEffect(() => { useEffect(() => {
@ -1763,9 +2147,8 @@ export default function FinishedDownloads({
{/* Views */} {/* Views */}
<Button <Button
size={isSmall ? 'sm' : 'md'} size='md'
variant="soft" variant="soft"
className={isSmall ? 'h-9' : 'h-10'}
disabled={!lastAction || undoing} disabled={!lastAction || undoing}
onClick={undoLastAction} onClick={undoLastAction}
title={ title={
@ -2023,7 +2406,7 @@ export default function FinishedDownloads({
) : ( ) : (
<> <>
{view === 'cards' && ( {view === 'cards' && (
<div className={isSmall ? 'mt-8' : ''}> <div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
<FinishedDownloadsCardsView <FinishedDownloadsCardsView
rows={pageRows} rows={pageRows}
isSmall={isSmall} isSmall={isSmall}
@ -2067,6 +2450,9 @@ export default function FinishedDownloads({
onToggleTagFilter={toggleTagFilter} onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey} onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/> />
</div> </div>
)} )}
@ -2112,6 +2498,9 @@ export default function FinishedDownloads({
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
deleteVideo={deleteVideo} deleteVideo={deleteVideo}
keepVideo={keepVideo} keepVideo={keepVideo}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/> />
)} )}
@ -2150,6 +2539,9 @@ export default function FinishedDownloads({
activeTagSet={activeTagSet} activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter} onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey} onHoverPreviewKeyChange={setHoverTeaserKey}
enqueueDeleteVideo={enqueueDeleteVideo}
enqueueKeepVideo={enqueueKeepVideo}
enqueueToggleHot={enqueueToggleHotVideo}
/> />
)} )}

View File

@ -84,6 +84,10 @@ type Props = {
onToggleFavorite?: (job: RecordJob) => void | Promise<void> onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void> onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void> onToggleWatch?: (job: RecordJob) => void | Promise<void>
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
} }
const parseTags = (raw?: string): string[] => { const parseTags = (raw?: string): string[] => {
@ -157,6 +161,72 @@ function chooseSpriteGrid(count: number): [number, number] {
return [bestCols, bestRows] return [bestCols, bestRows]
} }
function CardBlurWrapper({
blurred,
animateUnblurOnMount,
children,
}: {
blurred?: boolean
animateUnblurOnMount?: boolean
children: React.ReactNode
}) {
const [entered, setEntered] = React.useState(!animateUnblurOnMount)
React.useEffect(() => {
if (!animateUnblurOnMount) return
const raf = requestAnimationFrame(() => {
setEntered(true)
})
return () => cancelAnimationFrame(raf)
}, [animateUnblurOnMount])
const className = [
'relative will-change-[filter,transform] transition-[filter,transform,opacity] duration-180 ease-out',
blurred
? 'blur-[1.5px] saturate-90 scale-[0.995]'
: entered
? 'blur-0 saturate-100 scale-100'
: 'blur-[1.5px] saturate-90 scale-[0.995]',
]
.filter(Boolean)
.join(' ')
return <div className={className}>{children}</div>
}
function PromoteToFrontWrapper({
animateOnMount,
children,
}: {
animateOnMount?: boolean
children: React.ReactNode
}) {
const [entered, setEntered] = React.useState(!animateOnMount)
React.useEffect(() => {
if (!animateOnMount) return
const raf = requestAnimationFrame(() => {
setEntered(true)
})
return () => cancelAnimationFrame(raf)
}, [animateOnMount])
return (
<div
className="will-change-[transform,opacity] transition-[transform,opacity] duration-160 ease-out motion-reduce:transition-none"
style={{
transform: entered
? 'translateY(0px) scale(1) translateZ(0)'
: 'translateY(-15px) scale(0.97) translateZ(0)',
opacity: entered ? 1 : 0.92,
transformOrigin: 'top center',
}}
>
{children}
</div>
)
}
export default function FinishedDownloadsCardsView({ export default function FinishedDownloadsCardsView({
rows, rows,
isSmall, isSmall,
@ -204,6 +274,10 @@ export default function FinishedDownloadsCardsView({
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) { }: Props) {
const parseMeta = React.useCallback((j: RecordJob): any | null => { const parseMeta = React.useCallback((j: RecordJob): any | null => {
@ -324,6 +398,10 @@ export default function FinishedDownloadsCardsView({
isDecorative?: boolean isDecorative?: boolean
forceLoadStill?: boolean forceLoadStill?: boolean
mobileStackTopOnlyVideo?: boolean mobileStackTopOnlyVideo?: boolean
disableScrubber?: boolean
blur?: boolean
animateUnblurOnMount?: boolean
preloadTeaserWhenStill?: boolean
} }
) => { ) => {
const k = keyFor(j) const k = keyFor(j)
@ -346,7 +424,7 @@ export default function FinishedDownloadsCardsView({
opts?.forceStill opts?.forceStill
? false ? false
: opts?.mobileStackTopOnlyVideo : opts?.mobileStackTopOnlyVideo
? (teaserPlayback === 'all' || (teaserPlayback === 'hover' ? teaserKey === k : false)) ? (teaserPlayback === 'still' ? false : true)
: (teaserPlayback === 'all' : (teaserPlayback === 'all'
? true ? true
: teaserPlayback === 'hover' : teaserPlayback === 'hover'
@ -525,145 +603,169 @@ export default function FinishedDownloadsCardsView({
} }
}} }}
> >
{/* Card shell keeps backgrounds consistent */} <CardBlurWrapper
<Card noBodyPadding className="overflow-hidden bg-transparent"> blurred={opts?.blur}
{/* Preview */} animateUnblurOnMount={opts?.animateUnblurOnMount}
<div >
id={inlineDomId} {/* Card shell keeps backgrounds consistent */}
ref={ <Card noBodyPadding className="overflow-hidden bg-transparent">
opts?.disableInline || opts?.isDecorative {/* Preview */}
? undefined <div
: registerTeaserHost(k) id={inlineDomId}
} ref={
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5" opts?.disableInline || opts?.isDecorative
onMouseEnter={ ? undefined
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k) : registerTeaserHost(k)
} }
onMouseLeave={() => { className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null) onMouseEnter={
clearScrubActiveIndex(k) isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
}} }
onClick={(e) => { onMouseLeave={() => {
e.preventDefault() if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
e.stopPropagation() clearScrubActiveIndex(k)
if (isSmall || opts?.disableInline) return }}
startInline(k) onClick={(e) => {
}} e.preventDefault()
> e.stopPropagation()
{/* media */} if (isSmall || opts?.disableInline) return
<div className="absolute inset-0 overflow-hidden rounded-t-lg"> startInline(k)
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */} }}
{!inlineActive ? ( >
<div {/* media */}
className={ <div className="absolute inset-0 overflow-hidden rounded-t-lg">
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' + {/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
'group-hover:opacity-0 group-focus-within:opacity-0 ' + {!inlineActive ? (
(inlineActive ? 'opacity-0' : 'opacity-100')
}
>
<div <div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]" className={
title={[ 'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
dur, 'group-hover:opacity-0 group-focus-within:opacity-0 ' +
resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', (inlineActive ? 'opacity-0' : 'opacity-100')
size, }
]
.filter(Boolean)
.join(' • ')}
> >
<span>{dur}</span> <div
{resLabel ? <span aria-hidden="true"></span> : null} className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
{resLabel ? <span>{resLabel}</span> : null} title={[
<span aria-hidden="true"></span> dur,
<span>{size}</span> resObj ? `${resObj.w}×${resObj.h}` : resLabel || '',
size,
]
.filter(Boolean)
.join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div> </div>
</div> ) : null}
) : null}
<FinishedVideoPreview <FinishedVideoPreview
job={j} job={j}
getFileName={(p) => stripHotPrefix(baseName(p))} getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full" className="h-full w-full"
variant="fill" variant="fill"
durationSeconds={durations[k] ?? (j as any)?.durationSeconds} durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration} onDuration={handleDuration}
showPopover={false} showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)} blur={inlineActive ? false : Boolean(blurPreviews)}
animated={allowTeaserAnimation} animated={allowTeaserAnimation}
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
clipSeconds={1} clipSeconds={1}
thumbSamples={18} thumbSamples={18}
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false} inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
inlineNonce={inlineNonce} inlineNonce={inlineNonce}
inlineControls={inlineActive} inlineControls={inlineActive}
inlineLoop={false} inlineLoop={false}
muted={previewMuted} muted={previewMuted}
popoverMuted={previewMuted} popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
alwaysLoadStill={forceLoadStill} alwaysLoadStill={forceLoadStill}
teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall} teaserPreloadEnabled={
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'} opts?.mobileStackTopOnlyVideo
scrubProgressRatio={scrubProgressRatio} ? true
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'} : (opts?.preloadTeaserWhenStill ? true : !isSmall)
/> }
teaserPreloadRootMargin={
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */} opts?.preloadTeaserWhenStill
{hasSpriteScrubber && spriteUrl ? ( ? '1200px 0px'
<img : (isSmall ? '900px 0px' : '700px 0px')
src={spriteUrl} }
alt="" scrubProgressRatio={scrubProgressRatio}
className="hidden" preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
loading="lazy"
decoding="async"
aria-hidden="true"
/> />
) : null}
{/* ✅ 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) */} {/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? ( {hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true"> <div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div className="h-full w-full" style={spriteFrameStyle} /> <div className="h-full w-full" style={spriteFrameStyle} />
</div> </div>
) : null} ) : null}
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */} {/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
{!opts?.isDecorative && !inlineActive && scrubberCount > 1 ? ( {!opts?.isDecorative && !opts?.disableScrubber && !inlineActive && scrubberCount > 1 ? (
<div <div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150" className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => setScrubHovering(k, true)} onMouseEnter={() => setScrubHovering(k, true)}
onMouseLeave={() => { onMouseLeave={() => {
setScrubHovering(k, false) setScrubHovering(k, false)
// optional: Index sofort loslassen, dann springt Bar direkt zurück auf Teaser // optional: Index sofort loslassen, dann springt Bar direkt zurück auf Teaser
setScrubActiveIndex(k, undefined) setScrubActiveIndex(k, undefined)
}} }}
> >
<PreviewScrubber <PreviewScrubber
className="pointer-events-auto px-1" className="pointer-events-auto px-1"
imageCount={scrubberCount} imageCount={scrubberCount}
activeIndex={scrubActiveIndex} activeIndex={scrubActiveIndex}
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)} onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
onIndexClick={(index) => { onIndexClick={(index) => {
// wie Preview-Klick: inline starten // wie Preview-Klick: inline starten
if (isSmall || opts?.disableInline) { if (isSmall || opts?.disableInline) {
// Mobile/Decorative/Fallback: bestehendes Verhalten // Mobile/Decorative/Fallback: bestehendes Verhalten
handleScrubberClickIndex(j, index, scrubberCount) handleScrubberClickIndex(j, index, scrubberCount)
return return
} }
// Zielsekunde aus Scrubber ableiten // Zielsekunde aus Scrubber ableiten
const seconds = const seconds =
scrubberStepSeconds > 0 scrubberStepSeconds > 0
? index * scrubberStepSeconds ? index * scrubberStepSeconds
: 0 : 0
// 1) bevorzugt: direkt inline an Position starten (falls Parent das unterstützt) // 1) bevorzugt: direkt inline an Position starten (falls Parent das unterstützt)
if (startInlineAt) { if (startInlineAt) {
startInlineAt(k, seconds, inlineDomId) 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(() => { requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) { if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -671,99 +773,88 @@ export default function FinishedDownloadsCardsView({
}) })
} }
}) })
return
}
// 2) Fallback: inline normal starten (ohne exakten Seek) // 3) Optionaler Fallback auf bestehenden Handler (wenn du dort OpenPlayerAt machst)
startInline(k) // handleScrubberClickIndex(j, index, scrubberCount)
requestAnimationFrame(() => { }}
if (!tryAutoplayInline(inlineDomId)) { stepSeconds={scrubberStepSeconds}
requestAnimationFrame(() => { />
tryAutoplayInline(inlineDomId) </div>
}) ) : null}
} </div>
})
// 3) Optionaler Fallback auf bestehenden Handler (wenn du dort OpenPlayerAt machst)
// handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
</div> </div>
</div>
{/* Footer / Meta (wie Gallery strukturiert) */} {/* Footer / Meta (wie Gallery strukturiert) */}
<div className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"> <div className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div> <div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="mt-0.5 flex items-start gap-2 min-w-0"> <div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? ( {isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300"> <span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
HOT HOT
</span>
) : null}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span> </span>
) : null} </div>
</div>
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"> <div className="shrink-0 flex items-center gap-1.5 pt-0.5">
{stripHotPrefix(fileRaw) || '—'} {isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
</span> {isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div> </div>
</div> </div>
<div className="shrink-0 flex items-center gap-1.5 pt-0.5"> {/* Meta + Actions (nicht im Video) */}
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null} <div
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null} className="mt-2"
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null} onClick={(e) => e.stopPropagation()}
</div> onMouseDown={(e) => e.stopPropagation()}
</div> >
{/* Actions: volle Breite */}
{/* Meta + Actions (nicht im Video) */} <div className="w-full">
<div <div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
className="mt-2" <RecordJobActions
onClick={(e) => e.stopPropagation()} job={j}
onMouseDown={(e) => e.stopPropagation()} variant="table"
> busy={busy}
{/* Actions: volle Breite */} collapseToMenu
<div className="w-full"> compact={false}
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> isHot={isHot}
<RecordJobActions isFavorite={isFav}
job={j} isLiked={isLiked}
variant="table" isWatching={isWatching}
busy={busy} onToggleWatch={onToggleWatch}
collapseToMenu onToggleFavorite={onToggleFavorite}
compact={false} onToggleLike={onToggleLike}
isHot={isHot} onToggleHot={onToggleHot}
isFavorite={isFav} onKeep={keepVideo}
isLiked={isLiked} onDelete={deleteVideo}
isWatching={isWatching} order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
onToggleWatch={onToggleWatch} className="w-full gap-1.5"
onToggleFavorite={onToggleFavorite} />
onToggleLike={onToggleLike} </div>
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full gap-1.5"
/>
</div> </div>
</div> </div>
</div>
{/* Tags */} {/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> <div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow <TagOverflowRow
rowKey={k} rowKey={k}
tags={tags} tags={tags}
activeTagSet={activeTagSet} activeTagSet={activeTagSet}
lower={lower} lower={lower}
onToggleTagFilter={onToggleTagFilter} onToggleTagFilter={onToggleTagFilter}
/> />
</div>
</div> </div>
</div> </Card>
</Card> </CardBlurWrapper>
</div> </div>
) )
@ -782,17 +873,17 @@ export default function FinishedDownloadsCardsView({
// Sichtbarer Stack bleibt bei 3 Karten // Sichtbarer Stack bleibt bei 3 Karten
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : [] const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
// Für Preload (Still-Previews) verwenden wir ALLE Rows auf Mobile // ✅ Mobile-Preload stark begrenzen (sonst zu viele hidden <FinishedVideoPreview/> Mounts)
const mobileAllRows = isSmall ? rows : [] const MOBILE_STILL_PRELOAD_LIMIT = 4 // 0..6 je nach Gerätetest
const mobileStillPreloadRows = isSmall
? rows.slice(mobileStackDepth, mobileStackDepth + MOBILE_STILL_PRELOAD_LIMIT)
: []
// größerer Peek-Offset für stärkeren Stack-Effekt // größerer Peek-Offset für stärkeren Stack-Effekt
const stackPeekOffsetPx = 15 const stackPeekOffsetPx = 15
// zusätzlicher Abstand ÜBER dem Stack (zum vorherigen Element)
const stackTopGapPx = 24
// weil wir nach OBEN stacken, brauchen wir oben Platz // weil wir nach OBEN stacken, brauchen wir oben Platz
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1) * stackPeekOffsetPx const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
return ( return (
<div className="relative"> <div className="relative">
@ -809,109 +900,135 @@ export default function FinishedDownloadsCardsView({
})} })}
</div> </div>
) : ( ) : (
<div className="relative"> <div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
{rows.length === 0 ? null : ( {rows.length === 0 ? null : (
<div className="relative mx-auto w-full max-w-[560px] overflow-visible"> <div
className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
style={{ overflowX: 'clip' }}
>
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */} {/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
<div <div
className="relative overflow-visible" className="relative overflow-visible"
style={{ style={{
minHeight: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined, paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
paddingTop: `${stackExtraTopPx + stackTopGapPx}px`,
}} }}
> >
{mobileVisibleStackRows {(() => {
.map((j, idx) => { const visible = mobileVisibleStackRows
const isTop = idx === 0 const topRow = visible[0]
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem( const backRows = visible.slice(1)
j,
isTop return (
? { <>
forceLoadStill: true, {/* Hintere Karten zuerst (absolut, dekorativ) */}
mobileStackTopOnlyVideo: true, {backRows
} .map((j, backIdx) => {
: { const idx = backIdx + 1 // 1,2...
const { k, cardInner } = renderCardItem(j, {
forceStill: true, forceStill: true,
disableInline: true, disableInline: true,
disablePreviewHover: true, disablePreviewHover: true,
isDecorative: true, isDecorative: true,
forceLoadStill: true, forceLoadStill: true,
} blur: true,
) preloadTeaserWhenStill: true,
const depth = idx // 0,1,2 })
const y = -(depth * stackPeekOffsetPx) // nach OBEN staffeln
const scale = 1 - depth * 0.03 // etwas stärkerer Tiefen-Effekt
const opacity = 1 - depth * 0.14
// untere Karten nur Deko (keine Interaktion) const depth = idx
if (!isTop) { const y = -(depth * stackPeekOffsetPx)
return ( const scale = 1 - depth * 0.03
<div const opacity = 1 - depth * 0.14
key={k}
className="absolute inset-x-0 top-0 pointer-events-none" return (
style={{ <div
zIndex: 20 - depth, key={k}
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`, className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity] transition-[transform,opacity] duration-220 ease-out motion-reduce:transition-none"
opacity, style={{
transformOrigin: 'top center', zIndex: 20 - depth,
}} transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
aria-hidden="true" opacity,
> transformOrigin: 'top center',
<div className="relative"> }}
{cardInner} aria-hidden="true"
{/* leichtes Frosting, damit klar ist: nur Vorschau */} >
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" /> <div className="relative">
{cardInner}
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
</div>
</div>
)
})
.reverse()}
{/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
{topRow ? (() => {
const j = topRow
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
forceLoadStill: true,
mobileStackTopOnlyVideo: true,
disableScrubber: true,
animateUnblurOnMount: true,
})
return (
<div
key={k}
className="relative touch-pan-y"
style={{ zIndex: 30 }}
>
<PromoteToFrontWrapper animateOnMount>
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
if (enqueueToggleHot) {
enqueueToggleHot(j)
return
}
await onToggleHot?.(j)
}}
onTap={() => {
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) {
requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
}
})
}}
onSwipeLeft={() => {
if (enqueueDeleteVideo) return enqueueDeleteVideo(j)
return deleteVideo(j)
}}
onSwipeRight={() => {
if (enqueueKeepVideo) return enqueueKeepVideo(j)
return keepVideo(j)
}}
>
{cardInner}
</SwipeCard>
</PromoteToFrontWrapper>
</div> </div>
</div> )
) })() : null}
} </>
)
// oberste Karte: echte SwipeCard (wie bisher) })()}
return (
<div
key={k}
className="absolute inset-x-0 top-0"
style={{
zIndex: 30,
transform: `translateY(${y}px) scale(${scale})`,
transformOrigin: 'top center',
}}
>
<SwipeCard
ref={(h) => {
if (h) swipeRefs.current.set(k, h)
else swipeRefs.current.delete(k)
}}
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onDoubleTap={async () => {
if (isHot) return
await onToggleHot?.(j)
}}
onTap={() => {
startInline(k)
requestAnimationFrame(() => {
if (!tryAutoplayInline(inlineDomId)) requestAnimationFrame(() => tryAutoplayInline(inlineDomId))
})
}}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>
{cardInner}
</SwipeCard>
</div>
)
})
.reverse() /* zuerst hinten rendern, oben zuletzt */}
</div> </div>
{/* Hidden preloader: lädt für ALLE weiteren Mobile-Rows nur das Still-Preview */} {/* ✅ Hidden preloader (mobile): nur wenige nächste Cards, sonst UI träge */}
{mobileAllRows.length > mobileStackDepth ? ( {mobileStillPreloadRows.length > 0 ? (
<div className="sr-only" aria-hidden="true"> <div className="sr-only" aria-hidden="true">
{mobileAllRows.slice(mobileStackDepth).map((j) => { {mobileStillPreloadRows.map((j) => {
const k = keyFor(j) const k = keyFor(j)
return ( return (
@ -922,9 +1039,9 @@ export default function FinishedDownloadsCardsView({
className="h-full w-full" className="h-full w-full"
showPopover={false} showPopover={false}
blur={Boolean(blurPreviews)} blur={Boolean(blurPreviews)}
// ✅ Nur Previewbild laden kein Teaser-Video
animated={false} animated={false}
teaserPreloadEnabled={true}
teaserPreloadRootMargin="1200px 0px"
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
inlineVideo={false} inlineVideo={false}
@ -932,27 +1049,14 @@ export default function FinishedDownloadsCardsView({
inlineLoop={false} inlineLoop={false}
muted={true} muted={true}
popoverMuted={true} popoverMuted={true}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
// ✅ Still immer laden
alwaysLoadStill alwaysLoadStill
// ✅ Für Hidden-Preloader kein Teaser-Video vorladen
teaserPreloadEnabled={false}
/> />
</div> </div>
) )
})} })}
</div> </div>
) : null} ) : null}
{/* optionaler Hinweis */}
{rows.length > 1 ? (
<div className="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
Wische nach links zum Löschen nach rechts zum Behalten
</div>
) : null}
</div> </div>
)} )}
</div> </div>

View File

@ -72,6 +72,11 @@ type Props = {
onToggleLike?: (job: RecordJob) => void | Promise<void> onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void> onToggleWatch?: (job: RecordJob) => void | Promise<void>
onToggleHot: (job: RecordJob) => void | Promise<void> onToggleHot: (job: RecordJob) => void | Promise<void>
// optional queued actions (bevorzugt verwenden, falls vorhanden)
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
} }
function firstNonEmptyString(...values: unknown[]): string | undefined { function firstNonEmptyString(...values: unknown[]): string | undefined {
@ -177,6 +182,9 @@ export default function FinishedDownloadsGalleryView({
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) { }: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all' const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
@ -649,9 +657,27 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
onToggleHot={onToggleHot} onToggleHot={async (job) => {
onKeep={keepVideo} if (enqueueToggleHot) {
onDelete={deleteVideo} const accepted = enqueueToggleHot(job)
if (accepted) return
}
return onToggleHot(job)
}}
onKeep={async (job) => {
if (enqueueKeepVideo) {
const accepted = enqueueKeepVideo(job)
if (accepted) return true
}
return keepVideo(job)
}}
onDelete={async (job) => {
if (enqueueDeleteVideo) {
const accepted = enqueueDeleteVideo(job)
if (accepted) return true
}
return deleteVideo(job)
}}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']} order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full gap-1.5" className="w-full gap-1.5"
/> />

View File

@ -73,6 +73,11 @@ type Props = {
deleteVideo: (job: RecordJob) => Promise<boolean> deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
// optional queued actions (bevorzugt verwenden, falls vorhanden)
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
} }
export default function FinishedDownloadsTableView({ export default function FinishedDownloadsTableView({
@ -120,6 +125,9 @@ export default function FinishedDownloadsTableView({
deleteVideo, deleteVideo,
keepVideo, keepVideo,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) { }: Props) {
const [sort, setSort] = React.useState<SortState>(null) const [sort, setSort] = React.useState<SortState>(null)
@ -427,9 +435,31 @@ export default function FinishedDownloadsTableView({
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
onToggleHot={onToggleHot} onToggleHot={
onKeep={keepVideo} onToggleHot
onDelete={deleteVideo} ? async (job) => {
if (enqueueToggleHot) {
const accepted = enqueueToggleHot(job)
if (accepted) return
}
return onToggleHot(job)
}
: undefined
}
onKeep={async (job) => {
if (enqueueKeepVideo) {
const accepted = enqueueKeepVideo(job)
if (accepted) return true
}
return keepVideo(job)
}}
onDelete={async (job) => {
if (enqueueDeleteVideo) {
const accepted = enqueueDeleteVideo(job)
if (accepted) return true
}
return deleteVideo(job)
}}
order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']} order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']}
className="flex items-center justify-end gap-1" className="flex items-center justify-end gap-1"
/> />

View File

@ -636,10 +636,8 @@ export default function FinishedVideoPreview({
} }
;(async () => { ;(async () => {
const byId = await tryFetch(`/api/record/done/meta?id=${encodeURIComponent(previewId)}`)
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
const j = byId ?? byFile if (!aborted && byFile) setFetchedMeta(byFile)
if (!aborted && j) setFetchedMeta(j)
})() })()
return () => { return () => {

View File

@ -25,6 +25,16 @@ type ParsedModel = {
type ImportKind = 'liked' | 'favorite' type ImportKind = 'liked' | 'favorite'
type ModelsViewMode = 'table' | 'gallery' type ModelsViewMode = 'table' | 'gallery'
type GallerySortMode =
| 'created_desc'
| 'created_asc'
| 'model_asc'
| 'model_desc'
| 'videos_desc'
| 'videos_asc'
| 'tags_desc'
| 'tags_asc'
export type StoredModel = { export type StoredModel = {
id: string id: string
input: string input: string
@ -208,6 +218,46 @@ function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string
return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() } return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() }
} }
function gallerySortModeFromSort(
sort: { key: string; direction: 'asc' | 'desc' } | null
): GallerySortMode {
if (!sort) return 'created_desc'
if (sort.key === 'createdAt') return sort.direction === 'asc' ? 'created_asc' : 'created_desc'
if (sort.key === 'model') return sort.direction === 'asc' ? 'model_asc' : 'model_desc'
if (sort.key === 'videos') return sort.direction === 'asc' ? 'videos_asc' : 'videos_desc'
if (sort.key === 'tags') return sort.direction === 'asc' ? 'tags_asc' : 'tags_desc'
return 'created_desc'
}
function sortFromGallerySortMode(mode: GallerySortMode): { key: string; direction: 'asc' | 'desc' } {
switch (mode) {
case 'created_asc':
return { key: 'createdAt', direction: 'asc' }
case 'created_desc':
return { key: 'createdAt', direction: 'desc' }
case 'model_asc':
return { key: 'model', direction: 'asc' }
case 'model_desc':
return { key: 'model', direction: 'desc' }
case 'videos_asc':
return { key: 'videos', direction: 'asc' }
case 'videos_desc':
return { key: 'videos', direction: 'desc' }
case 'tags_asc':
return { key: 'tags', direction: 'asc' }
case 'tags_desc':
return { key: 'tags', direction: 'desc' }
default:
return { key: 'createdAt', direction: 'desc' }
}
}
function GridIcon() { function GridIcon() {
return ( return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
@ -249,8 +299,8 @@ export default function ModelsTab() {
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false) const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({ const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
key: 'model', key: 'createdAt',
direction: 'asc', direction: 'desc',
}) })
const refreshVideoCounts = React.useCallback(async () => { const refreshVideoCounts = React.useCallback(async () => {
@ -749,6 +799,34 @@ export default function ModelsTab() {
) )
}, },
}, },
{
key: 'createdAt',
header: 'Hinzugefügt',
sortable: true,
sortValue: (m) => {
const t = Date.parse(String(m.createdAt ?? ''))
return Number.isFinite(t) ? t : 0
},
widthClassName: 'w-[160px]',
cell: (m) => {
const ts = Date.parse(String(m.createdAt ?? ''))
if (!Number.isFinite(ts) || ts <= 0) {
return <span className="text-xs text-gray-400 dark:text-gray-500"></span>
}
const d = new Date(ts)
return (
<div className="text-xs text-gray-700 dark:text-gray-300 leading-tight">
<div className="tabular-nums">
{d.toLocaleDateString('de-DE')}
</div>
<div className="tabular-nums text-gray-500 dark:text-gray-400">
{d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
)
},
},
{ {
key: 'actions', key: 'actions',
header: '', header: '',
@ -823,7 +901,7 @@ export default function ModelsTab() {
React.useEffect(() => { React.useEffect(() => {
setPage(1) setPage(1)
}, [q, tagFilter, viewMode]) }, [q, tagFilter, viewMode, sort])
const totalItems = sortedAll.length const totalItems = sortedAll.length
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize]) const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
@ -910,60 +988,190 @@ export default function ModelsTab() {
header={ header={
<div className="space-y-2"> <div className="space-y-2">
<div className="grid gap-2 sm:flex sm:items-center sm:justify-between"> <div className="grid gap-2 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center gap-2 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
Models <span className="text-gray-500 dark:text-gray-400">({filtered.length})</span> Models <span className="text-gray-500 dark:text-gray-400">({filtered.length})</span>
</div> </div>
<div className="sm:hidden"> {/* Mobile */}
<div className="sm:hidden shrink-0">
<Button variant="secondary" size="md" onClick={openImport}> <Button variant="secondary" size="md" onClick={openImport}>
Import Import
</Button> </Button>
</div> </div>
{/* Desktop */}
<div className="hidden sm:block shrink-0">
<Button variant="secondary" size="md" onClick={openImport}>
Importieren
</Button>
</div>
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2"> <div className="w-full sm:w-auto min-w-0">
<div className="flex items-center gap-2"> {/* Mobile Layout: 2 Zeilen */}
<ButtonGroup <div className="sm:hidden grid gap-2">
ariaLabel="Ansicht umschalten" {/* Zeile 1: View + Sort */}
size="lg" <div className="grid grid-cols-[auto,minmax(0,1fr)] gap-2 items-center min-w-0">
value={viewMode} <div className="shrink-0">
onChange={(id) => { <ButtonGroup
if (id === 'table' || id === 'gallery') setViewMode(id) ariaLabel="Ansicht umschalten"
}} size="lg"
items={[ className="flex w-full [&>button]:flex-1 [&>button]:min-w-0"
{ value={viewMode}
id: 'gallery', onChange={(id) => {
label: 'Gallery', if (id === 'table' || id === 'gallery') setViewMode(id)
icon: <GridIcon />, }}
}, items={[
{ {
id: 'table', id: 'gallery',
label: 'Tabelle', label: 'Gallery',
icon: <TableIcon />, icon: <GridIcon />,
}, },
]} {
/> id: 'table',
label: 'Tabelle',
icon: <TableIcon />,
},
]}
/>
</div>
<div className="hidden sm:block"> <div className="min-w-0">
<Button variant="secondary" size="md" onClick={openImport}> {viewMode === 'gallery' ? (
Importieren <>
</Button> <label className="sr-only" htmlFor="models-gallery-sort-mobile">
Sortierung
</label>
<select
id="models-gallery-sort-mobile"
value={gallerySortModeFromSort(sort)}
onChange={(e) => {
const next = sortFromGallerySortMode(e.target.value as GallerySortMode)
setSort(next)
}}
className="
h-9 w-full min-w-0
rounded-md px-2 text-sm
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10 dark:[color-scheme:dark]
"
>
<option value="created_desc">Hinzugefügt </option>
<option value="created_asc">Hinzugefügt </option>
<option value="model_asc">Model AZ</option>
<option value="model_desc">Model ZA</option>
<option value="videos_desc">Videos </option>
<option value="videos_asc">Videos </option>
<option value="tags_desc">Tags </option>
<option value="tags_asc">Tags </option>
</select>
</>
) : (
<div
aria-hidden="true"
className="
h-9 w-full min-w-0
rounded-md px-3 text-sm
inline-flex items-center
bg-gray-50 text-gray-400 ring-1 ring-gray-200
dark:bg-white/5 dark:text-white/30 dark:ring-white/10
select-none
"
title="Sortierung in der Tabellenansicht über Spaltenkopf"
>
Sortierung
</div>
)}
</div>
</div> </div>
{/* Zeile 2: Suche volle Breite */}
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
className="
w-full min-w-0
rounded-md px-3 py-2 text-sm
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10
"
/>
</div> </div>
<input {/* Desktop Layout: wie bisher */}
value={q} <div className="hidden sm:flex items-center gap-2 min-w-0 sm:justify-end">
onChange={(e) => setQ(e.target.value)} {viewMode === 'gallery' ? (
placeholder="Suchen…" <div className="shrink-0">
className=" <label className="sr-only" htmlFor="models-gallery-sort">
w-full sm:w-[260px] Sortierung
rounded-md px-3 py-2 text-sm </label>
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 <select
focus:outline-none focus:ring-2 focus:ring-indigo-500 id="models-gallery-sort"
dark:bg-white/10 dark:text-white dark:ring-white/10 value={gallerySortModeFromSort(sort)}
" onChange={(e) => {
/> const next = sortFromGallerySortMode(e.target.value as GallerySortMode)
setSort(next)
}}
className="
h-9 w-auto
rounded-md px-3 text-sm
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10 dark:[color-scheme:dark]
"
>
<option value="created_desc">Hinzugefügt </option>
<option value="created_asc">Hinzugefügt </option>
<option value="model_asc">Model AZ</option>
<option value="model_desc">Model ZA</option>
<option value="videos_desc">Videos </option>
<option value="videos_asc">Videos </option>
<option value="tags_desc">Tags </option>
<option value="tags_asc">Tags </option>
</select>
</div>
) : null}
<div className="shrink-0">
<ButtonGroup
ariaLabel="Ansicht umschalten"
size="lg"
value={viewMode}
onChange={(id) => {
if (id === 'table' || id === 'gallery') setViewMode(id)
}}
items={[
{
id: 'gallery',
label: 'Gallery',
icon: <GridIcon />,
},
{
id: 'table',
label: 'Tabelle',
icon: <TableIcon />,
},
]}
/>
</div>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
className="
w-full sm:w-[260px]
rounded-md px-3 py-2 text-sm
bg-white text-gray-900 shadow-sm ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10
"
/>
</div>
</div> </div>
</div> </div>
@ -1042,7 +1250,7 @@ export default function ModelsTab() {
return ( return (
<div <div
key={m.id} key={m.id}
className="group overflow-hidden rounded-md border border-gray-200 bg-slate-900/80 shadow-sm transition hover:shadow-md dark:border-white/10" className="group h-full overflow-hidden rounded-md border border-gray-200 bg-slate-900/80 shadow-sm transition hover:shadow-md dark:border-white/10 flex flex-col"
> >
<div <div
className="relative cursor-pointer bg-slate-950" className="relative cursor-pointer bg-slate-950"
@ -1092,19 +1300,46 @@ export default function ModelsTab() {
{/* dunkler Verlauf unten für bessere Lesbarkeit */} {/* dunkler Verlauf unten für bessere Lesbarkeit */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 via-black/20 to-transparent" /> <div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
{/* Modelname im Bild unten links (statt nur im Footer dominant) */} {/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5"> <div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
<div <div
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.7)]" className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title={m.modelKey} title={m.modelKey}
> >
{m.modelKey} {m.modelKey}
</div> </div>
{m.host ? ( {m.host ? (
<div className="truncate text-[11px] text-white/70">{m.host}</div> <div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{m.host}
</div>
) : null} ) : null}
</div> </div>
{/* Stats im Bild (unten rechts, untereinander, mit Icons) */}
<div className="pointer-events-none absolute bottom-2 right-2 z-10 flex flex-col items-end gap-1">
<span
className="inline-flex items-center gap-1 px-0.5 py-0 text-[10px] font-semibold text-white/95 tabular-nums"
title="Videos"
aria-hidden="true"
>
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🎬</span>
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{videoCountsLoading ? '…' : videoCount}
</span>
</span>
<span
className="inline-flex items-center gap-1 px-0.5 py-0 text-[10px] font-semibold text-white/95 tabular-nums"
title="Tags"
aria-hidden="true"
>
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🏷</span>
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{tags.length}
</span>
</span>
</div>
{/* oben links: Record Actions Overlay */} {/* oben links: Record Actions Overlay */}
<div <div
className={clsx( className={clsx(
@ -1123,15 +1358,16 @@ export default function ModelsTab() {
[&_button]:bg-transparent [&_button]:shadow-none [&_button]:bg-transparent [&_button]:shadow-none
[&_button:hover]:bg-white/10 [&_button:hover]:bg-white/10
[&_button]:border-0 [&_button]:border-0
[&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]
" "
/> />
</div> </div>
{/* oben rechts: Status-Icons (nur Anzeige, ohne Hintergrund) */} {/* oben rechts: Status-Icons (nur Anzeige, ohne Hintergrund) */}
<div className="absolute right-2 top-2 flex items-center gap-1.5 pointer-events-none select-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.75)]"> <div className="absolute right-2 top-2 flex items-center gap-1.5 pointer-events-none select-none">
{watch ? ( {watch ? (
<span <span
className="inline-flex items-center justify-center text-[18px] leading-none text-indigo-300" className="inline-flex items-center justify-center text-xl leading-none text-indigo-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title="Beobachtet" title="Beobachtet"
aria-hidden="true" aria-hidden="true"
> >
@ -1141,7 +1377,7 @@ export default function ModelsTab() {
{fav ? ( {fav ? (
<span <span
className="inline-flex items-center justify-center text-[18px] leading-none text-amber-300" className="inline-flex items-center justify-center text-xl leading-none text-amber-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title="Favorit" title="Favorit"
aria-hidden="true" aria-hidden="true"
> >
@ -1151,7 +1387,7 @@ export default function ModelsTab() {
{liked ? ( {liked ? (
<span <span
className="inline-flex items-center justify-center text-[18px] leading-none text-rose-300" className="inline-flex items-center justify-center text-xl leading-none text-rose-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title="Gefällt mir" title="Gefällt mir"
aria-hidden="true" aria-hidden="true"
> >
@ -1164,12 +1400,12 @@ export default function ModelsTab() {
{(m.hot || m.keep) && ( {(m.hot || m.keep) && (
<div className="absolute left-2 top-2 flex flex-col gap-1"> <div className="absolute left-2 top-2 flex flex-col gap-1">
{m.hot ? ( {m.hot ? (
<span className="rounded bg-amber-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow"> <span className="rounded bg-amber-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
HOT HOT
</span> </span>
) : null} ) : null}
{m.keep ? ( {m.keep ? (
<span className="rounded bg-indigo-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow"> <span className="rounded bg-indigo-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
KEEP KEEP
</span> </span>
) : null} ) : null}
@ -1179,69 +1415,44 @@ export default function ModelsTab() {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2"> <div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2 rounded-b-md flex-1">
{/* Zeile 1: Stats links, Actions rechts */} {/* Mobile: kompakter, aber nicht gequetscht */}
<div className="flex items-center justify-between gap-2 text-[11px]"> <div className="sm:hidden">
<div className="flex items-center gap-1.5 min-w-0 text-slate-300"> {/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
<span className="inline-flex items-center rounded-md bg-white/5 px-1.5 py-0.5 tabular-nums">
{videoCountsLoading ? '…' : videoCount} Videos
</span>
{tags.length > 0 ? (
<span className="inline-flex items-center rounded-md bg-white/5 px-1.5 py-0.5 tabular-nums">
{tags.length} Tags
</span>
) : null}
</div>
<div <div
className="flex items-center gap-1 shrink-0" className="grid grid-cols-3 gap-1.5"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span <Button
variant={watch ? 'soft' : 'secondary'}
size="xs"
className={clsx( className={clsx(
hideUntilHover && !watch 'h-8 min-w-0 px-0 shadow-none',
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity' watch
: 'opacity-100' ? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)} )}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watched: !watch })
}}
> >
<Button <span className="inline-flex items-center justify-center gap-1">
variant={watch ? 'soft' : 'secondary'} <span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
size="xs"
rounded="full"
className={clsx(
'h-7 w-7 p-0 min-w-0',
watch
? 'bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/30 shadow-none'
: 'bg-white/5 text-indigo-200/80 shadow-none hover:bg-white/10 hover:text-indigo-200'
)}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watched: !watch })
}}
>
<span
className={clsx(
'text-sm leading-none',
watch ? 'text-indigo-300' : 'text-slate-300 group-hover:text-indigo-200'
)}
>
👁 👁
</span> </span>
</Button> </span>
</span> </Button>
<Button <Button
variant={fav ? 'soft' : 'secondary'} variant={fav ? 'soft' : 'secondary'}
size="xs" size="xs"
rounded="full"
className={clsx( className={clsx(
'h-7 w-7 p-0 min-w-0', 'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !fav ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
fav fav
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 shadow-none' ? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
: 'bg-white/5 text-amber-200/80 shadow-none hover:bg-white/10 hover:text-amber-200' : 'bg-white/5 text-slate-200 hover:bg-white/10'
)} )}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'} title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => { onClick={(e) => {
@ -1250,26 +1461,21 @@ export default function ModelsTab() {
else patch(m.id, { favorite: true, liked: false }) else patch(m.id, { favorite: true, liked: false })
}} }}
> >
<span <span className="inline-flex items-center justify-center gap-1">
className={clsx( <span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}>
'text-sm leading-none',
fav ? 'text-amber-300' : 'text-slate-300 group-hover:text-amber-200' </span>
)}
>
</span> </span>
</Button> </Button>
<Button <Button
variant={liked ? 'soft' : 'secondary'} variant={liked ? 'soft' : 'secondary'}
size="xs" size="xs"
rounded="full"
className={clsx( className={clsx(
'h-7 w-7 p-0 min-w-0', 'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !liked ? 'opacity-0 group-hover:opacity-100 transition-opacity' : '',
liked liked
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 shadow-none' ? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30'
: 'bg-white/5 text-rose-200/80 shadow-none hover:bg-white/10 hover:text-rose-200' : 'bg-white/5 text-slate-200 hover:bg-white/10'
)} )}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'} title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => { onClick={(e) => {
@ -1278,33 +1484,132 @@ export default function ModelsTab() {
else patch(m.id, { liked: true, favorite: false }) else patch(m.id, { liked: true, favorite: false })
}} }}
> >
<span <span className="inline-flex items-center justify-center gap-1">
className={clsx( <span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}>
'text-sm leading-none',
liked ? 'text-rose-300' : 'text-slate-300 group-hover:text-rose-200' </span>
)}
>
</span> </span>
</Button> </Button>
</div> </div>
{/* Zeile 2: Tags */}
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
{shownTags.length > 0 ? (
shownTags.map((t) => (
<TagBadge
key={`${m.id}:${t}`}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))
) : (
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
)}
</div>
</div> </div>
{/* Zeile 2: Tags (immer vorhanden für gleiche Kartenhöhe) */} {/* Desktop: ohne Footer-Stats (wie mobile Buttons) */}
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5"> <div className="hidden sm:block">
{shownTags.length > 0 ? ( {/* Zeile 1: Actions rechts, Style wie mobile */}
shownTags.map((t) => ( <div onClick={(e) => e.stopPropagation()}>
<TagBadge <div className="grid grid-cols-3 gap-1.5 w-full">
key={`${m.id}:${t}`} <Button
tag={t} variant={watch ? 'soft' : 'secondary'}
title={t} size="xs"
active={activeTagSet.has(t.toLowerCase())} className={clsx(
onClick={toggleTagFilter} 'h-8 min-w-0 px-0 shadow-none',
/> hideUntilHover && !watch
)) ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
) : ( : 'opacity-100',
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span> watch
)} ? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watched: !watch })
}}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
👁
</span>
</span>
</Button>
<Button
variant={fav ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !fav
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100',
fav
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => {
e.stopPropagation()
if (fav) patch(m.id, { favorite: false })
else patch(m.id, { favorite: true, liked: false })
}}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}>
</span>
</span>
</Button>
<Button
variant={liked ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !liked
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100',
liked
? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30'
: 'bg-white/5 text-slate-200 hover:bg-white/10'
)}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => {
e.stopPropagation()
if (liked) patch(m.id, { liked: false })
else patch(m.id, { liked: true, favorite: false })
}}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}>
</span>
</span>
</Button>
</div>
</div>
{/* Zeile 2: Tags (immer vorhanden für gleiche Kartenhöhe) */}
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
{shownTags.length > 0 ? (
shownTags.map((t) => (
<TagBadge
key={`${m.id}:${t}`}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))
) : (
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -702,6 +702,7 @@ export default function Player({
const isDesktop = useMediaQuery('(min-width: 640px)') const isDesktop = useMediaQuery('(min-width: 640px)')
const miniDesktop = mini && isDesktop const miniDesktop = mini && isDesktop
const usePortal = expanded || miniDesktop
const WIN_KEY = 'player_window_v1' const WIN_KEY = 'player_window_v1'
@ -745,14 +746,18 @@ export default function Player({
React.useEffect(() => setMounted(true), []) React.useEffect(() => setMounted(true), [])
React.useEffect(() => { React.useEffect(() => {
if (!usePortal) {
setPortalTarget(null)
return
}
let el = document.getElementById('player-root') as HTMLElement | null let el = document.getElementById('player-root') as HTMLElement | null
if (!el) { if (!el) {
el = document.createElement('div') el = document.createElement('div')
el.id = 'player-root' el.id = 'player-root'
} }
// ✅ Mobile: immer in <body>, damit "fixed bottom-0" am echten Viewport hängt // Desktop / Expanded: im Top-Layer (Dialog) oder body
// ✅ Desktop: in den obersten offenen Dialog, damit er im Top-Layer vor dem Modal liegt
let host: HTMLElement | null = null let host: HTMLElement | null = null
if (isDesktop) { if (isDesktop) {
@ -767,7 +772,7 @@ export default function Player({
el.style.zIndex = '2147483647' el.style.zIndex = '2147483647'
setPortalTarget(el) setPortalTarget(el)
}, [isDesktop]) }, [isDesktop, usePortal])
React.useEffect(() => { React.useEffect(() => {
const p: any = playerRef.current const p: any = playerRef.current
@ -1612,7 +1617,8 @@ export default function Player({
if (job.status !== 'running') setStopPending(false) if (job.status !== 'running') setStopPending(false)
}, [job.id, job.status]) }, [job.id, job.status])
if (!mounted || !portalTarget) return null if (!mounted) return null
if (usePortal && !portalTarget) return null
const overlayBtn = const overlayBtn =
'inline-flex items-center justify-center rounded-md p-2 transition ' + 'inline-flex items-center justify-center rounded-md p-2 transition ' +
@ -2110,7 +2116,7 @@ export default function Player({
? { left: win.x, top: win.y, width: win.w, height: win.h } ? { left: win.x, top: win.y, width: win.w, height: win.h }
: undefined : undefined
return createPortal( const content = (
<> <>
<style>{` <style>{`
/* Live-Download: Progress/Seek-Bar ausblenden */ /* Live-Download: Progress/Seek-Bar ausblenden */
@ -2192,34 +2198,34 @@ export default function Player({
`}</style> `}</style>
{expanded || miniDesktop ? ( {expanded || miniDesktop ? (
<div <div
className={cn( className={cn(
'fixed z-[2147483647]', 'fixed z-[2147483647]',
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]' !isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
)} )}
style={{ style={{
...(wrapStyle as any), ...(wrapStyle as any),
willChange: isResizing ? 'left, top, width, height' : undefined, willChange: isResizing ? 'left, top, width, height' : undefined,
}} }}
> >
{snapGhostEl} {snapGhostEl}
{cardEl} {cardEl}
{miniDesktop ? ( {miniDesktop ? (
<div className="pointer-events-none absolute inset-0"> <div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} /> <div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />
<div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} /> <div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} />
<div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} /> <div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} />
<div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} /> <div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />
<div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} /> <div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} />
<div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} /> <div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} />
<div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} /> <div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} />
<div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} /> <div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
</div> </div>
) : null} ) : null}
</div> </div>
) : ( ) : (
<div <div
className=" className="
@ -2234,7 +2240,12 @@ export default function Player({
{cardEl} {cardEl}
</div> </div>
)} )}
</>, </>
portalTarget
) )
if (usePortal) {
return createPortal(content, portalTarget!)
}
return content
} }

View File

@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
import Button from './Button' import Button from './Button'
import Card from './Card' import Card from './Card'
import LabeledSwitch from './LabeledSwitch' import LabeledSwitch from './LabeledSwitch'
import GenerateAssetsTask from './GenerateAssetsTask' import Task from './Task'
import TaskList from './TaskList' import TaskList from './TaskList'
import type { TaskItem } from './TaskList' import type { TaskItem } from './TaskList'
@ -416,76 +416,81 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
<div className="flex items-center justify-between gap-3"> <Task
<div className="min-w-0 flex-1"> title="Assets-Generator"
<GenerateAssetsTask description="Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste."
onFinished={onAssetsGenerated} startLabel="Start"
onStart={(ac) => { startingLabel="Starte…"
assetsAbortRef.current = ac startUrl="/api/tasks/generate-assets"
setAssetsTask((t: TaskItem) => ({ stopUrl="/api/tasks/generate-assets"
...t, sseUrl="/api/tasks/assets/stream"
status: 'running', onFinished={onAssetsGenerated}
title: 'Assets generieren', onStart={(ac) => {
text: '', assetsAbortRef.current = ac
done: 0, setAssetsTask((t: TaskItem) => ({
total: 0, ...t,
err: undefined, status: 'running',
fading: false, title: 'Assets generieren',
})) text: '',
}} done: 0,
onProgress={(p) => { total: 0,
const fn = shortTaskFilename(p.currentFile) err: undefined,
fading: false,
}))
}}
onProgress={(p) => {
const fn = shortTaskFilename(p.currentFile)
setAssetsTask((t: TaskItem) => ({ setAssetsTask((t: TaskItem) => ({
...t, ...t,
status: 'running', status: 'running',
title: 'Assets generieren', title: 'Assets generieren',
text: fn || '', text: fn || '',
done: p.done, done: p.done,
total: p.total, total: p.total,
})) }))
}} }}
onDone={() => { onDone={() => {
assetsAbortRef.current = null assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' })) setAssetsTask((t: TaskItem) => ({ ...t, status: 'done', title: 'Assets generieren' }))
fadeOutTask(setAssetsTask) fadeOutTask(setAssetsTask)
}} }}
onCancelled={() => { onCancelled={() => {
assetsAbortRef.current = null assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ setAssetsTask((t: TaskItem) => ({
...t, ...t,
status: 'cancelled', status: 'cancelled',
title: 'Assets generieren', title: 'Assets generieren',
text: 'Abgebrochen.', text: 'Abgebrochen.',
})) }))
fadeOutTask(setAssetsTask) fadeOutTask(setAssetsTask)
}} }}
onError={(message) => { onError={(message) => {
assetsAbortRef.current = null assetsAbortRef.current = null
setAssetsTask((t: TaskItem) => ({ setAssetsTask((t: TaskItem) => ({
...t, ...t,
status: 'error', status: 'error',
title: 'Assets generieren', title: 'Assets generieren',
text: 'Fehler beim Generieren.', text: 'Fehler beim Generieren.',
err: message, err: message,
})) }))
fadeOutTask(setAssetsTask) fadeOutTask(setAssetsTask)
}} }}
/> />
</div>
<div className="shrink-0 flex items-center gap-2"> <Task
<Button title="Aufräumen"
variant="secondary" description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
onClick={cleanupSmallDone} startLabel="Aufräumen"
disabled={saving || cleaning || !value.autoDeleteSmallDownloads} startingLabel="Läuft…"
className="h-9 px-3" onTrigger={cleanupSmallDone}
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)" busy={cleaning}
> disabled={saving || !value.autoDeleteSmallDownloads}
{cleaning ? '…' : 'Aufräumen'} onError={(message) => {
</Button> // Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
</div> setErr(message)
</div> }}
/>
</div> </div>
</div> </div>

View File

@ -196,16 +196,26 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const outDx = dir === 'right' ? w + 40 : -(w + 40) const outDx = dir === 'right' ? w + 40 : -(w + 40)
dxRef.current = outDx dxRef.current = outDx
setDx(outDx) setDx(outDx)
let ok: boolean | void = true // ✅ Action sofort starten, damit Parent die nächste Karte direkt zeigen kann.
// Animation läuft parallel weiter.
let actionPromise: Promise<boolean | void>
if (runAction) { if (runAction) {
try { actionPromise = Promise.resolve(
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft() dir === 'right' ? onSwipeRight() : onSwipeLeft()
} catch { ).catch(() => false)
ok = false } else {
} actionPromise = Promise.resolve(true)
} }
// Mindestens die Commit-Animation abwarten (für sauberes Gefühl),
// aber Action bereits parallel laufen lassen.
const animPromise = new Promise<void>((resolve) => {
window.setTimeout(resolve, commitMs)
})
const [, ok] = await Promise.all([animPromise, actionPromise])
// wenn Aktion fehlschlägt => zurücksnappen // wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) { if (ok === false) {
setAnimMs(snapMs) setAnimMs(snapMs)

View File

@ -1,4 +1,4 @@
// frontend\src\components\ui\GenerateAssetsTask.tsx // frontend\src\components\ui\Task.tsx
'use client' 'use client'
@ -22,6 +22,23 @@ type TaskState = {
type Progress = { done: number; total: number; currentFile?: string } type Progress = { done: number; total: number; currentFile?: string }
type Props = { type Props = {
/** API-Endpunkte (optional, wenn onTrigger verwendet wird) */
startUrl?: string
stopUrl?: string
sseUrl?: string
/** Optional: lokaler Trigger statt API/SSE */
onTrigger?: () => Promise<void> | void
/** UI-Texte */
title?: string
description?: string
startLabel?: string
startingLabel?: string
disabled?: boolean
busy?: boolean
/** Callback-Hooks (nur für API/SSE-Variante relevant) */
onFinished?: () => void onFinished?: () => void
onStart?: (ac: AbortController) => void onStart?: (ac: AbortController) => void
onProgress?: (p: Progress) => void onProgress?: (p: Progress) => void
@ -55,7 +72,17 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
return data as T return data as T
} }
export default function GenerateAssetsTask({ export default function Task({
startUrl,
stopUrl,
sseUrl,
onTrigger,
title = 'Task',
description = 'Startet eine Hintergrundaufgabe. Fortschritt & Abbrechen oben in der Taskliste.',
startLabel = 'Start',
startingLabel = 'Starte…',
disabled = false,
busy = false,
onFinished, onFinished,
onStart, onStart,
onProgress, onProgress,
@ -87,10 +114,11 @@ export default function GenerateAssetsTask({
}, [onError]) }, [onError])
async function stopInternal() { async function stopInternal() {
if (!stopUrl) return
if (stopInFlightRef.current) return if (stopInFlightRef.current) return
stopInFlightRef.current = true stopInFlightRef.current = true
try { try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any }) await fetch(stopUrl, { method: 'DELETE', cache: 'no-store' as any })
} catch { } catch {
// ignore // ignore
} finally { } finally {
@ -147,7 +175,9 @@ export default function GenerateAssetsTask({
// SSE: State + Progress nur nach oben (TaskList), kein UI hier // SSE: State + Progress nur nach oben (TaskList), kein UI hier
useEffect(() => { useEffect(() => {
const unsub = subscribeSSE<TaskState>('/api/tasks/assets/stream', 'state', (st) => { if (!sseUrl) return
const unsub = subscribeSSE<TaskState>(sseUrl, 'state', (st) => {
setState(st) setState(st)
if (st?.running) { if (st?.running) {
@ -162,7 +192,7 @@ export default function GenerateAssetsTask({
const errText = String(st?.error ?? '').trim() const errText = String(st?.error ?? '').trim()
// Abbruch ist kein "Fehler"-Event für die UI // Abbruch ist kein "Fehler"-Event für die UI
if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) { if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) {
lastErrorRef.current = errText lastErrorRef.current = errText
onErrorRef.current?.(errText) onErrorRef.current?.(errText)
@ -170,9 +200,10 @@ export default function GenerateAssetsTask({
}) })
return () => unsub() return () => unsub()
}, []) }, [sseUrl])
async function start() { async function start() {
if (busy) return
if (state?.running) return if (state?.running) return
setStartError(null) setStartError(null)
@ -180,11 +211,34 @@ export default function GenerateAssetsTask({
cancelledRef.current = false cancelledRef.current = false
lastErrorRef.current = '' lastErrorRef.current = ''
// Controller vorbereiten, aber TaskList erst *nach* erfolgreichem Start armieren // 1) Lokaler Task-Modus (z.B. Cleanup)
if (onTrigger) {
try {
await onTrigger()
} catch (e: any) {
const msg = e?.message ?? String(e)
setStartError(msg)
onError?.(msg)
} finally {
setStarting(false)
}
return
}
// 2) API/SSE-Task-Modus (wie Assets)
if (!startUrl) {
const msg = 'Task ist nicht konfiguriert (startUrl fehlt).'
setStartError(msg)
onError?.(msg)
setStarting(false)
return
}
// Controller vorbereiten, aber TaskList erst nach erfolgreichem Start armieren
const ac = ensureControllerCreated() const ac = ensureControllerCreated()
try { try {
const st = await fetchJSON<TaskState>('/api/tasks/generate-assets', { method: 'POST' }) const st = await fetchJSON<TaskState>(startUrl, { method: 'POST' })
setState(st) setState(st)
// TaskList jetzt aktivieren // TaskList jetzt aktivieren
@ -215,10 +269,8 @@ export default function GenerateAssetsTask({
return ( return (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Assets-Generator</div> <div className="text-sm font-semibold text-gray-900 dark:text-white">{title}</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{description}</div>
Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste.
</div>
{startError ? ( {startError ? (
<div className="mt-2 text-xs text-red-700 dark:text-red-200"> <div className="mt-2 text-xs text-red-700 dark:text-red-200">
@ -228,10 +280,10 @@ export default function GenerateAssetsTask({
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<Button variant="primary" onClick={start} disabled={starting || running}> <Button variant="primary" onClick={start} disabled={disabled || busy || starting || running}>
{starting ? 'Starte…' : 'Start'} {(starting || busy) ? startingLabel : startLabel}
</Button> </Button>
</div> </div>
</div> </div>
) )
} }

View File

@ -1,5 +1,5 @@
// frontend\src\components\ui\ToastProvider.tsx // frontend\src\components\ui\ToastProvider.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -22,6 +22,12 @@ export type Toast = {
imageUrl?: string imageUrl?: string
imageAlt?: string imageAlt?: string
durationMs?: number // auto close durationMs?: number // auto close
onClick?: () => void
closeOnClick?: boolean // default true (bei klickbaren Toasts)
}
type ToastInternal = Toast & {
open: boolean
} }
type ToastContextValue = { type ToastContextValue = {
@ -32,29 +38,64 @@ type ToastContextValue = {
const ToastContext = React.createContext<ToastContextValue | null>(null) const ToastContext = React.createContext<ToastContextValue | null>(null)
const TOAST_LEAVE_MS = 220
function iconFor(type: ToastType) { function iconFor(type: ToastType) {
switch (type) { switch (type) {
case 'success': case 'success':
return { Icon: CheckCircleIcon, cls: 'text-emerald-500' } return { Icon: CheckCircleIcon, cls: 'text-emerald-600 dark:text-emerald-400' }
case 'error': case 'error':
return { Icon: XCircleIcon, cls: 'text-rose-500' } return { Icon: XCircleIcon, cls: 'text-rose-600 dark:text-rose-400' }
case 'warning': case 'warning':
return { Icon: ExclamationTriangleIcon, cls: 'text-amber-500' } return { Icon: ExclamationTriangleIcon, cls: 'text-amber-600 dark:text-amber-400' }
default: default:
return { Icon: InformationCircleIcon, cls: 'text-sky-500' } return { Icon: InformationCircleIcon, cls: 'text-sky-600 dark:text-sky-400' }
} }
} }
function borderFor(type: ToastType) { function borderFor(type: ToastType) {
switch (type) { switch (type) {
case 'success': case 'success':
return 'border-emerald-200/70 dark:border-emerald-400/20' return 'border-emerald-200/80 dark:border-emerald-400/20'
case 'error': case 'error':
return 'border-rose-200/70 dark:border-rose-400/20' return 'border-rose-200/80 dark:border-rose-400/20'
case 'warning': case 'warning':
return 'border-amber-200/70 dark:border-amber-400/20' return 'border-amber-200/80 dark:border-amber-400/20'
default: default:
return 'border-sky-200/70 dark:border-sky-400/20' return 'border-sky-200/80 dark:border-sky-400/20'
}
}
function accentFor(type: ToastType) {
switch (type) {
case 'success':
return {
line: 'bg-emerald-500 dark:bg-emerald-400',
progressTrack: 'bg-emerald-500/10 dark:bg-emerald-400/10',
progressFill: 'bg-emerald-500/70 dark:bg-emerald-400/70',
iconBg: 'bg-emerald-50 dark:bg-emerald-400/10',
}
case 'error':
return {
line: 'bg-rose-500 dark:bg-rose-400',
progressTrack: 'bg-rose-500/10 dark:bg-rose-400/10',
progressFill: 'bg-rose-500/70 dark:bg-rose-400/70',
iconBg: 'bg-rose-50 dark:bg-rose-400/10',
}
case 'warning':
return {
line: 'bg-amber-500 dark:bg-amber-400',
progressTrack: 'bg-amber-500/10 dark:bg-amber-400/10',
progressFill: 'bg-amber-500/70 dark:bg-amber-400/70',
iconBg: 'bg-amber-50 dark:bg-amber-400/10',
}
default:
return {
line: 'bg-sky-500 dark:bg-sky-400',
progressTrack: 'bg-sky-500/10 dark:bg-sky-400/10',
progressFill: 'bg-sky-500/70 dark:bg-sky-400/70',
iconBg: 'bg-sky-50 dark:bg-sky-400/10',
}
} }
} }
@ -86,180 +127,420 @@ export function ToastProvider({
defaultDurationMs?: number defaultDurationMs?: number
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left' position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
}) { }) {
const [toasts, setToasts] = React.useState<Toast[]>([]) const [toasts, setToasts] = React.useState<ToastInternal[]>([])
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true) const [notificationsEnabled, setNotificationsEnabled] = React.useState(true)
const loadNotificationSetting = React.useCallback(async () => { const autoCloseTimersRef = React.useRef<Record<string, number>>({})
try { const finalizeRemoveTimersRef = React.useRef<Record<string, number>>({})
const r = await fetch('/api/settings', { cache: 'no-store' })
if (!r.ok) return const toastStartedAtRef = React.useRef<Record<string, number>>({})
const data = await r.json() const toastRemainingMsRef = React.useRef<Record<string, number>>({})
setNotificationsEnabled(!!(data?.enableNotifications ?? true)) const toastPausedRef = React.useRef<Record<string, boolean>>({})
} catch {
// ignorieren -> default true const clearTimersFor = React.useCallback((id: string) => {
const autoId = autoCloseTimersRef.current[id]
if (autoId) {
window.clearTimeout(autoId)
delete autoCloseTimersRef.current[id]
}
const finId = finalizeRemoveTimersRef.current[id]
if (finId) {
window.clearTimeout(finId)
delete finalizeRemoveTimersRef.current[id]
}
delete toastStartedAtRef.current[id]
delete toastRemainingMsRef.current[id]
delete toastPausedRef.current[id]
}, [])
const loadNotificationSetting = React.useCallback(async () => {
try {
const r = await fetch('/api/settings', { cache: 'no-store' })
if (!r.ok) return
const data = await r.json()
setNotificationsEnabled(!!(data?.enableNotifications ?? true))
} catch {
// ignorieren -> default true
}
}, [])
React.useEffect(() => {
loadNotificationSetting()
const onUpdated = () => loadNotificationSetting()
window.addEventListener('recorder-settings-updated', onUpdated)
return () => window.removeEventListener('recorder-settings-updated', onUpdated)
}, [loadNotificationSetting])
React.useEffect(() => {
if (!notificationsEnabled) {
// Nur Fehler sichtbar lassen (animiert schließen)
setToasts((prev) => prev.map((t) => (t.type === 'error' ? t : { ...t, open: false })))
const ids = toasts.filter((t) => t.type !== 'error').map((t) => t.id)
ids.forEach((id) => {
clearTimersFor(id)
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
delete finalizeRemoveTimersRef.current[id]
}, TOAST_LEAVE_MS)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notificationsEnabled])
const remove = React.useCallback(
(id: string) => {
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, open: false } : t)))
clearTimersFor(id)
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
delete finalizeRemoveTimersRef.current[id]
}, TOAST_LEAVE_MS)
},
[clearTimersFor]
)
const clear = React.useCallback(() => {
setToasts((prev) => prev.map((t) => ({ ...t, open: false })))
Object.keys(autoCloseTimersRef.current).forEach((id) => {
window.clearTimeout(autoCloseTimersRef.current[id])
delete autoCloseTimersRef.current[id]
})
Object.keys(finalizeRemoveTimersRef.current).forEach((id) => {
window.clearTimeout(finalizeRemoveTimersRef.current[id])
delete finalizeRemoveTimersRef.current[id]
})
window.setTimeout(() => setToasts([]), TOAST_LEAVE_MS)
}, [])
const startAutoCloseTimer = React.useCallback(
(id: string, ms: number) => {
if (!ms || ms <= 0) return
// alten Timer sicher entfernen
const old = autoCloseTimersRef.current[id]
if (old) {
window.clearTimeout(old)
delete autoCloseTimersRef.current[id]
} }
}, [])
React.useEffect(() => { toastStartedAtRef.current[id] = Date.now()
// initial laden toastRemainingMsRef.current[id] = ms
loadNotificationSetting() toastPausedRef.current[id] = false
// nach "Speichern" in Settings neu laden autoCloseTimersRef.current[id] = window.setTimeout(() => {
const onUpdated = () => loadNotificationSetting() remove(id)
window.addEventListener('recorder-settings-updated', onUpdated) delete autoCloseTimersRef.current[id]
return () => window.removeEventListener('recorder-settings-updated', onUpdated) delete toastStartedAtRef.current[id]
}, [loadNotificationSetting]) delete toastRemainingMsRef.current[id]
delete toastPausedRef.current[id]
}, ms)
},
[remove]
)
// optional: wenn deaktiviert, alle aktuellen Toasts ausblenden const push = React.useCallback(
React.useEffect(() => { (t: Omit<Toast, 'id'>) => {
if (!notificationsEnabled) { if (!notificationsEnabled && t.type !== 'error') return ''
// ✅ Nur nicht-Fehler ausblenden, Fehler dürfen bleiben
setToasts((prev) => prev.filter((t) => t.type === 'error')) const id = uid()
const durationMs = t.durationMs ?? defaultDurationMs
setToasts((prev) => {
const next: ToastInternal[] = [{ ...t, id, durationMs, open: true }, ...prev]
const limit = Math.max(1, maxToasts)
const kept = next.slice(0, limit)
const dropped = next.slice(limit)
dropped.forEach((d) => clearTimersFor(d.id))
return kept
})
if (durationMs && durationMs > 0) {
startAutoCloseTimer(id, durationMs)
} }
}, [notificationsEnabled])
const remove = React.useCallback((id: string) => { return id
setToasts((prev) => prev.filter((t) => t.id !== id)) },
}, []) [defaultDurationMs, maxToasts, notificationsEnabled, clearTimersFor, startAutoCloseTimer]
)
const clear = React.useCallback(() => setToasts([]), []) const pauseAutoCloseTimer = React.useCallback((id: string) => {
if (toastPausedRef.current[id]) return
const push = React.useCallback( const timerId = autoCloseTimersRef.current[id]
(t: Omit<Toast, 'id'>) => { if (!timerId) return
// ✅ Errors IMMER zeigen, alles andere abhängig vom Toggle
if (!notificationsEnabled && t.type !== 'error') return ''
const id = uid() window.clearTimeout(timerId)
const durationMs = t.durationMs ?? defaultDurationMs delete autoCloseTimersRef.current[id]
setToasts((prev) => { const startedAt = toastStartedAtRef.current[id] ?? Date.now()
const next = [{ ...t, id, durationMs }, ...prev] const remaining = toastRemainingMsRef.current[id] ?? 0
return next.slice(0, Math.max(1, maxToasts)) const elapsed = Date.now() - startedAt
}) const nextRemaining = Math.max(0, remaining - elapsed)
if (durationMs && durationMs > 0) { toastRemainingMsRef.current[id] = nextRemaining
window.setTimeout(() => remove(id), durationMs) toastPausedRef.current[id] = true
}, [])
const resumeAutoCloseTimer = React.useCallback(
(id: string) => {
if (!toastPausedRef.current[id]) return
const remaining = toastRemainingMsRef.current[id] ?? 0
if (remaining <= 0) {
remove(id)
return
}
toastPausedRef.current[id] = false
toastStartedAtRef.current[id] = Date.now()
autoCloseTimersRef.current[id] = window.setTimeout(() => {
remove(id)
delete autoCloseTimersRef.current[id]
delete toastStartedAtRef.current[id]
delete toastRemainingMsRef.current[id]
delete toastPausedRef.current[id]
}, remaining)
},
[remove]
)
React.useEffect(() => {
return () => {
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n))
}
}, [])
const ctx = React.useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
const posCls =
position === 'top-right'
? 'items-start sm:items-start sm:justify-start'
: position === 'top-left'
? 'items-start sm:items-start sm:justify-start'
: position === 'bottom-left'
? 'items-end sm:items-end sm:justify-end'
: 'items-end sm:items-end sm:justify-end'
const alignCls = position.endsWith('left') ? 'sm:items-start' : 'sm:items-end'
const insetCls = position.startsWith('top') ? 'top-0 bottom-auto' : 'bottom-0 top-auto'
return (
<ToastContext.Provider value={ctx}>
{children}
<style>{`
@keyframes toast-progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
} }
return id .toast-progress-bar {
}, animation-name: toast-progress;
[defaultDurationMs, maxToasts, remove, notificationsEnabled] animation-timing-function: linear;
) animation-fill-mode: forwards;
transform-origin: left center;
will-change: transform;
}
`}</style>
const ctx = React.useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear]) {/* Live region */}
<div
aria-live="assertive"
className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')}
>
<div className={['flex w-full px-3 py-4 sm:px-6 sm:py-6', posCls].join(' ')}>
<div className={['flex w-full flex-col gap-2.5 sm:gap-3', alignCls].join(' ')}>
{toasts.map((t) => {
const { Icon, cls } = iconFor(t.type)
const accents = accentFor(t.type)
const title = (t.title || '').trim() || titleDefault(t.type)
const msg = (t.message || '').trim()
const img = (t.imageUrl || '').trim()
const imgAlt = (t.imageAlt || title).trim()
const isClickable = typeof t.onClick === 'function'
const posCls = return (
position === 'top-right' <Transition
? 'items-start sm:items-start sm:justify-start' key={t.id}
: position === 'top-left' appear
? 'items-start sm:items-start sm:justify-start' show={t.open}
: position === 'bottom-left' enter="transform transition ease-out duration-250"
? 'items-end sm:items-end sm:justify-end' enterFrom="opacity-0 -translate-y-3 sm:-translate-y-2"
: 'items-end sm:items-end sm:justify-end' enterTo="opacity-100 translate-y-0"
leave="transform transition ease-in duration-200"
const alignCls = leaveFrom="opacity-100 translate-y-0"
position.endsWith('left') leaveTo="opacity-0 -translate-y-2 sm:-translate-y-3"
? 'sm:items-start' >
: 'sm:items-end' <div
role={isClickable ? 'button' : 'status'}
const insetCls = className={[
position.startsWith('top') 'pointer-events-auto relative w-[22rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl',
? 'top-0 bottom-auto' 'border bg-white dark:bg-slate-900',
: 'bottom-0 top-auto' 'shadow-sm',
'ring-1 ring-black/5 dark:ring-white/5',
return ( isClickable
<ToastContext.Provider value={ctx}> ? 'cursor-pointer transition-[box-shadow,transform,background-color] duration-150 hover:bg-gray-50 dark:hover:bg-slate-800/90 hover:shadow-lg hover:ring-black/10 dark:hover:ring-white/10 active:scale-[0.998]'
{children} : '',
borderFor(t.type),
{/* Live region */} ].join(' ')}
<div onMouseEnter={() => pauseAutoCloseTimer(t.id)}
aria-live="assertive" onMouseLeave={() => resumeAutoCloseTimer(t.id)}
className={[ onFocus={() => pauseAutoCloseTimer(t.id)}
'pointer-events-none fixed z-[80] inset-x-0', onBlur={() => resumeAutoCloseTimer(t.id)}
insetCls, onClick={() => {
].join(' ')} if (!t.onClick) return
> t.onClick()
<div className={['flex w-full px-4 py-6 sm:p-6', posCls].join(' ')}> if (t.closeOnClick !== false) remove(t.id)
<div className={['flex w-full flex-col space-y-3', alignCls].join(' ')}> }}
{toasts.map((t) => { onKeyDown={(e) => {
const { Icon, cls } = iconFor(t.type) if (!t.onClick) return
const title = (t.title || '').trim() || titleDefault(t.type) if (e.key === 'Enter' || e.key === ' ') {
const msg = (t.message || '').trim() e.preventDefault()
const img = (t.imageUrl || '').trim() t.onClick()
const imgAlt = (t.imageAlt || title).trim() if (t.closeOnClick !== false) remove(t.id)
}
return ( }}
<Transition key={t.id} appear show={true}> tabIndex={isClickable ? 0 : undefined}
<div aria-label={isClickable ? `${title} öffnen` : undefined}
className={[ >
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-xl', {img ? (
'border bg-white/90 shadow-lg backdrop-blur', // ===== Layout MIT Bild: Bild links edge-to-edge (oben/unten ohne Padding) =====
'outline-1 outline-black/5', <div className="relative pr-12 sm:pr-14">
'dark:bg-gray-950/70 dark:-outline-offset-1 dark:outline-white/10', <div className="flex items-stretch">
borderFor(t.type), {/* Bild links: edge-to-edge */}
// animation classes (headlessui v2 data-*)
'transition data-closed:opacity-0 data-enter:transform data-enter:duration-200 data-enter:ease-out',
'data-closed:data-enter:translate-y-2 sm:data-closed:data-enter:translate-y-0',
position.endsWith('right')
? 'sm:data-closed:data-enter:translate-x-2'
: 'sm:data-closed:data-enter:-translate-x-2',
].join(' ')}
>
<div className="p-4">
<div className="flex items-start gap-3">
{img ? (
<div className="shrink-0">
<img
src={img}
alt={imgAlt}
loading="lazy"
referrerPolicy="no-referrer"
className={[
'h-12 w-12 rounded-lg object-cover',
'ring-1 ring-black/10 dark:ring-white/10',
].join(' ')}
/>
</div>
) : (
<div className="shrink-0"> <div className="shrink-0">
<Icon className={['size-6', cls].join(' ')} aria-hidden="true" /> <img
src={img}
alt={imgAlt}
loading="lazy"
referrerPolicy="no-referrer"
className={[
// größer + volle Höhe des Toast-Inhaltsbereichs visuell anliegend
'h-full min-h-[72px] w-16 sm:w-[72px]',
'object-cover object-center',
// nur links runden, damit es in den Toast passt
'rounded-l-xl',
// leichte Trennlinie rechts
'border-r border-black/5 dark:border-white/10',
'bg-gray-100 dark:bg-white/5',
].join(' ')}
/>
</div> </div>
)}
<div className="min-w-0 flex-1"> {/* Textbereich mit Padding */}
<p className="text-sm font-semibold text-gray-900 dark:text-white"> <div className="min-w-0 flex-1 py-3 pl-3 pr-2 sm:py-3.5 sm:pl-3.5 sm:pr-3">
<p className="truncate text-sm font-semibold leading-5 text-gray-900 dark:text-white">
{title} {title}
</p> </p>
{msg ? ( {msg ? (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300 break-words"> <p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
{msg} {msg}
</p> </p>
) : null} ) : null}
</div> </div>
<button
type="button"
onClick={() => remove(t.id)}
className="shrink-0 rounded-md text-gray-400 hover:text-gray-600 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:hover:text-white dark:focus:outline-indigo-500"
>
<span className="sr-only">Close</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</button>
</div> </div>
{/* Close-Button vertikal mittig rechts */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
remove(t.id)
}}
title="Schließen"
className={[
'absolute right-2 top-1/2 -translate-y-1/2',
'inline-flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full',
'text-gray-400 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white',
'hover:bg-gray-100 dark:hover:bg-white/10',
'active:scale-[0.98] active:bg-gray-200/80 dark:active:bg-white/15',
'transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
'dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900',
].join(' ')}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5.5 sm:size-6" />
</button>
</div> </div>
</div> ) : (
</Transition> // ===== Layout OHNE Bild: Icon mittig wie vorher =====
) <div className="relative pr-12 py-2.5 sm:pr-14">
})} <div className="flex items-center gap-3 px-3 sm:px-3.5">
</div> <div className="shrink-0">
<div
className={[
'inline-flex h-12 w-12 sm:h-14 sm:w-14 items-center justify-center rounded-full',
accents.iconBg,
'ring-1 ring-black/5 dark:ring-white/10',
].join(' ')}
>
<Icon className={['size-5.5 sm:size-6', cls].join(' ')} aria-hidden="true" />
</div>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold leading-5 text-gray-900 dark:text-white">
{title}
</p>
{msg ? (
<p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
{msg}
</p>
) : null}
</div>
</div>
{/* Close-Button vertikal mittig rechts */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
remove(t.id)
}}
title="Schließen"
className={[
'absolute right-2 top-1/2 -translate-y-1/2',
'inline-flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full',
'text-gray-400 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white',
'hover:bg-gray-100 dark:hover:bg-white/10',
'active:scale-[0.98] active:bg-gray-200/80 dark:active:bg-white/15',
'transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
'dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900',
].join(' ')}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5.5 sm:size-6" />
</button>
</div>
)}
</div>
</Transition>
)
})}
</div> </div>
</div> </div>
</ToastContext.Provider> </div>
) </ToastContext.Provider>
)
} }
export function useToast() { export function useToast() {
const ctx = React.useContext(ToastContext) const ctx = React.useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within <ToastProvider>') if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
return ctx return ctx
} }