This commit is contained in:
Linrador 2026-03-23 15:42:14 +01:00
parent 82f8b5269d
commit 19b473f8bd
7 changed files with 378 additions and 464 deletions

View File

@ -11,22 +11,17 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/grafov/m3u8" "github.com/grafov/m3u8"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
) )
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
@ -213,13 +208,6 @@ var (
jobsMu = sync.Mutex{} jobsMu = sync.Mutex{}
) )
var serverStartedAt = time.Now()
var lastCPUUsageBits uint64 // atomic float64 bits
func setLastCPUUsage(v float64) { atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) }
func getLastCPUUsage() float64 { return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) }
func startPreviewIdleKiller() { func startPreviewIdleKiller() {
t := time.NewTicker(5 * time.Second) t := time.NewTicker(5 * time.Second)
go func() { go func() {
@ -254,8 +242,8 @@ func startPreviewIdleKiller() {
} }
func init() { func init() {
initFFmpegSemaphores() initFFmpegLimiters()
startAdaptiveSemController(context.Background()) startCPUUsageSampler(context.Background())
startPreviewIdleKiller() startPreviewIdleKiller()
initSSE() initSSE()
@ -328,228 +316,6 @@ func detectFFprobePath() string {
return "ffprobe" return "ffprobe"
} }
// ---------- Dynamic Semaphore (resizeable by load controller) ----------
type DynSem struct {
mu sync.Mutex
in int
max int
cap int
}
func NewDynSem(initial, cap int) *DynSem {
if cap < 1 {
cap = 1
}
if initial < 1 {
initial = 1
}
if initial > cap {
initial = cap
}
return &DynSem{max: initial, cap: cap}
}
func (s *DynSem) Acquire(ctx context.Context) error {
for {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
s.mu.Lock()
if s.in < s.max {
s.in++
s.mu.Unlock()
return nil
}
s.mu.Unlock()
time.Sleep(25 * time.Millisecond)
}
}
func (s *DynSem) Release() {
s.mu.Lock()
if s.in > 0 {
s.in--
}
s.mu.Unlock()
}
func (s *DynSem) SetMax(n int) {
if n < 1 {
n = 1
}
if n > s.cap {
n = s.cap
}
s.mu.Lock()
s.max = n
s.mu.Unlock()
}
func (s *DynSem) Max() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.max
}
func (s *DynSem) Cap() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.cap
}
func (s *DynSem) InUse() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.in
}
var (
genSem *DynSem
previewSem *DynSem
thumbSem *DynSem
durSem *DynSem
)
func clamp(n, lo, hi int) int {
if n < lo {
return lo
}
if n > hi {
return hi
}
return n
}
func envInt(name string) (int, bool) {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return 0, false
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return n, true
}
func initFFmpegSemaphores() {
cpu := runtime.NumCPU()
if cpu <= 0 {
cpu = 2
}
// Defaults (heuristisch)
previewN := clamp((cpu+1)/2, 1, 6) // x264 live -> konservativ
thumbN := clamp(cpu, 2, 12) // Frames -> darf höher
genN := clamp((cpu+3)/4, 1, 4) // preview.mp4 clips -> eher klein
durN := clamp(cpu, 2, 16) // ffprobe: darf höher, aber nicht unbegrenzt
// ENV Overrides (optional)
if n, ok := envInt("PREVIEW_WORKERS"); ok {
previewN = clamp(n, 1, 32)
}
if n, ok := envInt("THUMB_WORKERS"); ok {
thumbN = clamp(n, 1, 64)
}
if n, ok := envInt("GEN_WORKERS"); ok {
genN = clamp(n, 1, 16)
}
if n, ok := envInt("DUR_WORKERS"); ok {
durN = clamp(n, 1, 64)
}
// Caps (Obergrenzen) können via ENV überschrieben werden
previewCap := clamp(cpu, 2, 12)
thumbCap := clamp(cpu*2, 4, 32)
genCap := clamp((cpu+1)/2, 2, 12)
durCap := clamp(cpu*2, 4, 32)
if n, ok := envInt("PREVIEW_CAP"); ok {
previewCap = clamp(n, 1, 64)
}
if n, ok := envInt("THUMB_CAP"); ok {
thumbCap = clamp(n, 1, 128)
}
if n, ok := envInt("GEN_CAP"); ok {
genCap = clamp(n, 1, 64)
}
if n, ok := envInt("DUR_CAP"); ok {
durCap = clamp(n, 1, 128)
}
// Initial max (Startwerte)
previewSem = NewDynSem(previewN, previewCap)
thumbSem = NewDynSem(thumbN, thumbCap)
genSem = NewDynSem(genN, genCap)
durSem = NewDynSem(durN, durCap)
/*
fmt.Printf(
"🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n",
previewSem.Max(), previewSem.Cap(),
thumbSem.Max(), thumbSem.Cap(),
genSem.Max(), genSem.Cap(),
durSem.Max(), durSem.Cap(),
cpu,
)
fmt.Printf(
"🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n",
previewN, thumbN, genN, durN, cpu,
)
*/
}
func startAdaptiveSemController(ctx context.Context) {
targetHi := 85.0
targetLo := 65.0
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_HI")); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
targetHi = f
}
}
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_LO")); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
targetLo = f
}
}
// Warmup (erste Messung kann 0 sein)
_, _ = gocpu.Percent(200*time.Millisecond, false)
t := time.NewTicker(2 * time.Second)
go func() {
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
p, err := gocpu.Percent(0, false)
if err != nil || len(p) == 0 {
continue
}
usage := p[0]
setLastCPUUsage(usage)
// Preview ist am teuersten → konservativ
if usage > targetHi {
previewSem.SetMax(previewSem.Max() - 1)
genSem.SetMax(genSem.Max() - 1)
thumbSem.SetMax(thumbSem.Max() - 1)
} else if usage < targetLo {
previewSem.SetMax(previewSem.Max() + 1)
genSem.SetMax(genSem.Max() + 1)
thumbSem.SetMax(thumbSem.Max() + 1)
}
}
}
}()
}
type durEntry struct { type durEntry struct {
size int64 size int64
mod time.Time mod time.Time
@ -565,185 +331,6 @@ var startedAtFromFilenameRe = regexp.MustCompile(
`^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`, `^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`,
) )
func buildPerfSnapshot() map[string]any {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
s := getSettings()
recordDir, _ := resolvePathRelativeToApp(s.RecordDir)
var diskFreeBytes uint64
var diskTotalBytes uint64
var diskUsedPercent float64
diskPath := recordDir
if recordDir != "" {
if u, err := godisk.Usage(recordDir); err == nil && u != nil {
diskFreeBytes = u.Free
diskTotalBytes = u.Total
diskUsedPercent = u.UsedPercent
}
}
// ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB)
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
resp := map[string]any{
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"serverMs": time.Now().UTC().UnixMilli(), // ✅ für "Ping" im Frontend (Approx)
"uptimeSec": time.Since(serverStartedAt).Seconds(),
"cpuPercent": func() float64 {
v := getLastCPUUsage()
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return 0
}
return v
}(),
"diskPath": diskPath,
"diskFreeBytes": diskFreeBytes,
"diskTotalBytes": diskTotalBytes,
"diskUsedPercent": diskUsedPercent,
"diskEmergency": atomic.LoadInt32(&diskEmergency) == 1,
// ✅ statt LowDiskPauseBelowGB aus Settings
"diskPauseBelowGB": pauseGB,
"diskResumeAboveGB": resumeGB,
// ✅ optional, aber sehr hilfreich (Debug/UI)
"diskInFlightBytes": inFlight,
"diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)),
"diskPauseNeedBytes": pauseNeed,
"diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)),
"diskResumeNeedBytes": resumeNeed,
"diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)),
"goroutines": runtime.NumGoroutine(),
"mem": map[string]any{
"alloc": ms.Alloc,
"heapAlloc": ms.HeapAlloc,
"heapInuse": ms.HeapInuse,
"sys": ms.Sys,
"numGC": ms.NumGC,
},
}
sem := map[string]any{}
if genSem != nil {
sem["gen"] = map[string]any{"inUse": genSem.InUse(), "cap": genSem.Cap(), "max": genSem.Max()}
}
if previewSem != nil {
sem["preview"] = map[string]any{"inUse": previewSem.InUse(), "cap": previewSem.Cap(), "max": previewSem.Max()}
}
if thumbSem != nil {
sem["thumb"] = map[string]any{"inUse": thumbSem.InUse(), "cap": thumbSem.Cap(), "max": thumbSem.Max()}
}
if durSem != nil {
sem["dur"] = map[string]any{"inUse": durSem.InUse(), "cap": durSem.Cap(), "max": durSem.Max()}
}
if len(sem) > 0 {
resp["sem"] = sem
}
return resp
}
func pingHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func perfHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
resp := buildPerfSnapshot()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(resp)
}
func perfStreamHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
// Optional: client kann Intervall mitgeben: /api/perf/stream?ms=5000
ms := 5000
if q := r.URL.Query().Get("ms"); q != "" {
if v, err := strconv.Atoi(q); err == nil {
// clamp: 1000..30000
if v < 1000 {
v = 1000
}
if v > 30000 {
v = 30000
}
ms = v
}
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
// hilfreich hinter nginx/proxies:
w.Header().Set("X-Accel-Buffering", "no")
ctx := r.Context()
// sofort erstes Event schicken
send := func() error {
payload := buildPerfSnapshot()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
return err
}
// event: perf
_, _ = io.WriteString(w, "event: perf\n")
_, _ = io.WriteString(w, "data: ")
_, _ = w.Write(buf.Bytes())
_, _ = io.WriteString(w, "\n")
fl.Flush()
return nil
}
// initial
_ = send()
t := time.NewTicker(time.Duration(ms) * time.Millisecond)
hb := time.NewTicker(15 * time.Second) // heartbeat gegen Proxy timeouts
defer t.Stop()
defer hb.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = send()
case <-hb.C:
// SSE Kommentar als Heartbeat
_, _ = io.WriteString(w, ": keep-alive\n\n")
fl.Flush()
}
}
}
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
// returns: (shouldDelete, sizeBytes, thresholdBytes) // returns: (shouldDelete, sizeBytes, thresholdBytes)

343
backend/performance.go Normal file
View File

@ -0,0 +1,343 @@
// backend\performance.go
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"math"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
)
// Server-Startzeit für Perf/Uptime
var serverStartedAt = time.Now()
var lastCPUUsageBits uint64 // atomic float64 bits
func setLastCPUUsage(v float64) {
atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v))
}
func getLastCPUUsage() float64 {
return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits))
}
func startCPUUsageSampler(ctx context.Context) {
_, _ = gocpu.Percent(200*time.Millisecond, false)
t := time.NewTicker(2 * time.Second)
go func() {
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
p, err := gocpu.Percent(0, false)
if err != nil || len(p) == 0 {
continue
}
usage := p[0]
if math.IsNaN(usage) || math.IsInf(usage, 0) || usage < 0 {
continue
}
if usage > 100 {
usage = 100
}
setLastCPUUsage(usage)
}
}
}()
}
// ---------- Feste ffmpeg/ffprobe Limiter ----------
type FixedLimiter chan struct{}
func NewFixedLimiter(n int) FixedLimiter {
if n < 1 {
n = 1
}
return make(FixedLimiter, n)
}
func (l FixedLimiter) Acquire(ctx context.Context) error {
if l == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
select {
case l <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (l FixedLimiter) Release() {
if l == nil {
return
}
select {
case <-l:
default:
}
}
func (l FixedLimiter) Limit() int {
return cap(l)
}
func (l FixedLimiter) InUse() int {
return len(l)
}
var (
previewSem FixedLimiter
thumbSem FixedLimiter
genSem FixedLimiter
durSem FixedLimiter
)
func envInt(name string) (int, bool) {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return 0, false
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return n, true
}
func initFFmpegLimiters() {
previewN := 2
thumbN := 4
genN := 2
durN := 8
if n, ok := envInt("PREVIEW_WORKERS"); ok && n > 0 {
previewN = n
}
if n, ok := envInt("THUMB_WORKERS"); ok && n > 0 {
thumbN = n
}
if n, ok := envInt("GEN_WORKERS"); ok && n > 0 {
genN = n
}
if n, ok := envInt("DUR_WORKERS"); ok && n > 0 {
durN = n
}
previewSem = NewFixedLimiter(previewN)
thumbSem = NewFixedLimiter(thumbN)
genSem = NewFixedLimiter(genN)
durSem = NewFixedLimiter(durN)
setLastCPUUsage(0)
startCPUUsageSampler(context.Background())
}
func buildPerfSnapshot() map[string]any {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
s := getSettings()
recordDir, _ := resolvePathRelativeToApp(s.RecordDir)
var diskFreeBytes uint64
var diskTotalBytes uint64
var diskUsedPercent float64
diskPath := recordDir
if recordDir != "" {
if u, err := godisk.Usage(recordDir); err == nil && u != nil {
diskFreeBytes = u.Free
diskTotalBytes = u.Total
diskUsedPercent = u.UsedPercent
}
}
// ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB)
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
resp := map[string]any{
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"serverMs": time.Now().UTC().UnixMilli(),
"uptimeSec": time.Since(serverStartedAt).Seconds(),
"cpuPercent": func() float64 {
v := getLastCPUUsage()
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}(),
"diskPath": diskPath,
"diskFreeBytes": diskFreeBytes,
"diskTotalBytes": diskTotalBytes,
"diskUsedPercent": diskUsedPercent,
"diskEmergency": atomic.LoadInt32(&diskEmergency) == 1,
"diskPauseBelowGB": pauseGB,
"diskResumeAboveGB": resumeGB,
"diskInFlightBytes": inFlight,
"diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)),
"diskPauseNeedBytes": pauseNeed,
"diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)),
"diskResumeNeedBytes": resumeNeed,
"diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)),
"goroutines": runtime.NumGoroutine(),
"mem": map[string]any{
"alloc": ms.Alloc,
"heapAlloc": ms.HeapAlloc,
"heapInuse": ms.HeapInuse,
"sys": ms.Sys,
"numGC": ms.NumGC,
},
}
sem := map[string]any{}
if previewSem != nil {
sem["preview"] = map[string]any{
"inUse": previewSem.InUse(),
"limit": previewSem.Limit(),
}
}
if thumbSem != nil {
sem["thumb"] = map[string]any{
"inUse": thumbSem.InUse(),
"limit": thumbSem.Limit(),
}
}
if genSem != nil {
sem["gen"] = map[string]any{
"inUse": genSem.InUse(),
"limit": genSem.Limit(),
}
}
if durSem != nil {
sem["dur"] = map[string]any{
"inUse": durSem.InUse(),
"limit": durSem.Limit(),
}
}
if len(sem) > 0 {
resp["sem"] = sem
}
return resp
}
func pingHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func perfHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
resp := buildPerfSnapshot()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(resp)
}
func perfStreamHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
ms := 5000
if q := r.URL.Query().Get("ms"); q != "" {
if v, err := strconv.Atoi(q); err == nil {
if v < 1000 {
v = 1000
}
if v > 30000 {
v = 30000
}
ms = v
}
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
ctx := r.Context()
send := func() error {
payload := buildPerfSnapshot()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
return err
}
_, _ = io.WriteString(w, "event: perf\n")
_, _ = io.WriteString(w, "data: ")
_, _ = w.Write(buf.Bytes())
_, _ = io.WriteString(w, "\n")
fl.Flush()
return nil
}
_ = send()
t := time.NewTicker(time.Duration(ms) * time.Millisecond)
hb := time.NewTicker(15 * time.Second)
defer t.Stop()
defer hb.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = send()
case <-hb.C:
_, _ = io.WriteString(w, ": keep-alive\n\n")
fl.Flush()
}
}
}

View File

@ -3435,7 +3435,7 @@ export default function App() {
</div> </div>
<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-[70] 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 pt-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">
@ -3589,7 +3589,7 @@ export default function App() {
</div> </div>
</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-[65] 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 pt-0 pb-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>

View File

@ -34,7 +34,7 @@ export default function Checkbox({
}, [indeterminate, checked]) }, [indeterminate, checked])
return ( return (
<div className={['group grid size-4 grid-cols-1', className].filter(Boolean).join(' ')}> <div className={['relative z-0 group grid size-4 grid-cols-1', className].filter(Boolean).join(' ')}>
<input <input
ref={ref} ref={ref}
id={id} id={id}

View File

@ -2905,57 +2905,51 @@ export default function FinishedDownloads({
<Button <Button
size="sm" size="sm"
variant="soft" variant="soft"
color={allPageSelected ? 'amber' : 'indigo'}
leadingIcon={allPageSelected ? <XMarkIcon className="size-4 shrink-0" /> : <CheckIcon className="size-4 shrink-0" />}
disabled={bulkBusy} disabled={bulkBusy}
onClick={() => { onClick={() => {
if (allPageSelected) deselectPageRows(pageRows) if (allPageSelected) deselectPageRows(pageRows)
else selectPageRows(pageRows) else selectPageRows(pageRows)
}} }}
className={[ className="w-full justify-center"
'w-full inline-flex items-center justify-center gap-2 font-medium',
allPageSelected
? 'text-amber-700 ring-amber-200 hover:bg-amber-50 dark:text-amber-300 dark:ring-amber-500/20 dark:hover:bg-amber-500/10'
: 'text-indigo-700 ring-indigo-200 hover:bg-indigo-50 dark:text-indigo-300 dark:ring-indigo-500/20 dark:hover:bg-indigo-500/10',
].join(' ')}
> >
{allPageSelected ? ( {allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
<XMarkIcon className="size-4 shrink-0" />
) : (
<CheckIcon className="size-4 shrink-0" />
)}
<span>{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}</span>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="soft" variant="secondary"
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
disabled={bulkBusy} disabled={bulkBusy}
onClick={clearSelection} onClick={clearSelection}
className="w-full inline-flex items-center justify-center gap-2 font-medium text-gray-700 ring-gray-200 hover:bg-gray-50 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10" className="w-full justify-center"
> >
<XMarkIcon className="size-4 shrink-0" /> Auswahl aufheben
<span>Auswahl aufheben</span>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="soft" variant="soft"
color="emerald"
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
disabled={bulkBusy} disabled={bulkBusy}
onClick={() => void bulkKeepSelected()} onClick={() => void bulkKeepSelected()}
className="w-full inline-flex items-center justify-center gap-2 font-medium text-emerald-700 ring-emerald-200 hover:bg-emerald-50 dark:text-emerald-300 dark:ring-emerald-500/20 dark:hover:bg-emerald-500/10" className="w-full justify-center"
> >
<ArchiveBoxIcon className="size-4 shrink-0" /> Behalten
<span>Behalten</span>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="soft" variant="soft"
color="red"
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
disabled={bulkBusy} disabled={bulkBusy}
onClick={() => void bulkDeleteSelected()} onClick={() => void bulkDeleteSelected()}
className="w-full inline-flex items-center justify-center gap-2 font-medium text-red-700 ring-red-200 hover:bg-red-50 dark:text-red-300 dark:ring-red-500/20 dark:hover:bg-red-500/10" className="w-full justify-center"
> >
<TrashIcon className="size-4 shrink-0" /> Löschen
<span>Löschen</span>
</Button> </Button>
</div> </div>
</div> </div>
@ -2972,35 +2966,25 @@ export default function FinishedDownloads({
<Button <Button
size="sm" size="sm"
variant="soft" variant="soft"
color={allPageSelected ? 'amber' : 'indigo'}
leadingIcon={allPageSelected ? <XMarkIcon className="size-4 shrink-0" /> : <CheckIcon className="size-4 shrink-0" />}
disabled={bulkBusy} disabled={bulkBusy}
onClick={() => { onClick={() => {
if (allPageSelected) deselectPageRows(pageRows) if (allPageSelected) deselectPageRows(pageRows)
else selectPageRows(pageRows) else selectPageRows(pageRows)
}} }}
className={[
'inline-flex items-center gap-2 font-medium',
allPageSelected
? 'text-amber-700 ring-amber-200 hover:bg-amber-50 dark:text-amber-300 dark:ring-amber-500/20 dark:hover:bg-amber-500/10'
: 'text-indigo-700 ring-indigo-200 hover:bg-indigo-50 dark:text-indigo-300 dark:ring-indigo-500/20 dark:hover:bg-indigo-500/10',
].join(' ')}
> >
{allPageSelected ? ( {allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
<XMarkIcon className="size-4 shrink-0" />
) : (
<CheckIcon className="size-4 shrink-0" />
)}
<span>{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}</span>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="soft" variant="secondary"
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
disabled={bulkBusy} disabled={bulkBusy}
onClick={clearSelection} onClick={clearSelection}
className="inline-flex items-center gap-2 font-medium text-gray-700 ring-gray-200 hover:bg-gray-50 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
> >
<XMarkIcon className="size-4 shrink-0" /> Auswahl aufheben
<span>Auswahl aufheben</span>
</Button> </Button>
<div className="h-5 w-px bg-gray-200 dark:bg-white/10" /> <div className="h-5 w-px bg-gray-200 dark:bg-white/10" />
@ -3008,23 +2992,23 @@ export default function FinishedDownloads({
<Button <Button
size="sm" size="sm"
variant="soft" variant="soft"
color="emerald"
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
disabled={bulkBusy} disabled={bulkBusy}
onClick={() => void bulkKeepSelected()} onClick={() => void bulkKeepSelected()}
className="inline-flex items-center gap-2 font-medium text-emerald-700 ring-emerald-200 hover:bg-emerald-50 dark:text-emerald-300 dark:ring-emerald-500/20 dark:hover:bg-emerald-500/10"
> >
<ArchiveBoxIcon className="size-4 shrink-0" /> Behalten
<span>Behalten</span>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="soft" variant="soft"
color="red"
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
disabled={bulkBusy} disabled={bulkBusy}
onClick={() => void bulkDeleteSelected()} onClick={() => void bulkDeleteSelected()}
className="inline-flex items-center gap-2 font-medium text-red-700 ring-red-200 hover:bg-red-50 dark:text-red-300 dark:ring-red-500/20 dark:hover:bg-red-500/10"
> >
<TrashIcon className="size-4 shrink-0" /> Löschen
<span>Löschen</span>
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -811,7 +811,7 @@ export default function FinishedDownloadsCardsView({
{!isSmall ? ( {!isSmall ? (
<div <div
className={[ className={[
'absolute left-3 top-3 z-30 transition-opacity duration-150', 'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100', checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')} ].join(' ')}
onClick={(e) => { onClick={(e) => {

View File

@ -509,7 +509,7 @@ export default function FinishedDownloadsGalleryView({
> >
<div <div
className={[ className={[
'absolute left-3 top-3 z-30 transition-opacity duration-150', 'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100', checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')} ].join(' ')}
onClick={(e) => { onClick={(e) => {