This commit is contained in:
Linrador 2026-01-13 14:00:05 +01:00
parent ab3b55bcf8
commit 7d7387d8bb
55 changed files with 13889 additions and 3117 deletions

222
backend/autostart_pause.go Normal file
View File

@ -0,0 +1,222 @@
// backend\autostart_pause.go
package main
import (
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
)
var autostartPaused int32 // 0=false, 1=true
// --- SSE subscribers für Autostart-State ---
var autostartSubsMu sync.Mutex
var autostartSubs = map[chan bool]struct{}{}
func broadcastAutostartPaused(paused bool) {
autostartSubsMu.Lock()
defer autostartSubsMu.Unlock()
for ch := range autostartSubs {
// non-blocking: wenn Client langsam ist, droppen wir Updates (neuester Zustand kommt eh wieder)
select {
case ch <- paused:
default:
}
}
}
func isAutostartPaused() bool {
return atomic.LoadInt32(&autostartPaused) == 1
}
func setAutostartPaused(v bool) {
old := isAutostartPaused()
if v {
atomic.StoreInt32(&autostartPaused, 1)
} else {
atomic.StoreInt32(&autostartPaused, 0)
}
// nur wenn sich der Zustand wirklich geändert hat -> pushen
if old != v {
broadcastAutostartPaused(v)
}
}
type autostartPauseReq struct {
Paused *bool `json:"paused"`
}
func autostartPauseHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
return
case http.MethodPost:
// erlaubt: JSON body { "paused": true/false }
// oder Query ?paused=1/0/true/false
var val *bool
ct := strings.ToLower(r.Header.Get("Content-Type"))
if strings.Contains(ct, "application/json") {
var req autostartPauseReq
_ = json.NewDecoder(r.Body).Decode(&req)
val = req.Paused
}
if val == nil {
q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("paused")))
if q != "" {
b := q == "1" || q == "true" || q == "yes" || q == "on"
val = &b
}
}
if val == nil {
http.Error(w, "missing 'paused' (json body or query)", http.StatusBadRequest)
return
}
setAutostartPaused(*val)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
return
default:
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
return
}
}
func writeAutostartState(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"paused": isAutostartPaused(),
})
}
// GET /api/autostart/state
func autostartStateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
writeAutostartState(w)
}
// GET/POST /api/autostart/pause (POST ohne Body pausiert)
func autostartPauseQuickHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeAutostartState(w)
return
case http.MethodPost:
setAutostartPaused(true)
writeAutostartState(w)
return
default:
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
return
}
}
// POST /api/autostart/resume (optional auch GET)
func autostartResumeHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeAutostartState(w)
return
case http.MethodPost:
setAutostartPaused(false)
writeAutostartState(w)
return
default:
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
return
}
}
// GET /api/autostart/state/stream (SSE)
func autostartStateStreamHandler(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
}
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") // wichtig falls Proxy/Nginx
ch := make(chan bool, 1)
// subscribe
autostartSubsMu.Lock()
autostartSubs[ch] = struct{}{}
autostartSubsMu.Unlock()
// cleanup
defer func() {
autostartSubsMu.Lock()
delete(autostartSubs, ch)
autostartSubsMu.Unlock()
close(ch)
}()
send := func(paused bool) {
payload := map[string]any{
"paused": paused,
"ts": time.Now().UTC().Format(time.RFC3339Nano),
}
b, _ := json.Marshal(payload)
_, _ = io.WriteString(w, "event: autostart\n")
_, _ = io.WriteString(w, "data: ")
_, _ = w.Write(b)
_, _ = io.WriteString(w, "\n\n")
fl.Flush()
}
// initial state sofort senden
send(isAutostartPaused())
ctx := r.Context()
hb := time.NewTicker(15 * time.Second)
defer hb.Stop()
for {
select {
case <-ctx.Done():
return
case v := <-ch:
send(v)
case <-hb.C:
// heartbeat gegen Proxy timeouts
_, _ = io.WriteString(w, ": keep-alive\n\n")
fl.Flush()
}
}
}

View File

@ -96,6 +96,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
var lastStart time.Time var lastStart time.Time
for { for {
if isAutostartPaused() {
// optional: Queue behalten oder leeren ich würde sie behalten.
time.Sleep(2 * time.Second)
continue
}
s := getSettings() s := getSettings()
// ✅ Autostart nur wenn Feature aktiviert ist // ✅ Autostart nur wenn Feature aktiviert ist
// (optional zusätzlich AutoAddToDownloadList wie im Frontend logisch gekoppelt) // (optional zusätzlich AutoAddToDownloadList wie im Frontend logisch gekoppelt)

View File

@ -0,0 +1,243 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const chaturbateBioContextURLFmt = "https://chaturbate.com/api/biocontext/%s/"
// wie lange wir Biocontext ohne Refresh aus der DB zurückgeben
const bioCacheMaxAge = 10 * time.Minute
type BioContextResp struct {
Enabled bool `json:"enabled"`
FetchedAt time.Time `json:"fetchedAt"`
LastError string `json:"lastError,omitempty"`
Model string `json:"model"`
Bio any `json:"bio,omitempty"`
}
func sanitizeModelKey(s string) string {
s = strings.TrimSpace(strings.TrimPrefix(s, "@"))
// erlaubte chars: a-z A-Z 0-9 _ - .
s = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= '0' && r <= '9':
return r
case r == '_' || r == '-' || r == '.':
return r
default:
return -1
}
}, s)
return strings.TrimSpace(s)
}
func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
// ✅ Option A: Biocontext ist IMMER aktiv (unabhängig von UseChaturbateAPI)
enabled := true
model := sanitizeModelKey(r.URL.Query().Get("model"))
if model == "" {
http.Error(w, "model fehlt", http.StatusBadRequest)
return
}
refresh := strings.TrimSpace(r.URL.Query().Get("refresh"))
forceRefresh := refresh == "1" || strings.EqualFold(refresh, "true")
// 1) Cache aus ModelStore lesen (persistiert über Neustarts)
var (
cachedBio any
cachedAt time.Time
hasCache bool
)
if cbModelStore != nil {
raw, ts, ok, err := cbModelStore.GetBioContext("chaturbate.com", model)
if err == nil && ok {
var tmp any
if e := json.Unmarshal([]byte(raw), &tmp); e == nil {
cachedBio = tmp
hasCache = true
if t, e2 := time.Parse(time.RFC3339Nano, strings.TrimSpace(ts)); e2 == nil {
cachedAt = t
}
}
}
}
// 2) Wenn Cache frisch ist und kein Force-Refresh, geben wir ihn sofort zurück
if hasCache && !forceRefresh && !cachedAt.IsZero() && time.Since(cachedAt) < bioCacheMaxAge {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled,
FetchedAt: cachedAt,
LastError: "",
Model: model,
Bio: cachedBio,
})
return
}
// 3) Upstream abrufen
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
u := fmt.Sprintf(chaturbateBioContextURLFmt, model)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
http.Error(w, "request build failed: "+err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Referer", "https://chaturbate.com/"+model+"/")
// ✅ optional: Cookies vom Frontend (für Cloudflare / session)
if ck := strings.TrimSpace(r.Header.Get("X-Chaturbate-Cookie")); ck != "" {
req.Header.Set("Cookie", ck)
}
resp, err := cbHTTP.Do(req) // cbHTTP existiert schon in chaturbate_online.go
if err != nil {
// Fallback: Cache liefern (wenn vorhanden)
if hasCache {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled,
FetchedAt: cachedAt,
LastError: err.Error(),
Model: model,
Bio: cachedBio,
})
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled,
FetchedAt: time.Now().UTC(),
LastError: err.Error(),
Model: model,
})
return
}
defer resp.Body.Close()
// max 1MB
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// Helper: Body-Snippet für Debug
snip := strings.TrimSpace(string(raw))
if len(snip) > 400 {
snip = snip[:400] + "…"
}
if resp.StatusCode != 200 {
errMsg := fmt.Sprintf("HTTP %d (ct=%s): %s", resp.StatusCode, resp.Header.Get("Content-Type"), snip)
if hasCache {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled,
FetchedAt: cachedAt,
LastError: errMsg,
Model: model,
Bio: cachedBio,
})
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled,
FetchedAt: time.Now().UTC(),
LastError: errMsg,
Model: model,
})
return
}
// 4) JSON parse
var bio any
if err := json.Unmarshal(raw, &bio); err != nil {
ct := strings.ToLower(resp.Header.Get("Content-Type"))
lowerBody := strings.ToLower(snip)
// sehr häufig: HTML statt JSON (Cloudflare/Age-Gate/etc.)
htmlLike := strings.Contains(ct, "text/html") ||
strings.HasPrefix(strings.TrimSpace(lowerBody), "<!doctype") ||
strings.Contains(lowerBody, "<html") ||
strings.Contains(lowerBody, "just a moment") ||
strings.Contains(lowerBody, "cloudflare")
errMsg := ""
if htmlLike {
errMsg = fmt.Sprintf("upstream returned HTML (likely blocked/age-gate) (ct=%s): %s", resp.Header.Get("Content-Type"), snip)
} else {
errMsg = fmt.Sprintf("invalid json from upstream (ct=%s): %v; body: %s", resp.Header.Get("Content-Type"), err, snip)
}
// Fallback: Cache liefern (wenn vorhanden)
if hasCache {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled,
FetchedAt: cachedAt,
LastError: errMsg,
Model: model,
Bio: cachedBio,
})
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled,
FetchedAt: time.Now().UTC(),
LastError: errMsg,
Model: model,
})
return
}
fetchedAt := time.Now().UTC()
// 5) Persistieren
if cbModelStore != nil {
_ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano))
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled,
FetchedAt: fetchedAt,
LastError: "",
Model: model,
Bio: bio,
})
}

View File

@ -1,11 +1,16 @@
// backend\chaturbate_online.go
package main package main
import ( import (
"context" "context"
"crypto/sha1"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -46,24 +51,50 @@ type ChaturbateRoom struct {
Slug string `json:"slug"` Slug string `json:"slug"`
} }
// ✅ Was das Frontend wirklich braucht (viel kleiner & schneller zu marshalen)
type ChaturbateOnlineRoomLite struct {
Username string `json:"username"`
CurrentShow string `json:"current_show"`
ChatRoomURL string `json:"chat_room_url"`
ImageURL string `json:"image_url"`
}
type chaturbateCache struct { type chaturbateCache struct {
Rooms []ChaturbateRoom Rooms []ChaturbateRoom
FetchedAt time.Time RoomsByUser map[string]ChaturbateRoom
LastErr string
// ✅ Lite-Index für die Online-API Response
LiteByUser map[string]ChaturbateOnlineRoomLite
FetchedAt time.Time
LastAttempt time.Time // ✅ wichtig für Bootstrap-Cooldown (siehe Punkt 2)
LastErr string
} }
var ( var (
cbHTTP = &http.Client{Timeout: 12 * time.Second} cbHTTP = &http.Client{Timeout: 30 * time.Second}
cbMu sync.RWMutex cbMu sync.RWMutex
cb chaturbateCache cb chaturbateCache
// ✅ Optional: ModelStore, um Tags aus der Online-API zu übernehmen
cbModelStore *ModelStore
) )
var (
cbRefreshMu sync.Mutex
cbRefreshInFlight bool
)
// setChaturbateOnlineModelStore wird einmal beim Startup aufgerufen.
func setChaturbateOnlineModelStore(store *ModelStore) {
cbModelStore = store
}
func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) { func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, chaturbateOnlineRoomsURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, chaturbateOnlineRoomsURL, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// ein "normaler" UA reduziert manchmal Block/Rate-Limit Probleme
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
@ -78,30 +109,86 @@ func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) {
return nil, fmt.Errorf("chaturbate online rooms: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) return nil, fmt.Errorf("chaturbate online rooms: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
} }
data, err := io.ReadAll(resp.Body) dec := json.NewDecoder(resp.Body)
// Erwartet: JSON Array
tok, err := dec.Token()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if d, ok := tok.(json.Delim); !ok || d != '[' {
return nil, fmt.Errorf("chaturbate online rooms: expected JSON array")
}
var rooms []ChaturbateRoom rooms := make([]ChaturbateRoom, 0, 4096)
if err := json.Unmarshal(data, &rooms); err != nil { for dec.More() {
var rm ChaturbateRoom
if err := dec.Decode(&rm); err != nil {
return nil, err
}
rooms = append(rooms, rm)
}
// schließende ']' lesen
if _, err := dec.Token(); err != nil {
return nil, err return nil, err
} }
return rooms, nil return rooms, nil
} }
func indexRoomsByUser(rooms []ChaturbateRoom) map[string]ChaturbateRoom {
m := make(map[string]ChaturbateRoom, len(rooms))
for _, rm := range rooms {
u := strings.ToLower(strings.TrimSpace(rm.Username))
if u == "" {
continue
}
m[u] = rm
}
return m
}
func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite {
m := make(map[string]ChaturbateOnlineRoomLite, len(rooms))
for _, rm := range rooms {
u := strings.ToLower(strings.TrimSpace(rm.Username))
if u == "" {
continue
}
m[u] = ChaturbateOnlineRoomLite{
Username: rm.Username,
CurrentShow: rm.CurrentShow,
ChatRoomURL: rm.ChatRoomURL,
ImageURL: rm.ImageURL,
}
}
return m
}
// startChaturbateOnlinePoller pollt die API alle paar Sekunden, // startChaturbateOnlinePoller pollt die API alle paar Sekunden,
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist. // aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
func startChaturbateOnlinePoller() { // startChaturbateOnlinePoller pollt die API alle paar Sekunden,
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
func startChaturbateOnlinePoller(store *ModelStore) {
// ✅ etwas langsamer pollen (weniger Last)
const interval = 10 * time.Second const interval = 10 * time.Second
// nur loggen, wenn sich etwas ändert (sonst spammt es alle 5s) // ✅ Tags-Fill ist teuer -> max alle 10 Minuten
const tagsFillEvery = 10 * time.Minute
// nur loggen, wenn sich etwas ändert (sonst spammt es)
lastLoggedCount := -1 lastLoggedCount := -1
lastLoggedErr := "" lastLoggedErr := ""
// Tags-Fill Throttle (lokal in der Funktion)
var tagsMu sync.Mutex
var tagsLast time.Time
// sofort ein initialer Tick // sofort ein initialer Tick
first := time.NewTimer(0) first := time.NewTimer(0)
defer first.Stop() defer first.Stop()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
@ -115,18 +202,25 @@ func startChaturbateOnlinePoller() {
continue continue
} }
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) // ✅ immer merken: wir haben es versucht (hilft dem Handler beim Bootstrap-Cooldown)
cbMu.Lock()
cb.LastAttempt = time.Now()
cbMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
rooms, err := fetchChaturbateOnlineRooms(ctx) rooms, err := fetchChaturbateOnlineRooms(ctx)
cancel() cancel()
cbMu.Lock() cbMu.Lock()
if err != nil { if err != nil {
// ❗️WICHTIG: bei Fehler NICHT fetchedAt aktualisieren, // ❗️bei Fehler NICHT fetchedAt aktualisieren,
// sonst wirkt der Cache "frisch", obwohl rooms alt sind. // sonst wirkt der Cache "frisch", obwohl rooms alt sind.
cb.LastErr = err.Error() cb.LastErr = err.Error()
// ❗️Damit offline Models nicht hängen bleiben: rooms leeren // ❗️damit offline Models nicht hängen bleiben: Cache leeren
cb.Rooms = nil cb.Rooms = nil
cb.RoomsByUser = nil
cb.LiteByUser = nil
cbMu.Unlock() cbMu.Unlock()
@ -137,12 +231,31 @@ func startChaturbateOnlinePoller() {
continue continue
} }
// ✅ Erfolg: komplette Liste ersetzen + fetchedAt setzen // ✅ Erfolg: komplette Liste ersetzen + indices + fetchedAt setzen
cb.LastErr = "" cb.LastErr = ""
cb.Rooms = rooms cb.Rooms = rooms
cb.RoomsByUser = indexRoomsByUser(rooms)
cb.LiteByUser = indexLiteByUser(rooms)
cb.FetchedAt = time.Now() cb.FetchedAt = time.Now()
cbMu.Unlock() cbMu.Unlock()
// ✅ Tags übernehmen ist teuer -> nur selten + im Hintergrund
if cbModelStore != nil && len(rooms) > 0 {
shouldFill := false
tagsMu.Lock()
if tagsLast.IsZero() || time.Since(tagsLast) >= tagsFillEvery {
tagsLast = time.Now()
shouldFill = true
}
tagsMu.Unlock()
if shouldFill {
go cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
}
}
// success logging only on changes // success logging only on changes
if lastLoggedErr != "" { if lastLoggedErr != "" {
fmt.Println("✅ [chaturbate] online rooms fetch recovered") fmt.Println("✅ [chaturbate] online rooms fetch recovered")
@ -155,105 +268,313 @@ func startChaturbateOnlinePoller() {
} }
} }
var onlineCacheMu sync.Mutex
var onlineCache = map[string]struct {
at time.Time
body []byte
}{}
func cachedOnline(key string) ([]byte, bool) {
onlineCacheMu.Lock()
defer onlineCacheMu.Unlock()
e, ok := onlineCache[key]
if !ok {
return nil, false
}
if time.Since(e.at) > 2*time.Second { // TTL
delete(onlineCache, key)
return nil, false
}
return e.body, true
}
func setCachedOnline(key string, body []byte) {
onlineCacheMu.Lock()
onlineCache[key] = struct {
at time.Time
body []byte
}{at: time.Now(), body: body}
onlineCacheMu.Unlock()
}
type cbOnlineReq struct {
Q []string `json:"q"` // usernames
Show []string `json:"show"` // public/private/hidden/away
Refresh bool `json:"refresh"`
}
func hashKey(parts ...string) string {
h := sha1.New()
for _, p := range parts {
_, _ = h.Write([]byte(p))
_, _ = h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
return return
} }
enabled := getSettings().UseChaturbateAPI enabled := getSettings().UseChaturbateAPI
// ---------------------------
// Request params (GET/POST)
// ---------------------------
wantRefresh := false
var users []string
var shows []string
if r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, 8<<20)
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Body read failed", http.StatusBadRequest)
return
}
var req cbOnlineReq
if len(raw) > 0 {
if err := json.Unmarshal(raw, &req); err != nil {
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
return
}
}
wantRefresh = req.Refresh
// normalize users
seenU := map[string]bool{}
for _, u := range req.Q {
u = strings.ToLower(strings.TrimSpace(u))
if u == "" || seenU[u] {
continue
}
seenU[u] = true
users = append(users, u)
}
sort.Strings(users)
// normalize shows
seenS := map[string]bool{}
for _, s := range req.Show {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" || seenS[s] {
continue
}
seenS[s] = true
shows = append(shows, s)
}
sort.Strings(shows)
} else {
// GET (legacy)
qRefresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh")))
wantRefresh = qRefresh == "1" || qRefresh == "true" || qRefresh == "yes"
qUsers := strings.TrimSpace(r.URL.Query().Get("q"))
if qUsers != "" {
seenU := map[string]bool{}
for _, s := range strings.Split(qUsers, ",") {
u := strings.ToLower(strings.TrimSpace(s))
if u == "" || seenU[u] {
continue
}
seenU[u] = true
users = append(users, u)
}
sort.Strings(users)
}
showFilter := strings.TrimSpace(r.URL.Query().Get("show"))
if showFilter != "" {
seenS := map[string]bool{}
for _, s := range strings.Split(showFilter, ",") {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" || seenS[s] {
continue
}
seenS[s] = true
shows = append(shows, s)
}
sort.Strings(shows)
}
}
// ---------------------------
// Ultra-wichtig: niemals die komplette Affiliate-Liste ausliefern.
// Wenn keine Users angegeben sind -> leere Antwort (spart massiv CPU + JSON)
// ---------------------------
onlySpecificUsers := len(users) > 0
// show allow-set
allowedShow := map[string]bool{}
for _, s := range shows {
allowedShow[s] = true
}
// ---------------------------
// Response Cache (2s)
// ---------------------------
cacheKey := "cb_online:" + hashKey(
fmt.Sprintf("enabled=%v", enabled),
"users="+strings.Join(users, ","),
"show="+strings.Join(shows, ","),
fmt.Sprintf("refresh=%v", wantRefresh),
"lite=1",
)
if body, ok := cachedOnline(cacheKey); ok {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
return
}
// ---------------------------
// Disabled -> immer schnell
// ---------------------------
if !enabled { if !enabled {
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{
out := map[string]any{
"enabled": false, "enabled": false,
"fetchedAt": time.Time{}, "fetchedAt": time.Time{},
"count": 0, "count": 0,
"lastError": "", "lastError": "",
"rooms": []ChaturbateRoom{}, "rooms": []any{},
}) }
body, _ := json.Marshal(out)
setCachedOnline(cacheKey, body)
_, _ = w.Write(body)
return return
} }
// optional: ?refresh=1 triggert einen direkten Fetch (falls aktiviert) // ---------------------------
q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) // Snapshot Cache (nur Lite-Index nutzen)
wantRefresh := q == "1" || q == "true" || q == "yes" // ---------------------------
// Snapshot des Caches
cbMu.RLock() cbMu.RLock()
rooms := cb.Rooms
fetchedAt := cb.FetchedAt fetchedAt := cb.FetchedAt
lastErr := cb.LastErr lastErr := cb.LastErr
lastAttempt := cb.LastAttempt
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
cbMu.RUnlock() cbMu.RUnlock()
// Wenn aktiviert aber Cache noch nie gefüllt wurde, einmalig automatisch fetchen. // ---------------------------
// (Das verhindert das "count=0 / fetchedAt=0001" Verhalten direkt nach Neustart.) // Refresh/Bootstrap-Strategie:
const staleAfter = 20 * time.Second // - Handler blockiert NICHT auf Remote-Fetch (Performance!)
isStale := fetchedAt.IsZero() || time.Since(fetchedAt) > staleAfter // - wenn refresh=true: triggert einen Fetch (best effort), aber liefert sofort Cache/leer zurück
// - wenn Cache noch nie erfolgreich war: "warming up" + best-effort Bootstrap, mit Cooldown
// ---------------------------
const bootstrapCooldown = 8 * time.Second
if enabled && (wantRefresh || isStale) { needBootstrap := fetchedAt.IsZero()
shouldTriggerFetch :=
wantRefresh ||
(needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown)
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) if shouldTriggerFetch {
freshRooms, err := fetchChaturbateOnlineRooms(ctx) cbRefreshMu.Lock()
cancel() if cbRefreshInFlight {
cbMu.Lock() cbRefreshMu.Unlock()
if err != nil {
cb.LastErr = err.Error()
// ❗WICHTIG: keine alten rooms weitergeben
cb.Rooms = nil
// ❗FetchedAt NICHT aktualisieren (bleibt letzte erfolgreiche Zeit)
} else { } else {
cb.LastErr = "" cbRefreshInFlight = true
cb.Rooms = freshRooms cbRefreshMu.Unlock()
cb.FetchedAt = time.Now()
// attempt timestamp sofort setzen (damit 100 Requests nicht alle triggern)
cbMu.Lock()
cb.LastAttempt = time.Now()
cbMu.Unlock()
// ✅ background fetch (nicht blockieren)
go func() {
defer func() {
cbRefreshMu.Lock()
cbRefreshInFlight = false
cbRefreshMu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
rooms, err := fetchChaturbateOnlineRooms(ctx)
cancel()
cbMu.Lock()
if err != nil {
cb.LastErr = err.Error()
cb.Rooms = nil
cb.RoomsByUser = nil
cb.LiteByUser = nil
// fetchedAt NICHT ändern (bleibt letzte erfolgreiche Zeit)
} else {
cb.LastErr = ""
cb.Rooms = rooms
cb.RoomsByUser = indexRoomsByUser(rooms)
cb.LiteByUser = indexLiteByUser(rooms) // ✅ kleiner Index für Handler
cb.FetchedAt = time.Now()
}
cbMu.Unlock()
// Tags optional übernehmen (nur bei Erfolg)
if cbModelStore != nil && err == nil && len(rooms) > 0 {
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
}
}()
} }
rooms = cb.Rooms
fetchedAt = cb.FetchedAt
lastErr = cb.LastErr
cbMu.Unlock()
} }
// nil-slice vermeiden -> Frontend bekommt [] statt null // ---------------------------
if rooms == nil { // Rooms bauen (LITE, O(Anzahl requested Users))
rooms = []ChaturbateRoom{} // ---------------------------
type outRoom struct {
Username string `json:"username"`
CurrentShow string `json:"current_show"`
ChatRoomURL string `json:"chat_room_url"`
ImageURL string `json:"image_url"`
} }
// optional: ?show=public,private,hidden,away outRooms := make([]outRoom, 0, len(users))
showFilter := strings.TrimSpace(r.URL.Query().Get("show"))
if showFilter != "" { if onlySpecificUsers && liteByUser != nil {
allowed := map[string]bool{} for _, u := range users {
for _, s := range strings.Split(showFilter, ",") { rm, ok := liteByUser[u]
s = strings.ToLower(strings.TrimSpace(s)) if !ok {
if s != "" { continue
allowed[s] = true
} }
} // show filter
if len(allowed) > 0 { if len(allowedShow) > 0 {
filtered := make([]ChaturbateRoom, 0, len(rooms)) s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
for _, rm := range rooms { if !allowedShow[s] {
if allowed[strings.ToLower(strings.TrimSpace(rm.CurrentShow))] { continue
filtered = append(filtered, rm)
} }
} }
rooms = filtered outRooms = append(outRooms, outRoom{
Username: rm.Username,
CurrentShow: rm.CurrentShow,
ChatRoomURL: rm.ChatRoomURL,
ImageURL: rm.ImageURL,
})
} }
} }
// wenn noch nie erfolgreich gefetched: nicer error
if needBootstrap && lastErr == "" {
lastErr = "warming up"
}
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")
// Wir liefern ein kleines Meta-Objekt, damit du im UI sofort siehst, ob der Cache aktuell ist.
out := map[string]any{ out := map[string]any{
"enabled": enabled, "enabled": true,
"fetchedAt": fetchedAt, "fetchedAt": fetchedAt,
"count": len(rooms), "count": len(outRooms),
"lastError": lastErr, "lastError": lastErr,
"rooms": rooms, "rooms": outRooms, // ✅ klein & schnell
} }
_ = json.NewEncoder(w).Encode(out)
body, _ := json.Marshal(out)
setCachedOnline(cacheKey, body)
_, _ = w.Write(body)
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,6 +8,16 @@ require (
github.com/grafov/m3u8 v0.12.1 github.com/grafov/m3u8 v0.12.1
) )
require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
)
require ( require (
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
@ -15,6 +25,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shirou/gopsutil/v3 v3.24.5
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 // indirect github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.47.0 // indirect

View File

@ -6,20 +6,37 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s= github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ=
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@ -52,13 +69,16 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@ -90,6 +110,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=

File diff suppressed because it is too large Load Diff

View File

@ -104,17 +104,22 @@ func modelNameFromFilename(file string) string {
base := file[strings.LastIndex(file, "/")+1:] base := file[strings.LastIndex(file, "/")+1:]
base = strings.TrimSuffix(base, filepath.Ext(base)) base = strings.TrimSuffix(base, filepath.Ext(base))
// ✅ HOT Prefix beim Parsen ignorieren (case-insensitive)
if strings.HasPrefix(strings.ToUpper(base), "HOT ") {
base = strings.TrimSpace(base[4:])
}
if m := reModel.FindStringSubmatch(base); len(m) == 2 && strings.TrimSpace(m[1]) != "" { if m := reModel.FindStringSubmatch(base); len(m) == 2 && strings.TrimSpace(m[1]) != "" {
return m[1] return strings.TrimSpace(m[1])
} }
// fallback: bis zum letzten "_" (wie bisher) // fallback: bis zum letzten "_" (wie bisher)
if i := strings.LastIndex(base, "_"); i > 0 { if i := strings.LastIndex(base, "_"); i > 0 {
return base[:i] return strings.TrimSpace(base[:i])
} }
if base == "" { if base == "" {
return "—" return "—"
} }
return base return strings.TrimSpace(base)
} }
func modelsEnsureLoaded() error { func modelsEnsureLoaded() error {

View File

@ -1,3 +1,5 @@
// backend\models_api.go
package main package main
import ( import (
@ -128,13 +130,20 @@ func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult,
idx[strings.ToLower(strings.TrimSpace(h))] = i idx[strings.ToLower(strings.TrimSpace(h))] = i
} }
need := []string{"url", "last_stream", "tags", "watch"} need := []string{"url", "last_stream", "tags"}
for _, k := range need { for _, k := range need {
if _, ok := idx[k]; !ok { if _, ok := idx[k]; !ok {
return importResult{}, errors.New("CSV: Spalte fehlt: " + k) return importResult{}, errors.New("CSV: Spalte fehlt: " + k)
} }
} }
// ✅ watch ODER watched akzeptieren
if _, ok := idx["watch"]; !ok {
if _, ok2 := idx["watched"]; !ok2 {
return importResult{}, errors.New("CSV: Spalte fehlt: watch oder watched")
}
}
seen := map[string]bool{} seen := map[string]bool{}
out := importResult{} out := importResult{}
@ -171,6 +180,10 @@ func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult,
lastStream := get("last_stream") lastStream := get("last_stream")
watchStr := get("watch") watchStr := get("watch")
if watchStr == "" {
watchStr = get("watched")
}
watch := false watch := false
if watchStr != "" { if watchStr != "" {
if n, err := strconv.Atoi(watchStr); err == nil { if n, err := strconv.Atoi(watchStr); err == nil {
@ -274,19 +287,24 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
var req struct { var req struct {
ModelKey string `json:"modelKey"` ModelKey string `json:"modelKey"`
Host string `json:"host,omitempty"`
} }
if err := modelsReadJSON(r, &req); err != nil { if err := modelsReadJSON(r, &req); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
key := strings.TrimSpace(req.ModelKey) key := strings.TrimSpace(req.ModelKey)
host := strings.ToLower(strings.TrimSpace(req.Host))
host = strings.TrimPrefix(host, "www.")
if key == "" { if key == "" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "modelKey fehlt"}) modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "modelKey fehlt"})
return return
} }
m, err := store.EnsureByModelKey(key) m, err := store.EnsureByHostModelKey(host, key)
if err != nil { if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
@ -338,11 +356,38 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
// ✅ id optional: wenn fehlt -> per (host, modelKey) sicherstellen + id setzen
if strings.TrimSpace(req.ID) == "" {
key := strings.TrimSpace(req.ModelKey)
host := strings.TrimSpace(req.Host)
if key == "" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id oder modelKey fehlt"})
return
}
ensured, err := store.EnsureByHostModelKey(host, key) // host darf leer sein
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
req.ID = ensured.ID
}
m, err := store.PatchFlags(req) m, err := store.PatchFlags(req)
if err != nil { if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
// ✅ Wenn ein Model weder beobachtet noch favorisiert/geliked ist, fliegt es aus dem Store.
// (Damit bleibt der Store „sauber“ und ModelsTab listet nur relevante Einträge.)
likedOn := (m.Liked != nil && *m.Liked)
if !m.Watching && !m.Favorite && !likedOn {
_ = store.Delete(m.ID) // best-effort: Patch war erfolgreich, Delete darf hier nicht „fatal“ sein
w.WriteHeader(http.StatusNoContent)
return
}
modelsWriteJSON(w, http.StatusOK, m) modelsWriteJSON(w, http.StatusOK, m)
}) })

View File

@ -58,13 +58,13 @@ type ParsedModelDTO struct {
} }
type ModelFlagsPatch struct { type ModelFlagsPatch struct {
ID string `json:"id"` Host string `json:"host,omitempty"` // ✅ neu
Watching *bool `json:"watching,omitempty"` ModelKey string `json:"modelKey,omitempty"` // ✅ wenn id fehlt
Favorite *bool `json:"favorite,omitempty"` ID string `json:"id,omitempty"` // ✅ optional
Hot *bool `json:"hot,omitempty"`
Keep *bool `json:"keep,omitempty"` Watched *bool `json:"watched,omitempty"`
Liked *bool `json:"liked,omitempty"` Favorite *bool `json:"favorite,omitempty"`
ClearLiked bool `json:"clearLiked,omitempty"` Liked *bool `json:"liked,omitempty"`
} }
type ModelStore struct { type ModelStore struct {
@ -79,6 +79,71 @@ type ModelStore struct {
mu sync.Mutex mu sync.Mutex
} }
func (s *ModelStore) EnsureByHostModelKey(host, modelKey string) (StoredModel, error) {
if err := s.ensureInit(); err != nil {
return StoredModel{}, err
}
key := strings.TrimSpace(modelKey)
if key == "" {
return StoredModel{}, errors.New("modelKey fehlt")
}
h := canonicalHost(host)
// host optional: wenn leer -> fallback auf bisherigen Weg (best match über alle Hosts)
if h == "" {
return s.EnsureByModelKey(key)
}
// 1) explizit host+key suchen
var existingID string
err := s.db.QueryRow(`
SELECT id
FROM models
WHERE lower(trim(host)) = lower(trim(?))
AND lower(trim(model_key)) = lower(trim(?))
LIMIT 1;
`, h, key).Scan(&existingID)
if err == nil && existingID != "" {
return s.getByID(existingID)
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return StoredModel{}, err
}
// 2) nicht vorhanden -> "manual" anlegen (is_url=0, input=modelKey), ABER host gesetzt
now := time.Now().UTC().Format(time.RFC3339Nano)
id := canonicalID(h, key)
s.mu.Lock()
defer s.mu.Unlock()
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
model_key=excluded.model_key,
host=excluded.host,
updated_at=excluded.updated_at;
`,
id, key, int64(0), h, "", key,
"", "",
int64(0), int64(0), int64(0), int64(0), nil,
now, now,
)
if err != nil {
return StoredModel{}, err
}
return s.getByID(id)
}
// EnsureByModelKey: // EnsureByModelKey:
// - liefert ein bestehendes Model (best match) wenn vorhanden // - liefert ein bestehendes Model (best match) wenn vorhanden
// - sonst legt es ein "manual" Model ohne URL an (Input=modelKey, IsURL=false) // - sonst legt es ein "manual" Model ohne URL an (Input=modelKey, IsURL=false)
@ -94,15 +159,20 @@ func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) {
return StoredModel{}, errors.New("modelKey fehlt") return StoredModel{}, errors.New("modelKey fehlt")
} }
// Erst schauen ob es das Model schon gibt (egal welcher Host)
// Erst schauen ob es das Model schon gibt (egal welcher Host) // Erst schauen ob es das Model schon gibt (egal welcher Host)
var existingID string var existingID string
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT id SELECT id
FROM models FROM models
WHERE lower(model_key) = lower(?) WHERE lower(trim(model_key)) = lower(trim(?))
ORDER BY favorite DESC, updated_at DESC ORDER BY
LIMIT 1; CASE WHEN is_url=1 THEN 1 ELSE 0 END DESC,
`, key).Scan(&existingID) CASE WHEN host IS NOT NULL AND trim(host)<>'' THEN 1 ELSE 0 END DESC,
favorite DESC,
updated_at DESC
LIMIT 1;
`, key).Scan(&existingID)
if err == nil && existingID != "" { if err == nil && existingID != "" {
return s.getByID(existingID) return s.getByID(existingID)
@ -141,6 +211,52 @@ ON CONFLICT(id) DO UPDATE SET
return s.getByID(id) return s.getByID(id)
} }
func (s *ModelStore) FillMissingTagsFromChaturbateOnline(rooms []ChaturbateRoom) {
if err := s.ensureInit(); err != nil {
return
}
if len(rooms) == 0 {
return
}
now := time.Now().UTC().Format(time.RFC3339Nano)
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.Prepare(`
UPDATE models
SET tags = ?, updated_at = ?
WHERE lower(trim(host)) = 'chaturbate.com'
AND lower(trim(model_key)) = lower(trim(?))
AND (tags IS NULL OR trim(tags) = '');
`)
if err != nil {
return
}
defer stmt.Close()
for _, rm := range rooms {
key := strings.TrimSpace(rm.Username)
if key == "" || len(rm.Tags) == 0 {
continue
}
tags := strings.TrimSpace(strings.Join(rm.Tags, ", "))
if tags == "" {
continue
}
_, _ = stmt.Exec(tags, now, key)
}
_ = tx.Commit()
}
// Backwards compatible: // Backwards compatible:
// - wenn du ".json" übergibst (wie aktuell in main.go), wird daraus automatisch ".db" // - wenn du ".json" übergibst (wie aktuell in main.go), wird daraus automatisch ".db"
// und die JSON-Datei wird als Legacy-Quelle für die 1x Migration genutzt. // und die JSON-Datei wird als Legacy-Quelle für die 1x Migration genutzt.
@ -188,7 +304,9 @@ func (s *ModelStore) init() error {
return err return err
} }
// SQLite am besten single-conn im Server-Prozess // SQLite am besten single-conn im Server-Prozess
db.SetMaxOpenConns(1) db.SetMaxOpenConns(5)
db.SetMaxIdleConns(5)
_, _ = db.Exec(`PRAGMA busy_timeout = 2500;`)
// Pragmas (einzeln ausführen) // Pragmas (einzeln ausführen)
_, _ = db.Exec(`PRAGMA foreign_keys = ON;`) _, _ = db.Exec(`PRAGMA foreign_keys = ON;`)
@ -215,6 +333,11 @@ func (s *ModelStore) init() error {
} }
} }
// ✅ beim Einlesen normalisieren
if err := s.normalizeNameOnlyChaturbate(); err != nil {
return err
}
return nil return nil
} }
@ -230,6 +353,8 @@ CREATE TABLE IF NOT EXISTS models (
tags TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '',
last_stream TEXT, last_stream TEXT,
biocontext_json TEXT,
biocontext_fetched_at TEXT,
watching INTEGER NOT NULL DEFAULT 0, watching INTEGER NOT NULL DEFAULT 0,
favorite INTEGER NOT NULL DEFAULT 0, favorite INTEGER NOT NULL DEFAULT 0,
@ -245,7 +370,6 @@ CREATE TABLE IF NOT EXISTS models (
return err return err
} }
// optionaler Unique-Index (hilft bei Konsistenz)
_, _ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_models_host_key ON models(host, model_key);`) _, _ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_models_host_key ON models(host, model_key);`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_models_updated ON models(updated_at);`) _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_models_updated ON models(updated_at);`)
return nil return nil
@ -281,6 +405,19 @@ func ensureModelsColumns(db *sql.DB) error {
return err return err
} }
} }
// ✅ Biocontext (persistente Bio-Infos)
if !cols["biocontext_json"] {
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN biocontext_json TEXT;`); err != nil {
return err
}
}
if !cols["biocontext_fetched_at"] {
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN biocontext_fetched_at TEXT;`); err != nil {
return err
}
}
return nil return nil
} }
@ -321,6 +458,104 @@ func ptrLikedFromNull(n sql.NullInt64) *bool {
return &v return &v
} }
// --- Biocontext Cache (persistente Bio-Infos aus Chaturbate) ---
// GetBioContext liefert das zuletzt gespeicherte Biocontext-JSON (+ Zeitstempel).
// ok=false wenn nichts gespeichert ist.
func (s *ModelStore) GetBioContext(host, modelKey string) (jsonStr string, fetchedAt string, ok bool, err error) {
if err := s.ensureInit(); err != nil {
return "", "", false, err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return "", "", false, errors.New("host/modelKey fehlt")
}
var js sql.NullString
var ts sql.NullString
err = s.db.QueryRow(`
SELECT biocontext_json, biocontext_fetched_at
FROM models
WHERE lower(trim(host)) = lower(trim(?))
AND lower(trim(model_key)) = lower(trim(?))
LIMIT 1;
`, host, key).Scan(&js, &ts)
if errors.Is(err, sql.ErrNoRows) {
return "", "", false, nil
}
if err != nil {
return "", "", false, err
}
val := strings.TrimSpace(js.String)
if val == "" {
return "", strings.TrimSpace(ts.String), false, nil
}
return val, strings.TrimSpace(ts.String), true, nil
}
// SetBioContext speichert/aktualisiert das Biocontext-JSON dauerhaft in der DB.
// Es legt das Model (host+modelKey) bei Bedarf minimal an.
func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return errors.New("host/modelKey fehlt")
}
js := strings.TrimSpace(jsonStr)
ts := strings.TrimSpace(fetchedAt)
now := time.Now().UTC().Format(time.RFC3339Nano)
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`
UPDATE models
SET biocontext_json=?, biocontext_fetched_at=?, updated_at=?
WHERE lower(trim(host)) = lower(trim(?))
AND lower(trim(model_key)) = lower(trim(?));
`, js, ts, now, host, key)
if err != nil {
return err
}
aff, _ := res.RowsAffected()
if aff > 0 {
return nil
}
// Model existiert noch nicht -> minimal anlegen (als URL)
id := canonicalID(host, key)
input := "https://" + host + "/" + key + "/"
path := "/" + key + "/"
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
biocontext_json,biocontext_fetched_at,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
biocontext_json=excluded.biocontext_json,
biocontext_fetched_at=excluded.biocontext_fetched_at,
updated_at=excluded.updated_at;
`, id, input, int64(1), host, path, key,
"", "",
js, ts,
int64(0), int64(0), int64(0), int64(0), nil,
now, now,
)
return err
}
func (s *ModelStore) migrateFromJSONIfEmpty() error { func (s *ModelStore) migrateFromJSONIfEmpty() error {
// DB leer? // DB leer?
var cnt int var cnt int
@ -433,20 +668,172 @@ func bytesTrimSpace(b []byte) []byte {
return []byte(strings.TrimSpace(string(b))) return []byte(strings.TrimSpace(string(b)))
} }
func (s *ModelStore) normalizeNameOnlyChaturbate() error {
// Kandidaten: is_url=0 UND input==model_key UND host leer oder schon chaturbate
rows, err := s.db.Query(`
SELECT
id, model_key,
tags, COALESCE(last_stream,''),
watching,favorite,hot,keep,liked,
created_at,updated_at
FROM models
WHERE is_url = 0
AND lower(trim(input)) = lower(trim(model_key))
AND (host IS NULL OR trim(host)='' OR lower(trim(host))='chaturbate.com');
`)
if err != nil {
return err
}
defer rows.Close()
type rowT struct {
oldID, key, tags, lastStream, createdAt, updatedAt string
watching, favorite, hot, keep int64
liked sql.NullInt64
}
var items []rowT
for rows.Next() {
var r rowT
if err := rows.Scan(
&r.oldID, &r.key,
&r.tags, &r.lastStream,
&r.watching, &r.favorite, &r.hot, &r.keep, &r.liked,
&r.createdAt, &r.updatedAt,
); err != nil {
continue
}
r.key = strings.TrimSpace(r.key)
if r.key == "" || strings.TrimSpace(r.oldID) == "" {
continue
}
items = append(items, r)
}
if len(items) == 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
const host = "chaturbate.com"
for _, it := range items {
newInput := "https://" + host + "/" + it.key + "/"
newPath := "/" + it.key + "/"
// Ziel-Datensatz: wenn bereits chaturbate.com:<key> existiert, dorthin mergen
var targetID string
err := tx.QueryRow(`
SELECT id
FROM models
WHERE lower(trim(host)) = lower(?) AND lower(trim(model_key)) = lower(?)
LIMIT 1;
`, host, it.key).Scan(&targetID)
if errors.Is(err, sql.ErrNoRows) {
targetID = ""
err = nil
}
if err != nil {
return err
}
var likedArg any
if it.liked.Valid {
likedArg = it.liked.Int64
} else {
likedArg = nil
}
// Wenn es keinen Ziel-Datensatz gibt: neu anlegen mit canonical ID
if targetID == "" {
targetID = canonicalID(host, it.key)
_, err = tx.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
`,
targetID, newInput, int64(1), host, newPath, it.key,
it.tags, it.lastStream,
it.watching, it.favorite, it.hot, it.keep, likedArg,
it.createdAt, it.updatedAt,
)
if err != nil {
return err
}
} else {
// Ziel existiert: Flags mergen + fehlende Felder auffüllen
_, err = tx.Exec(`
UPDATE models SET
input = CASE
WHEN is_url=0 OR input IS NULL OR trim(input)='' OR lower(trim(input))=lower(trim(model_key))
THEN ? ELSE input END,
is_url = CASE WHEN is_url=0 THEN 1 ELSE is_url END,
host = CASE WHEN host IS NULL OR trim(host)='' THEN ? ELSE host END,
path = CASE WHEN path IS NULL OR trim(path)='' THEN ? ELSE path END,
tags = CASE WHEN (tags IS NULL OR tags='') AND ?<>'' THEN ? ELSE tags END,
last_stream = CASE WHEN (last_stream IS NULL OR last_stream='') AND ?<>'' THEN ? ELSE last_stream END,
watching = CASE WHEN ?=1 THEN 1 ELSE watching END,
favorite = CASE WHEN ?=1 THEN 1 ELSE favorite END,
hot = CASE WHEN ?=1 THEN 1 ELSE hot END,
keep = CASE WHEN ?=1 THEN 1 ELSE keep END,
liked = CASE WHEN liked IS NULL AND ? IS NOT NULL THEN ? ELSE liked END,
updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END
WHERE id = ?;
`,
newInput, host, newPath,
it.tags, it.tags,
it.lastStream, it.lastStream,
it.watching, it.favorite, it.hot, it.keep,
likedArg, likedArg,
it.updatedAt, it.updatedAt,
targetID,
)
if err != nil {
return err
}
}
// alten "manual" Datensatz löschen (nur wenn anderer Ziel-Datensatz)
if it.oldID != targetID {
if _, err := tx.Exec(`DELETE FROM models WHERE id=?;`, it.oldID); err != nil {
return err
}
}
}
return tx.Commit()
}
func (s *ModelStore) List() []StoredModel { func (s *ModelStore) List() []StoredModel {
if err := s.ensureInit(); err != nil { if err := s.ensureInit(); err != nil {
return []StoredModel{} return []StoredModel{}
} }
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT SELECT
id,input,is_url,host,path,model_key, id,input,is_url,host,path,model_key,
tags, COALESCE(last_stream,''), tags, COALESCE(last_stream,''),
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at,updated_at created_at,updated_at
FROM models FROM models
ORDER BY updated_at DESC; ORDER BY updated_at DESC;
`) `)
if err != nil { if err != nil {
return []StoredModel{} return []StoredModel{}
} }
@ -643,31 +1030,31 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
return StoredModel{}, err return StoredModel{}, err
} }
if patch.Watching != nil { // ✅ watched -> watching (DB)
watching = boolToInt(*patch.Watching) if patch.Watched != nil {
watching = boolToInt(*patch.Watched)
} }
if patch.Favorite != nil { if patch.Favorite != nil {
favorite = boolToInt(*patch.Favorite) favorite = boolToInt(*patch.Favorite)
} }
if patch.Hot != nil {
hot = boolToInt(*patch.Hot) // ✅ liked ist true/false (kein ClearLiked mehr)
if patch.Liked != nil {
liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)}
} }
if patch.Keep != nil {
keep = boolToInt(*patch.Keep) // ✅ Exklusivität serverseitig (robust):
} // - liked=true => favorite=false
// ✅ Business-Rule (robust, auch wenn Frontend es mal nicht mitsendet): // - favorite=true => liked=false (nicht NULL)
// - Liked=true => Favorite=false
// - Favorite=true => Liked wird gelöscht (NULL)
if patch.Liked != nil && *patch.Liked { if patch.Liked != nil && *patch.Liked {
favorite = int64(0) favorite = int64(0)
} }
if patch.Favorite != nil && *patch.Favorite { if patch.Favorite != nil && *patch.Favorite {
liked = sql.NullInt64{Valid: false} // Wenn Frontend nicht explizit liked=true sendet, force liked=false
} if patch.Liked == nil || !*patch.Liked {
if patch.ClearLiked { liked = sql.NullInt64{Valid: true, Int64: 0}
liked = sql.NullInt64{Valid: false} }
} else if patch.Liked != nil {
liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)}
} }
now := time.Now().UTC().Format(time.RFC3339Nano) now := time.Now().UTC().Format(time.RFC3339Nano)

View File

@ -0,0 +1,185 @@
package main
import (
"os"
"strings"
"time"
)
// Startet watched MyFreeCams Models (ohne API) "best-effort".
// Wenn nach kurzer Zeit keine Output-Datei existiert (oder 0 Bytes), wird abgebrochen und der Job wieder entfernt.
func startMyFreeCamsAutoStartWorker(store *ModelStore) {
if store == nil {
return
}
// pro Model: Retry-Cooldown, damit du nicht permanent die gleichen Models spamst
const cooldown = 2 * time.Minute
// wie lange wir nach Start warten, ob eine Datei entsteht
const outputProbeMax = 12 * time.Second
lastAttempt := map[string]time.Time{}
tick := time.NewTicker(6 * time.Second)
defer tick.Stop()
for range tick.C {
s := getSettings()
if !s.UseMyFreeCamsWatcher {
continue
}
// watched Models aus DB
watched := store.ListWatchedLite("myfreecams.com")
if len(watched) == 0 {
continue
}
// langsam nacheinander starten (keine API -> einzelnes "Anprobieren")
for _, m := range watched {
// ✅ Wenn User den Switch während eines Ticks deaktiviert, sofort stoppen
if !getSettings().UseMyFreeCamsWatcher {
break
}
// ✅ Wenn im UI "Alle Stoppen" -> Autostart pausiert, sofort aufhören
if isAutostartPaused() {
break
}
u := strings.TrimSpace(m.Input)
if u == "" {
continue
}
modelID := strings.TrimSpace(m.ID)
if modelID == "" {
// Fallback
modelID = strings.TrimSpace(m.Host) + ":" + strings.TrimSpace(m.ModelKey)
}
// Cooldown
if t, ok := lastAttempt[modelID]; ok && time.Since(t) < cooldown {
continue
}
// bereits als Job aktiv?
if isJobRunningForURL(u) {
continue
}
lastAttempt[modelID] = time.Now()
job, err := startRecordingInternal(RecordRequest{URL: u, Hidden: true})
if err != nil || job == nil {
continue
}
// Output prüfen: wenn nichts entsteht -> abbrechen + aus jobs entfernen
go mfcAbortIfNoOutput(job.ID, outputProbeMax)
// kleine Pause, damit du nicht 20 Models in einem Tick startest
time.Sleep(1200 * time.Millisecond)
}
}
}
func isJobRunningForURL(u string) bool {
u = strings.TrimSpace(u)
if u == "" {
return false
}
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == u {
return true
}
}
return false
}
// Wenn nach maxWait keine Output-Datei (>0 Bytes) existiert, stoppen + Job entfernen.
// Hintergrund: bei MFC kann "offline/away/private" sein => keine Ausgabe entsteht.
func mfcAbortIfNoOutput(jobID string, maxWait time.Duration) {
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
jobsMu.Lock()
job := jobs[jobID]
status := JobStatus("")
out := ""
if job != nil {
status = job.Status
out = strings.TrimSpace(job.Output)
}
jobsMu.Unlock()
// Job schon weg oder nicht mehr running -> nix tun
if job == nil || status != JobRunning {
return
}
// Output schon da?
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
// ✅ jetzt ist es ein "echter" Download -> im UI sichtbar machen
publishJob(jobID)
return
}
}
time.Sleep(900 * time.Millisecond)
}
// nach Wartezeit immer noch keine Datei => stoppen + löschen
jobsMu.Lock()
job := jobs[jobID]
if job == nil || job.Status != JobRunning {
jobsMu.Unlock()
return
}
// Snapshot: was wir ohne Lock beenden können
pc := job.previewCmd
job.previewCmd = nil
cancel := job.cancel
out := strings.TrimSpace(job.Output)
jobsMu.Unlock()
// preview kill
if pc != nil && pc.Process != nil {
_ = pc.Process.Kill()
}
// recording cancel
if cancel != nil {
cancel()
}
// 0-Byte Datei ggf. wegwerfen (damit "leere Starts" nicht im recordDir liegen bleiben)
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() == 0 {
_ = os.Remove(out)
}
}
// Job aus der Liste entfernen (UI bleibt sauber)
jobsMu.Lock()
j := jobs[jobID]
wasVisible := (j != nil && !j.Hidden)
delete(jobs, jobID)
jobsMu.Unlock()
// ✅ wenn der Job nie sichtbar war, nicht unnötig UI refreshen
if wasVisible {
notifyJobsChanged()
}
}

Binary file not shown.

View File

@ -1,3 +1,5 @@
// backend\sharedelete_other.go
//go:build !windows //go:build !windows
package main package main

View File

@ -1,3 +1,5 @@
// backend\sharedelete_windows.go
//go:build windows //go:build windows
package main package main

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.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>frontend</title>
<script type="module" crossorigin src="/assets/index-zKk-xTZ_.js"></script> <script type="module" crossorigin src="/assets/index-jMGU1_s9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ZZZa38Qs.css"> <link rel="stylesheet" crossorigin href="/assets/index-ie8TR6qH.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -42,8 +42,8 @@ const sizeMap: Record<Size, string> = {
const colorMap: Record<Color, Record<Variant, string>> = { const colorMap: Record<Color, Record<Variant, string>> = {
indigo: { indigo: {
primary: primary:
'bg-indigo-600 text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-indigo-600 ' + '!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' +
'dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500', 'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
@ -51,10 +51,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' + 'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30', 'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30',
}, },
blue: { blue: {
primary: primary:
'bg-blue-600 text-white shadow-xs hover:bg-blue-500 focus-visible:outline-blue-600 ' + '!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' +
'dark:bg-blue-500 dark:shadow-none dark:hover:bg-blue-400 dark:focus-visible:outline-blue-500', 'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
@ -62,10 +63,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'bg-blue-50 text-blue-600 shadow-xs hover:bg-blue-100 ' + 'bg-blue-50 text-blue-600 shadow-xs hover:bg-blue-100 ' +
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:hover:bg-blue-500/30', 'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:hover:bg-blue-500/30',
}, },
emerald: { emerald: {
primary: primary:
'bg-emerald-600 text-white shadow-xs hover:bg-emerald-500 focus-visible:outline-emerald-600 ' + '!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' +
'dark:bg-emerald-500 dark:shadow-none dark:hover:bg-emerald-400 dark:focus-visible:outline-emerald-500', 'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
@ -73,10 +75,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'bg-emerald-50 text-emerald-700 shadow-xs hover:bg-emerald-100 ' + 'bg-emerald-50 text-emerald-700 shadow-xs hover:bg-emerald-100 ' +
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:hover:bg-emerald-500/30', 'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:hover:bg-emerald-500/30',
}, },
red: { red: {
primary: primary:
'bg-red-600 text-white shadow-xs hover:bg-red-500 focus-visible:outline-red-600 ' + '!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' +
'dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400 dark:focus-visible:outline-red-500', 'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
@ -84,10 +87,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'bg-red-50 text-red-700 shadow-xs hover:bg-red-100 ' + 'bg-red-50 text-red-700 shadow-xs hover:bg-red-100 ' +
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:hover:bg-red-500/30', 'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:hover:bg-red-500/30',
}, },
amber: { amber: {
primary: primary:
'bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:outline-amber-500 ' + '!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' +
'dark:bg-amber-500 dark:shadow-none dark:hover:bg-amber-400 dark:focus-visible:outline-amber-500', 'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
@ -100,8 +104,22 @@ const colorMap: Record<Color, Record<Variant, string>> = {
function Spinner() { function Spinner() {
return ( return (
<svg viewBox="0 0 24 24" className="size-4 animate-spin" aria-hidden="true"> <svg viewBox="0 0 24 24" className="size-4 animate-spin" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="4" opacity="0.25" /> <circle
<path d="M22 12a10 10 0 0 1-10 10" fill="none" stroke="currentColor" strokeWidth="4" opacity="0.9" /> cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
strokeWidth="4"
opacity="0.25"
/>
<path
d="M22 12a10 10 0 0 1-10 10"
fill="none"
stroke="currentColor"
strokeWidth="4"
opacity="0.9"
/>
</svg> </svg>
) )
} }

View File

@ -57,30 +57,36 @@ export default function ButtonGroup({
onClick={() => onChange(it.id)} onClick={() => onChange(it.id)}
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
'relative inline-flex items-center font-semibold focus:z-10', 'relative inline-flex items-center justify-center font-semibold focus:z-10 transition-colors',
!isFirst && '-ml-px', !isFirst && '-ml-px',
isFirst && 'rounded-l-md', isFirst && 'rounded-l-md',
isLast && 'rounded-r-md', isLast && 'rounded-r-md',
// Base (wie im TailwindUI Beispiel) // Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50', active
'dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20', ? 'bg-indigo-100 text-indigo-800 inset-ring-1 inset-ring-indigo-300 hover:bg-indigo-200 ' +
'dark:bg-indigo-500/40 dark:text-indigo-100 dark:inset-ring-indigo-400/50 dark:hover:bg-indigo-500/50'
// Active-Style (dezente Hervorhebung) : 'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 ' +
active && 'bg-gray-50 dark:bg-white/20', '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',
// Padding / Größe // Padding / Größe
iconOnly ? 'px-2 py-2 text-gray-400 dark:text-gray-300' : s.btn iconOnly ? 'px-2 py-2' : s.btn
)} )}
title={typeof it.label === 'string' ? it.label : it.srLabel} title={typeof it.label === 'string' ? it.label : it.srLabel}
> >
{iconOnly && it.srLabel ? <span className="sr-only">{it.srLabel}</span> : null} {iconOnly && it.srLabel ? <span className="sr-only">{it.srLabel}</span> : null}
{it.icon ? ( {it.icon ? (
<span className={cn('shrink-0', iconOnly ? '' : '-ml-0.5 text-gray-400 dark:text-gray-500')}> <span
className={cn(
'shrink-0',
iconOnly ? '' : '-ml-0.5',
active ? 'text-indigo-600 dark:text-indigo-200' : 'text-gray-400 dark:text-gray-500'
)}
>
{it.icon} {it.icon}
</span> </span>
) : null} ) : null}

View File

@ -71,13 +71,13 @@ export default function CookieModal({
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Name (z. B. cf_clearance)" placeholder="Name (z. B. cf_clearance)"
className="col-span-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white" className="col-span-1 truncate rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
/> />
<input <input
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder="Wert" placeholder="Wert"
className="col-span-1 sm:col-span-2 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white" className="col-span-1 truncate sm:col-span-2 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
/> />
</div> </div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\FinishedDownloadsCardsView.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -5,15 +7,15 @@ import Card from './Card'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import SwipeCard, { type SwipeCardHandle } from './SwipeCard' import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
import { flushSync } from 'react-dom'
import { import {
TrashIcon, StarIcon as StarSolidIcon,
FireIcon, HeartIcon as HeartSolidIcon,
BookmarkSquareIcon, EyeIcon as EyeSolidIcon,
StarIcon as StarOutlineIcon, } from '@heroicons/react/24/solid'
HeartIcon as HeartOutlineIcon, import TagBadge from './TagBadge'
} from '@heroicons/react/24/outline' import RecordJobActions from './RecordJobActions'
import { StarIcon as StarSolidIcon, HeartIcon as HeartSolidIcon } from '@heroicons/react/24/solid' import LazyMount from './LazyMount'
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
@ -24,6 +26,9 @@ type InlinePlayState = { key: string; nonce: number } | null
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
isSmall: boolean isSmall: boolean
teaserPlayback: 'still' | 'hover' | 'all'
teaserAudio?: boolean
hoverTeaserKey?: string | null
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
@ -48,6 +53,7 @@ type Props = {
lower: (s: string) => string lower: (s: string) => string
// callbacks/actions // callbacks/actions
onHoverPreviewKeyChange?: (key: string | null) => void
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
openPlayer: (job: RecordJob) => void openPlayer: (job: RecordJob) => void
startInline: (key: string) => void startInline: (key: string) => void
@ -61,17 +67,42 @@ type Props = {
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void> releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null }> modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void
onToggleHot?: (job: RecordJob) => void | Promise<void> onToggleHot?: (job: RecordJob) => void | Promise<void>
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>
} }
const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
return out
}
export default function FinishedDownloadsCardsView({ export default function FinishedDownloadsCardsView({
rows, rows,
isSmall, isSmall,
teaserPlayback,
teaserAudio,
hoverTeaserKey,
blurPreviews, blurPreviews,
durations, durations,
teaserKey, teaserKey,
@ -93,6 +124,7 @@ export default function FinishedDownloadsCardsView({
formatBytes, formatBytes,
lower, lower,
onHoverPreviewKeyChange,
onOpenPlayer, onOpenPlayer,
openPlayer, openPlayer,
startInline, startInline,
@ -107,16 +139,57 @@ export default function FinishedDownloadsCardsView({
releasePlayingFile, releasePlayingFile,
modelsByKey, modelsByKey,
activeTagSet,
onToggleTagFilter,
onToggleHot, onToggleHot,
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch
}: Props) { }: Props) {
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null)
const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!openTagsKey) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenTagsKey(null)
}
const onPointerDown = (e: PointerEvent) => {
const el = tagsPopoverRef.current
if (!el) return
if (el.contains(e.target as Node)) return
setOpenTagsKey(null)
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('pointerdown', onPointerDown)
}
}, [openTagsKey])
React.useEffect(() => {
if (!openTagsKey) return
// Falls Job aus der Liste verschwindet → Popover schließen
const exists = rows.some((j) => keyFor(j) === openTagsKey)
if (!exists) setOpenTagsKey(null)
}, [rows, keyFor, openTagsKey])
const mobileRootMargin = isSmall ? '180px' : '500px'
return ( return (
<div className="space-y-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => { {rows.map((j) => {
const k = keyFor(j) const k = keyFor(j)
const inlineActive = inlinePlay?.key === k const inlineActive = inlinePlay?.key === k
// Sound nur, wenn Setting aktiv UND (Inline aktiv ODER Hover auf diesem Teaser)
const allowSound = Boolean(teaserAudio) && (inlineActive || hoverTeaserKey === k)
const previewMuted = !allowSound
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0 const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
@ -127,6 +200,11 @@ export default function FinishedDownloadsCardsView({
const flags = modelsByKey[lower(model)] const flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const showTags = tags.slice(0, 6)
const restTags = tags.length - showTags.length
const fullTags = tags.join(', ')
const statusCls = const statusCls =
j.status === 'failed' j.status === 'failed'
@ -141,13 +219,17 @@ export default function FinishedDownloadsCardsView({
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
const inlineDomId = `inline-prev-${encodeURIComponent(k)}` const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
const motionCls = isSmall ? '' : 'transition-all duration-300 ease-in-out hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none'
const cardInner = ( const cardInner = (
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
className={[ className={[
'transition-all duration-300 ease-in-out', 'group',
motionCls,
'rounded-xl',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none', busy && 'pointer-events-none',
deletingKeys.has(k) && deletingKeys.has(k) &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30 animate-pulse', 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30 animate-pulse',
@ -168,6 +250,8 @@ export default function FinishedDownloadsCardsView({
id={inlineDomId} id={inlineDomId}
ref={registerTeaserHost(k)} ref={registerTeaserHost(k)}
className="relative aspect-video bg-black/5 dark:bg-white/5" className="relative aspect-video bg-black/5 dark:bg-white/5"
onMouseEnter={isSmall ? undefined : () => onHoverPreviewKeyChange?.(k)}
onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -175,22 +259,29 @@ export default function FinishedDownloadsCardsView({
startInline(k) startInline(k)
}} }}
> >
<FinishedVideoPreview <LazyMount
job={j} force={inlineActive}
getFileName={(p) => stripHotPrefix(baseName(p))} rootMargin={mobileRootMargin}
durationSeconds={durations[k]} placeholder={<div className="w-full h-full bg-black/5 dark:bg-white/5 animate-pulse" />}
onDuration={handleDuration} className="absolute inset-0"
className="w-full h-full" >
showPopover={false} <FinishedVideoPreview
blur={blurPreviews} job={j}
animated={teaserKey === k} getFileName={(p) => stripHotPrefix(baseName(p))}
animatedMode="teaser" className="w-full h-full"
animatedTrigger="always" showPopover={false}
inlineVideo={inlineActive ? 'always' : false} blur={isSmall ? false : (inlineActive ? false : blurPreviews)}
inlineNonce={inlineNonce} animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
inlineControls={inlineActive} animatedMode="teaser"
inlineLoop={false} animatedTrigger="always"
/> inlineVideo={inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
/>
</LazyMount>
{/* Gradient overlay bottom */} {/* Gradient overlay bottom */}
<div <div
@ -238,126 +329,58 @@ export default function FinishedDownloadsCardsView({
)} )}
{/* Actions top-right */} {/* Actions top-right */}
<div className="absolute right-2 top-2 flex items-center gap-2"> <div
{(() => { className="absolute right-2 top-2 flex items-center gap-2"
const iconBtn = onClick={(e) => e.stopPropagation()}
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' + onMouseDown={(e) => e.stopPropagation()}
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500' >
<RecordJobActions
return ( job={j}
<> variant="overlay"
busy={busy}
{/* Favorite */} isHot={isHot}
<button isFavorite={isFav}
type="button" isLiked={isLiked}
className={iconBtn} isWatching={isWatching}
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'} onToggleWatch={onToggleWatch}
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'} onToggleFavorite={onToggleFavorite}
disabled={busy || !onToggleFavorite} onToggleLike={onToggleLike}
onPointerDown={(e) => e.stopPropagation()} onToggleHot={
onClick={async (e) => { onToggleHot
e.preventDefault() ? async (job) => {
e.stopPropagation() const file = baseName(job.output || '')
await onToggleFavorite?.(j) if (file) {
}} // wichtig gegen File-Lock beim Rename:
> await releasePlayingFile(file, { close: true })
{(() => { await new Promise((r) => setTimeout(r, 150))
const Icon = isFav ? StarSolidIcon : StarOutlineIcon }
return <Icon className={cn('size-5', isFav ? 'text-amber-300' : 'text-white/90')} /> await onToggleHot(job)
})()} }
</button> : undefined
}
{/* Like */} showKeep={!isSmall}
<button showDelete={!isSmall}
type="button" onKeep={keepVideo}
className={iconBtn} onDelete={deleteVideo}
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'} order={['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']}
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'} className="flex items-center gap-2"
disabled={busy || !onToggleLike} />
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
await onToggleLike?.(j)
}}
>
{(() => {
const Icon = isLiked ? HeartSolidIcon : HeartOutlineIcon
return <Icon className={cn('size-5', isLiked ? 'text-rose-300' : 'text-white/90')} />
})()}
</button>
{/* HOT */}
<button
type="button"
className={iconBtn}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
disabled={busy || !onToggleHot}
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
// wichtig gegen File-Lock beim Rename:
await releasePlayingFile(fileRaw, { close: true })
await new Promise((r) => setTimeout(r, 150))
await onToggleHot?.(j)
}}
>
<FireIcon className={cn('size-5', isHot ? 'text-amber-300' : 'text-white/90')} />
</button>
{!isSmall && (
<>
{/* Keep */}
<button
type="button"
className={iconBtn}
title="Behalten (nach keep verschieben)"
aria-label="Behalten"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void keepVideo(j)
}}
>
<BookmarkSquareIcon className="size-5 text-emerald-300" />
</button>
{/* Delete */}
<button
type="button"
className={iconBtn}
title="Löschen"
aria-label="Löschen"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void deleteVideo(j)
}}
>
<TrashIcon className="size-5 text-red-300" />
</button>
</>
)}
</>
)
})()}
</div> </div>
</div> </div>
{/* Footer / Meta */} {/* Footer / Meta */}
<div className="px-4 py-3">{/* Model + Datei im Footer */} <div
className={[
'px-4 py-3 rounded-b-lg border-t border-gray-200/60 dark:border-white/10',
isSmall ? 'bg-white/90 dark:bg-gray-950/80' : 'bg-white/60 backdrop-blur dark:bg-white/5',
].join(' ')}
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"> <div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model} {model}
</div> </div>
<div className="shrink-0 flex items-center gap-1.5"> <div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null} {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} {isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div> </div>
@ -372,6 +395,90 @@ export default function FinishedDownloadsCardsView({
</span> </span>
) : null} ) : null}
</div> </div>
{/* Tags: 1 Zeile, +N öffnet Popover */}
<div
className="mt-2 h-6 relative flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* links: Tags (nowrap, werden ggf. geclippt) */}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex flex-nowrap items-center gap-1.5">
{showTags.length > 0 ? (
showTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
</div>
{/* rechts: Rest-Count immer sichtbar + klickbar */}
{restTags > 0 ? (
<button
type="button"
className="shrink-0 inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-200/70 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
title={fullTags}
aria-haspopup="dialog"
aria-expanded={openTagsKey === k}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenTagsKey((prev) => (prev === k ? null : k))
}}
>
+{restTags}
</button>
) : null}
{/* Popover */}
{openTagsKey === k ? (
<div
ref={tagsPopoverRef}
className={[
'absolute right-0 bottom-8 z-30 w-72 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200/70 bg-white/95 shadow-lg ring-1 ring-black/5',
isSmall ? '' : 'backdrop-blur',
'dark:border-white/10 dark:bg-gray-950/90 dark:ring-white/10',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-2 border-b border-gray-200/60 px-3 py-2 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">Tags</div>
<button
type="button"
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10"
onClick={() => setOpenTagsKey(null)}
aria-label="Schließen"
title="Schließen"
>
</button>
</div>
<div className="max-h-48 overflow-auto p-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))}
</div>
</div>
</div>
) : null}
</div>
</div> </div>
</Card> </Card>
</div> </div>
@ -390,10 +497,14 @@ export default function FinishedDownloadsCardsView({
ignoreFromBottomPx={110} ignoreFromBottomPx={110}
onTap={() => { onTap={() => {
const domId = `inline-prev-${encodeURIComponent(k)}` const domId = `inline-prev-${encodeURIComponent(k)}`
flushSync(() => startInline(k)) startInline(k)
if (!tryAutoplayInline(domId)) {
requestAnimationFrame(() => tryAutoplayInline(domId)) // ✅ nach dem State-Update dem DOM 12 Frames geben
} requestAnimationFrame(() => {
if (!tryAutoplayInline(domId)) {
requestAnimationFrame(() => tryAutoplayInline(domId))
}
})
}} }}
onSwipeLeft={() => deleteVideo(j)} onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)} onSwipeRight={() => keepVideo(j)}

View File

@ -3,22 +3,25 @@
import * as React from 'react' import * as React from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import {
TrashIcon,
BookmarkSquareIcon,
FireIcon,
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
} from '@heroicons/react/24/outline'
import { import {
StarIcon as StarSolidIcon, StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon, HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid' } from '@heroicons/react/24/solid'
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import LazyMount from './LazyMount'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
teaserPlayback: 'still' | 'hover' | 'all'
teaserAudio?: boolean
hoverTeaserKey?: string | null
teaserKey: string | null
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
keyFor: (j: RecordJob) => string keyFor: (j: RecordJob) => string
@ -39,20 +42,27 @@ type Props = {
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
deleteVideo: (job: RecordJob) => Promise<boolean> deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
onToggleHot: (job: RecordJob) => void | Promise<void>
lower: (s: string) => string lower: (s: string) => string
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null }>
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
activeTagSet: Set<string>
onHoverPreviewKeyChange?: (key: string | null) => void
onToggleTagFilter: (tag: string) => void
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>
onToggleHot: (job: RecordJob) => void | Promise<void>
} }
export default function FinishedDownloadsGalleryView({ export default function FinishedDownloadsGalleryView({
rows, rows,
blurPreviews, blurPreviews,
durations, durations,
teaserPlayback,
teaserAudio,
hoverTeaserKey,
teaserKey,
handleDuration, handleDuration,
keyFor, keyFor,
@ -70,253 +80,334 @@ export default function FinishedDownloadsGalleryView({
registerTeaserHost, registerTeaserHost,
onHoverPreviewKeyChange,
onOpenPlayer, onOpenPlayer,
deleteVideo, deleteVideo,
keepVideo, keepVideo,
onToggleHot, onToggleHot,
lower, lower,
modelsByKey, modelsByKey,
activeTagSet,
onToggleTagFilter,
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch,
}: Props) { }: Props) {
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null)
const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!openTagsKey) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenTagsKey(null)
}
const onPointerDown = (e: PointerEvent) => {
const el = tagsPopoverRef.current
if (!el) return
if (el.contains(e.target as Node)) return
setOpenTagsKey(null)
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('pointerdown', onPointerDown)
}
}, [openTagsKey])
React.useEffect(() => {
if (!openTagsKey) return
// Falls Job aus der Liste verschwindet → Popover schließen
const exists = rows.some((j) => keyFor(j) === openTagsKey)
if (!exists) setOpenTagsKey(null)
}, [rows, keyFor, openTagsKey])
const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
return out
}
return ( return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <>
{rows.map((j) => { <div
const k = keyFor(j) className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
const model = modelNameFromOutput(j.output) >
const modelKey = lower(model) {rows.map((j) => {
const flags = modelsByKey[modelKey] const k = keyFor(j)
const isFav = Boolean(flags?.favorite) // Sound nur bei Hover auf genau diesem Teaser
const isLiked = flags?.liked === true const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const file = baseName(j.output || '') const previewMuted = !allowSound
const isHot = file.startsWith('HOT ')
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const statusCls =
j.status === 'failed'
? 'bg-red-500/35'
: j.status === 'finished'
? 'bg-emerald-500/35'
: j.status === 'stopped'
? 'bg-amber-500/35'
: 'bg-black/40'
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const model = modelNameFromOutput(j.output)
const deleted = deletedKeys.has(k) const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const showTags = tags.slice(0, 6)
const restTags = tags.length - showTags.length
const fullTags = tags.join(', ')
return ( const file = baseName(j.output || '')
<div const isHot = file.startsWith('HOT ')
key={k} const dur = runtimeOf(j)
role="button" const size = formatBytes(sizeBytesOf(j))
tabIndex={0}
className={[ const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
'group relative overflow-hidden rounded-lg outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10', const deleted = deletedKeys.has(k)
'bg-white dark:bg-gray-900/40',
'transition-all duration-200', return (
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
deleted && 'hidden',
]
.filter(Boolean)
.join(' ')}
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* Thumb */}
<div <div
className="group relative aspect-video bg-black/5 dark:bg-white/5" key={k}
ref={registerTeaserHost(k)} role="button"
tabIndex={0}
className={[
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
deleted && 'hidden',
]
.filter(Boolean)
.join(' ')}
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
> >
<FinishedVideoPreview {/* Thumb */}
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k]}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={true}
animatedMode="teaser"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
/>
{/* Gradient overlay bottom */}
<div <div
className=" className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
pointer-events-none absolute inset-x-0 bottom-0 h-16 ref={registerTeaserHost(k)}
bg-gradient-to-t from-black/65 to-transparent onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
transition-opacity duration-150 onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
group-hover:opacity-0 group-focus-within:opacity-0
"
/>
{/* Bottom overlay meta (Status links, Dauer+Größe rechts) */}
<div
className="
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white
transition-opacity duration-150
group-hover:opacity-0 group-focus-within:opacity-0
"
> >
<div className="flex items-center justify-between gap-2 text-[11px] opacity-90"> {/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<span className={`rounded px-1.5 py-0.5 font-semibold ${statusCls}`}> <div className="absolute inset-0 overflow-hidden rounded-t-lg">
{j.status} <LazyMount
</span> force={teaserKey === k || hoverTeaserKey === k}
rootMargin="500px"
placeholder={<div className="absolute inset-0 bg-black/5 dark:bg-white/5 animate-pulse" />}
className="absolute inset-0"
>
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
muted={previewMuted}
popoverMuted={previewMuted}
/>
</LazyMount>
<div className="flex items-center gap-1.5"> {/* Gradient overlay bottom */}
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span> <div
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span> className="
pointer-events-none absolute inset-x-0 bottom-0 h-16
bg-gradient-to-t from-black/65 to-transparent
transition-opacity duration-150
group-hover:opacity-0 group-focus-within:opacity-0
"
/>
{/* Bottom overlay meta */}
<div
className="
pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white
"
>
<div className="flex items-end justify-between gap-2">
{/* Left: File + Status unten links */}
<div className="min-w-0">
<div>
<span
className={[
'inline-block rounded px-1.5 py-0.5 text-[11px] font-semibold',
j.status === 'finished'
? 'bg-emerald-600/70'
: j.status === 'stopped'
? 'bg-amber-600/70'
: j.status === 'failed'
? 'bg-red-600/70'
: 'bg-black/50',
].join(' ')}
>
{j.status}
</span>
</div>
</div>
{/* Right bottom: Duration + Size */}
<div className="shrink-0 flex items-center gap-1.5">
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{dur}</span>
<span className="rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium">{size}</span>
</div>
</div>
</div> </div>
</div> </div>
</div>
{/* Quick actions (top-right, wie Cards) */} {/* Actions (top-right) */}
<div <div
className={[ className="absolute inset-x-2 top-2 z-10 flex justify-end"
'absolute right-2 top-2 z-10 flex items-center gap-1.5', onClick={(e) => e.stopPropagation()}
'opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 transition-opacity', >
].join(' ')} <RecordJobActions
> job={j}
{(() => { variant="overlay"
const iconBtn = busy={busy}
'pointer-events-auto inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' + collapseToMenu
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' + isHot={isHot}
'disabled:opacity-50 disabled:cursor-not-allowed' isFavorite={isFav}
isLiked={isLiked}
return ( isWatching={isWatching}
<> onToggleWatch={onToggleWatch}
{/* Favorite */} onToggleFavorite={onToggleFavorite}
{onToggleFavorite ? ( onToggleLike={onToggleLike}
<button onToggleHot={onToggleHot}
type="button" onKeep={keepVideo}
className={iconBtn} onDelete={deleteVideo}
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'} order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']}
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'} className="w-full justify-end gap-1"
disabled={busy} />
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void onToggleFavorite(j)
}}
>
{isFav ? (
<StarSolidIcon className="size-5 text-amber-300" />
) : (
<StarOutlineIcon className="size-5 text-white/90" />
)}
</button>
) : null}
{/* Like */}
{onToggleLike ? (
<button
type="button"
className={iconBtn}
aria-label={isLiked ? 'Like entfernen' : 'Like setzen'}
title={isLiked ? 'Like entfernen' : 'Like setzen'}
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void onToggleLike(j)
}}
>
{isLiked ? (
<HeartSolidIcon className="size-5 text-rose-300" />
) : (
<HeartOutlineIcon className="size-5 text-white/90" />
)}
</button>
) : null}
<button
type="button"
className={iconBtn}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void onToggleHot(j)
}}
>
<FireIcon className={['size-5', isHot ? 'text-amber-300' : 'text-white/90'].join(' ')} />
</button>
<button
type="button"
className={iconBtn}
aria-label="Behalten"
title="Behalten (nach keep verschieben)"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void keepVideo(j)
}}
>
<BookmarkSquareIcon className="size-5 text-emerald-300" />
</button>
<button
type="button"
className={iconBtn}
aria-label="Video löschen"
title="Video löschen"
disabled={busy}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void deleteVideo(j)
}}
>
<TrashIcon className="size-5 text-red-300" />
</button>
</>
)
})()}
</div>
</div>
{/* Footer / Meta (wie CardView) */}
<div className="px-4 py-3">
{/* Model + Datei im Footer */}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<div className="shrink-0 flex items-center gap-1.5">
{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="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400"> {/* Footer / Meta */}
<span className="truncate">{stripHotPrefix(file) || '—'}</span> <div className="px-4 py-3 rounded-b-lg border-t border-gray-200/60 bg-white/60 backdrop-blur dark:border-white/10 dark:bg-white/5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{isHot ? ( <div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300"> <span className="truncate">{stripHotPrefix(file) || '—'}</span>
HOT
</span> {isHot ? (
) : null} <span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
{/* Tags: 1 Zeile, +N öffnet Popover */}
<div
className="mt-2 h-6 relative flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* links: Tags (nowrap, werden ggf. geclippt) */}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex flex-nowrap items-center gap-1.5">
{showTags.length > 0 ? (
showTags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))
) : (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
</div>
{/* rechts: Rest-Count immer sichtbar + klickbar */}
{restTags > 0 ? (
<button
type="button"
className="shrink-0 inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-200/70 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
title={fullTags}
aria-haspopup="dialog"
aria-expanded={openTagsKey === k}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpenTagsKey((prev) => (prev === k ? null : k))
}}
>
+{restTags}
</button>
) : null}
{/* Popover */}
{openTagsKey === k ? (
<div
ref={tagsPopoverRef}
className="absolute right-0 bottom-8 z-30 w-72 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200/70 bg-white/95 shadow-lg ring-1 ring-black/5 backdrop-blur dark:border-white/10 dark:bg-gray-950/90 dark:ring-white/10"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-2 border-b border-gray-200/60 px-3 py-2 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">Tags</div>
<button
type="button"
className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10"
onClick={() => setOpenTagsKey(null)}
aria-label="Schließen"
title="Schließen"
>
</button>
</div>
<div className="max-h-48 overflow-auto p-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<TagBadge
key={t}
tag={t}
active={activeTagSet.has(lower(t))}
onClick={onToggleTagFilter}
/>
))}
</div>
</div>
</div>
) : null}
</div>
</div> </div>
</div> </div>
</div> )
) })}
})} </div>
</div> </>
) )
} }

View File

@ -1,6 +1,7 @@
// frontend\src\components\ui\FinishedDownloadsTableView.tsx
'use client' 'use client'
import * as React from 'react'
import Table, { type Column, type SortState } from './Table' import Table, { type Column, type SortState } from './Table'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
@ -31,7 +32,8 @@ export default function FinishedDownloadsTableView({
striped striped
fullWidth fullWidth
stickyHeader stickyHeader
compact compact={false}
card
sort={sort} sort={sort}
onSortChange={onSortChange} onSortChange={onSortChange}
onRowClick={onRowClick} onRowClick={onRowClick}

View File

@ -20,7 +20,6 @@ export type FinishedVideoPreviewProps = {
animated?: boolean animated?: boolean
animatedMode?: AnimatedMode animatedMode?: AnimatedMode
animatedTrigger?: AnimatedTrigger animatedTrigger?: AnimatedTrigger
active?: boolean
/** nur für frames */ /** nur für frames */
autoTickMs?: number autoTickMs?: number
@ -70,8 +69,6 @@ export default function FinishedVideoPreview({
animated = false, animated = false,
animatedMode = 'frames', animatedMode = 'frames',
animatedTrigger = 'always', animatedTrigger = 'always',
active,
autoTickMs = 15000, autoTickMs = 15000,
thumbStepSec, thumbStepSec,
thumbSpread, thumbSpread,
@ -107,10 +104,15 @@ export default function FinishedVideoPreview({
const [videoOk, setVideoOk] = useState(true) const [videoOk, setVideoOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false) const [metaLoaded, setMetaLoaded] = useState(false)
const [teaserReady, setTeaserReady] = useState(false)
// inView (Viewport) // inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
// ✅ NEU: sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
const [everInView, setEverInView] = useState(false)
// Tick nur für frames-Mode // Tick nur für frames-Mode
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
@ -139,9 +141,6 @@ export default function FinishedVideoPreview({
[file] [file]
) )
// ✅ Teaser-Video (vorgerendert)
const isActive = active !== undefined ? Boolean(active) : true
const hasDuration = const hasDuration =
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
@ -162,6 +161,10 @@ export default function FinishedVideoPreview({
} catch {} } catch {}
} }
useEffect(() => {
setTeaserReady(false)
}, [previewId, assetNonce])
useEffect(() => { useEffect(() => {
const onRelease = (ev: any) => { const onRelease = (ev: any) => {
const f = String(ev?.detail?.file ?? '') const f = String(ev?.detail?.file ?? '')
@ -184,9 +187,17 @@ export default function FinishedVideoPreview({
if (!el) return if (!el) return
const obs = new IntersectionObserver( const obs = new IntersectionObserver(
(entries) => setInView(Boolean(entries[0]?.isIntersecting)), (entries) => {
{ threshold: 0.1 } const hit = Boolean(entries[0]?.isIntersecting)
setInView(hit)
if (hit) setEverInView(true) // ✅ NEU
},
{
threshold: 0.01,
rootMargin: '350px 0px', // ✅ lädt erst "bei Bedarf", aber schon etwas vor dem Viewport
}
) )
obs.observe(el) obs.observe(el)
return () => obs.disconnect() return () => obs.disconnect()
}, []) }, [])
@ -281,6 +292,9 @@ export default function FinishedVideoPreview({
inlineMode === 'hover' || inlineMode === 'hover' ||
(animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover') (animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover')
// ✅ Nur dann echte Asset-Requests auslösen, wenn wir sie brauchen
const shouldLoadAssets = everInView || (wantsHover && hovered)
// --- Legacy "clips" Logik (wenn du es noch nutzt) // --- Legacy "clips" Logik (wenn du es noch nutzt)
const clipTimes = useMemo(() => { const clipTimes = useMemo(() => {
if (!animated) return [] if (!animated) return []
@ -308,6 +322,28 @@ export default function FinishedVideoPreview({
const clipIdxRef = useRef(0) const clipIdxRef = useRef(0)
const clipStartRef = useRef(0) const clipStartRef = useRef(0)
useEffect(() => {
const v = teaserMp4Ref.current
if (!v) return
const active = teaserActive && animatedMode === 'teaser'
if (!active) {
try { v.pause() } catch {}
return
}
// iOS/Safari: Eigenschaften wirklich als Properties setzen
v.muted = Boolean(muted)
// @ts-ignore
v.defaultMuted = Boolean(muted)
v.playsInline = true
v.setAttribute('playsinline', '')
v.setAttribute('webkit-playsinline', '')
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}, [teaserActive, animatedMode, teaserSrc, muted])
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek // Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
useEffect(() => { useEffect(() => {
const v = teaserRef.current const v = teaserRef.current
@ -371,71 +407,90 @@ export default function FinishedVideoPreview({
onBlur={wantsHover ? () => setHovered(false) : undefined} onBlur={wantsHover ? () => setHovered(false) : undefined}
> >
{/* 1) Inline Full Video (mit Controls) */} {/* 1) Inline Full Video (mit Controls) */}
{/* ✅ Thumb IMMER als Fallback/Background */}
{shouldLoadAssets && thumbSrc && thumbOk ? (
<img
src={thumbSrc}
loading="lazy"
decoding="async"
alt={file}
className={['absolute inset-0 w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setThumbOk(false)}
/>
) : (
<div className="absolute inset-0 bg-black/10 dark:bg-white/10" />
)}
{/* ✅ Inline Full Video (nur wenn sichtbar/aktiv) */}
{showingInlineVideo ? ( {showingInlineVideo ? (
<video <video
{...commonVideoProps} {...commonVideoProps}
ref={inlineRef}
key={`inline-${previewId}-${inlineNonce}`} key={`inline-${previewId}-${inlineNonce}`}
src={videoSrc} src={videoSrc}
className={[ className={[
'w-full h-full object-cover bg-black', 'absolute inset-0 w-full h-full object-cover',
blurCls, blurCls,
inlineControls ? 'pointer-events-auto' : 'pointer-events-none', inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
autoPlay autoPlay
muted={muted}
controls={inlineControls} controls={inlineControls}
loop={inlineLoop} loop={inlineLoop}
poster={thumbSrc || undefined} poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}
onError={() => setVideoOk(false)} onError={() => setVideoOk(false)}
/> />
) : teaserActive && animatedMode === 'teaser' ? ( ) : null}
/* 2a) ✅ Teaser MP4 (vorgerendert) */
{/* ✅ Teaser MP4: nur im Viewport (teaserActive) Thumb bleibt drunter sichtbar */}
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
<video <video
ref={teaserRef} ref={teaserMp4Ref}
key={`teaser-mp4-${previewId}`} key={`teaser-mp4-${previewId}`}
src={teaserSrc} src={teaserSrc}
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')} className={[
muted 'absolute inset-0 w-full h-full object-cover pointer-events-none',
blurCls,
teaserReady ? 'opacity-100' : 'opacity-0',
'transition-opacity duration-150',
].filter(Boolean).join(' ')}
muted={muted}
playsInline playsInline
preload="metadata"
autoPlay autoPlay
loop loop
poster={thumbSrc || undefined} preload="metadata"
// ❗kein onLoadedMetadata -> sonst würdest du Teaser-Länge als Dauer speichern poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)}
onError={() => setVideoOk(false)} onError={() => setVideoOk(false)}
/> />
) : teaserActive && animatedMode === 'clips' ? ( ) : null}
/* 2b) Legacy: Teaser Clips (1s Segmente) aus Vollvideo */
{/* ✅ Legacy clips (falls noch genutzt) */}
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
<video <video
ref={teaserRef} ref={teaserRef}
key={`clips-${previewId}-${clipTimesKey}`} key={`clips-${previewId}-${clipTimesKey}`}
src={videoSrc} src={videoSrc}
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')} className={[
muted 'absolute inset-0 w-full h-full object-cover pointer-events-none',
blurCls,
].filter(Boolean).join(' ')}
muted={muted}
playsInline playsInline
preload="metadata" preload="metadata"
poster={thumbSrc || undefined} poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onError={() => setVideoOk(false)} onError={() => setVideoOk(false)}
/> />
) : thumbSrc && thumbOk ? ( ) : null}
/* 3) Statisches Bild / Frames */
<img
src={thumbSrc}
loading="lazy"
alt={file}
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
onError={() => setThumbOk(false)}
/>
) : (
<div className="w-full h-full bg-black" />
)}
{/* Metadaten nur laden wenn nötig (und nicht inline) */} {/* Metadaten nur laden wenn nötig (und nicht inline) */}
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && ( {inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
<video <video
src={videoSrc} src={videoSrc}
preload="metadata" preload="metadata"
muted muted={muted}
playsInline playsInline
className="hidden" className="hidden"
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}

View File

@ -49,6 +49,8 @@ export default function GenerateAssetsTask({ onFinished }: Props) {
const [state, setState] = useState<TaskState | null>(null) const [state, setState] = useState<TaskState | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
const [stopping, setStopping] = useState(false)
const loadStatus = useCallback(async () => { const loadStatus = useCallback(async () => {
try { try {
@ -78,7 +80,7 @@ export default function GenerateAssetsTask({ onFinished }: Props) {
useEffect(() => { useEffect(() => {
if (!state?.running) return if (!state?.running) return
const t = window.setInterval(loadStatus, 2000) const t = window.setInterval(loadStatus, 1200)
return () => window.clearInterval(t) return () => window.clearInterval(t)
}, [state?.running, loadStatus]) }, [state?.running, loadStatus])
@ -95,45 +97,156 @@ export default function GenerateAssetsTask({ onFinished }: Props) {
} }
} }
async function stop() {
setError(null)
setStopping(true)
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch (e: any) {
// ignore wir holen danach Status neu
} finally {
await loadStatus()
setStopping(false)
}
}
const running = !!state?.running const running = !!state?.running
const total = state?.total ?? 0 const total = state?.total ?? 0
const done = state?.done ?? 0 const done = state?.done ?? 0
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0 const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
const fmtTime = (iso?: string) => {
const s = String(iso ?? '').trim()
if (!s) return null
const d = new Date(s)
if (!Number.isFinite(d.getTime())) return null
return d.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const started = fmtTime(state?.startedAt)
const finished = fmtTime(state?.finishedAt)
return ( return (
<div className="rounded-md border border-gray-200 p-3 dark:border-white/10"> <div
<div className="flex items-start justify-between gap-4"> className="
rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/50 dark:supports-[backdrop-filter]:bg-gray-950/35
"
>
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">Fehlende Assets generieren</div> <div className="flex items-center gap-2">
<div className="mt-0.5 text-xs text-gray-600 dark:text-white/70"> <div className="text-sm font-semibold text-gray-900 dark:text-white">
Erzeugt pro fertiger Datei unter <span className="font-mono">/generated/&lt;id&gt;/</span> die Dateien{' '} Assets-Generator
<span className="font-mono">thumbs.jpg</span> und <span className="font-mono">preview.mp4</span>. </div>
{/* Status badge */}
{running ? (
<span className="inline-flex items-center rounded-full bg-indigo-500/10 px-2 py-0.5 text-[11px] font-semibold text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/30">
läuft
</span>
) : (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10">
bereit
</span>
)}
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-white/70">
Erzeugt pro fertiger Datei unter <span className="font-mono">/generated/&lt;id&gt;/</span>{' '}
<span className="font-mono">thumbs.jpg</span>, <span className="font-mono">preview.mp4</span>{' '}
und <span className="font-mono">meta.json</span> für schnelle Listen & zuverlässige Duration.
</div> </div>
</div> </div>
<Button variant="primary" onClick={start} disabled={starting || running}> {/* Actions */}
{running ? 'Läuft…' : 'Generieren'} <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
</Button> {running ? (
<Button
variant="secondary"
color="red"
onClick={stop}
disabled={stopping}
className="w-full sm:w-auto"
>
{stopping ? 'Stoppe…' : 'Stop'}
</Button>
) : null}
<Button
variant="primary"
onClick={start}
disabled={starting || running}
className="w-full sm:w-auto"
>
{starting ? 'Starte…' : running ? 'Läuft…' : 'Generieren'}
</Button>
</div>
</div> </div>
{error ? <div className="mt-2 text-xs text-red-600 dark:text-red-400">{error}</div> : null} {/* Errors */}
{state?.error ? <div className="mt-2 text-xs text-amber-600 dark:text-amber-400">{state.error}</div> : null} {error ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{error}
</div>
) : null}
{state?.error ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
{state.error}
</div>
) : null}
{/* Body */}
{state ? ( {state ? (
<div className="mt-3 space-y-2"> <div className="mt-4 space-y-3">
<ProgressBar <ProgressBar
value={pct} value={pct}
showPercent showPercent
rightLabel={total ? `${done}/${total} Dateien` : '—'} rightLabel={total ? `${done}/${total} Dateien` : '—'}
/> />
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-gray-700 dark:text-white/70"> {/* Stats */}
<div className="grid grid-cols-3 gap-2">
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="text-[11px] font-medium text-gray-600 dark:text-white/70">Thumbs</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">
{state.generatedThumbs ?? 0}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="text-[11px] font-medium text-gray-600 dark:text-white/70">Previews</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">
{state.generatedPreviews ?? 0}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="text-[11px] font-medium text-gray-600 dark:text-white/70">Übersprungen</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">
{state.skipped ?? 0}
</div>
</div>
</div>
{/* Times */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-white/70">
<span> <span>
Thumbs: {state.generatedThumbs} Previews: {state.generatedPreviews} Übersprungen: {state.skipped} {started ? <>Start: <span className="font-medium text-gray-900 dark:text-white">{started}</span></> : 'Start: —'}
</span>
<span>
{finished ? <>Ende: <span className="font-medium text-gray-900 dark:text-white">{finished}</span></> : 'Ende: —'}
</span> </span>
</div> </div>
</div> </div>
) : null} ) : (
<div className="mt-4 text-xs text-gray-600 dark:text-white/70">
Status wird geladen
</div>
)}
</div> </div>
) )
} }

View File

@ -0,0 +1,55 @@
'use client'
import * as React from 'react'
type Props = {
children: React.ReactNode
/** Wenn true: sofort mounten (ohne IntersectionObserver) */
force?: boolean
/** Vorladen bevor es wirklich sichtbar ist */
rootMargin?: string
/** Optional: Platzhalter bis mounted */
placeholder?: React.ReactNode
className?: string
}
export default function LazyMount({
children,
force = false,
rootMargin = '300px',
placeholder = null,
className,
}: Props) {
const ref = React.useRef<HTMLDivElement | null>(null)
const [mounted, setMounted] = React.useState<boolean>(force)
// Wenn force später true wird (z.B. inlinePlay startet) -> sofort mounten
React.useEffect(() => {
if (force && !mounted) setMounted(true)
}, [force, mounted])
React.useEffect(() => {
if (mounted || force) return
const el = ref.current
if (!el) return
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setMounted(true)
io.disconnect()
}
},
{ rootMargin }
)
io.observe(el)
return () => io.disconnect()
}, [mounted, force, rootMargin])
return (
<div ref={ref} className={className}>
{mounted ? children : placeholder}
</div>
)
}

View File

@ -1,9 +1,14 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import Hls from 'hls.js' import Hls from 'hls.js'
import { applyInlineVideoPolicy, DEFAULT_INLINE_MUTED } from './videoPolicy' import { applyInlineVideoPolicy, DEFAULT_INLINE_MUTED } from './videoPolicy'
function withNonce(url: string, nonce: number) {
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}v=${nonce}`
}
export default function LiveHlsVideo({ export default function LiveHlsVideo({
src, src,
muted = DEFAULT_INLINE_MUTED, muted = DEFAULT_INLINE_MUTED,
@ -15,50 +20,138 @@ export default function LiveHlsVideo({
}) { }) {
const ref = useRef<HTMLVideoElement>(null) const ref = useRef<HTMLVideoElement>(null)
const [broken, setBroken] = useState(false) const [broken, setBroken] = useState(false)
const [brokenReason, setBrokenReason] = useState<'private' | 'offline' | null>(null)
// ✅ pro Mount/Wechsel einmal eine „frische“ URL erzwingen (hilft v.a. Safari/iOS)
const manifestUrl = useMemo(() => withNonce(src, Date.now()), [src])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
let hls: Hls | null = null let hls: Hls | null = null
let stallTimer: number | null = null
let watchdogTimer: number | null = null
const videoEl = ref.current const videoEl = ref.current
if (!videoEl) return if (!videoEl) return
const video = videoEl // <- jetzt: HTMLVideoElement (nicht null)
setBroken(false) setBroken(false)
setBrokenReason(null)
// ✅ zentral applyInlineVideoPolicy(video, { muted })
applyInlineVideoPolicy(videoEl, { muted })
async function waitForManifest() { const cleanupTimers = () => {
const started = Date.now() if (stallTimer) window.clearTimeout(stallTimer)
while (!cancelled && Date.now() - started < 20_000) { if (watchdogTimer) window.clearInterval(watchdogTimer)
try { stallTimer = null
const r = await fetch(src, { cache: 'no-store' }) watchdogTimer = null
if (r.status === 204) {
// Preview wird noch erzeugt -> weiter pollen
} else if (r.ok) {
return true
}
} catch {}
await new Promise((r) => setTimeout(r, 400))
}
return false
} }
async function start(video: HTMLVideoElement) { const hardReloadNative = () => {
const ok = await waitForManifest() if (cancelled) return
if (!ok || cancelled) { cleanupTimers()
if (!cancelled) setBroken(true)
// src einmal „resetten“, dann neu setzen (Safari hängt sonst manchmal)
try {
video.pause()
} catch {}
video.removeAttribute('src')
video.load()
const url = withNonce(src, Date.now())
video.src = url
video.load()
video.play().catch(() => {})
}
async function waitForManifestWithSegments(): Promise<{ ok: boolean; reason?: 'private' | 'offline' }> {
const started = Date.now()
while (!cancelled && Date.now() - started < 20_000) {
try {
const r = await fetch(manifestUrl, { cache: 'no-store' })
if (r.status === 403) return { ok: false, reason: 'private' }
if (r.status === 404) return { ok: false, reason: 'offline' }
if (r.status === 204) {
// Preview wird noch erzeugt
} else if (r.ok) {
const txt = await r.text()
if (txt.includes('#EXTINF')) return { ok: true }
}
} catch {
// ignore, retry
}
await new Promise((res) => setTimeout(res, 400))
}
return { ok: false }
}
async function start() {
const res = await waitForManifestWithSegments()
if (!res.ok || cancelled) {
if (!cancelled) {
setBrokenReason(res.reason ?? null)
setBroken(true)
}
return return
} }
// Safari kann HLS nativ
// ✅ Safari / iOS: Native HLS
if (video.canPlayType('application/vnd.apple.mpegurl')) { if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src video.src = manifestUrl
video.load()
video.play().catch(() => {}) video.play().catch(() => {})
return
// ---- Stall Handling (native) ----
let lastProgressTs = Date.now()
let lastTime = -1
const onTimeUpdate = () => {
if (video.currentTime > lastTime + 0.01) {
lastTime = video.currentTime
lastProgressTs = Date.now()
}
}
const scheduleStallReload = () => {
if (stallTimer) return
stallTimer = window.setTimeout(() => {
stallTimer = null
// wenn wir seit ein paar Sekunden keinen Fortschritt hatten -> reload
if (!cancelled && Date.now() - lastProgressTs > 3500) {
hardReloadNative()
}
}, 800)
}
video.addEventListener('timeupdate', onTimeUpdate)
video.addEventListener('waiting', scheduleStallReload)
video.addEventListener('stalled', scheduleStallReload)
video.addEventListener('error', scheduleStallReload)
// zusätzlicher Watchdog
watchdogTimer = window.setInterval(() => {
if (cancelled) return
// nur wenn autoplay läuft (nicht wenn User bewusst pausiert)
if (!video.paused && Date.now() - lastProgressTs > 6000) {
hardReloadNative()
}
}, 2000)
return () => {
video.removeEventListener('timeupdate', onTimeUpdate)
video.removeEventListener('waiting', scheduleStallReload)
video.removeEventListener('stalled', scheduleStallReload)
video.removeEventListener('error', scheduleStallReload)
}
} }
// ✅ Nicht-Safari: hls.js
if (!Hls.isSupported()) { if (!Hls.isSupported()) {
setBroken(true) setBroken(true)
return return
@ -67,14 +160,27 @@ export default function LiveHlsVideo({
hls = new Hls({ hls = new Hls({
lowLatencyMode: true, lowLatencyMode: true,
liveSyncDurationCount: 2, liveSyncDurationCount: 2,
maxBufferLength: 4, maxBufferLength: 8, // etwas entspannter
}) })
hls.on(Hls.Events.ERROR, (_evt, data) => { hls.on(Hls.Events.ERROR, (_evt, data) => {
if (data.fatal) setBroken(true) if (!hls) return
// ✅ Recovery statt direkt broken
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
hls.startLoad()
return
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError()
return
}
setBroken(true)
}
}) })
hls.loadSource(src) hls.loadSource(manifestUrl)
hls.attachMedia(video) hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
@ -82,15 +188,30 @@ export default function LiveHlsVideo({
}) })
} }
void start(videoEl) let nativeCleanup: void | (() => void) = undefined
void (async () => {
const maybeCleanup = await start()
if (typeof maybeCleanup === 'function') nativeCleanup = maybeCleanup
})()
return () => { return () => {
cancelled = true cancelled = true
cleanupTimers()
try {
nativeCleanup?.()
} catch {}
hls?.destroy() hls?.destroy()
} }
}, [src, muted]) }, [src, manifestUrl, muted])
if (broken) {
return (
<div className="text-xs text-gray-400 italic">
{brokenReason === 'private' ? 'Private' : brokenReason === 'offline' ? 'Offline' : ''}
</div>
)
}
if (broken) return <div className="text-xs text-gray-400 italic"></div>
return ( return (
<video <video

View File

@ -1,15 +1,22 @@
'use client' 'use client'
import { Fragment } from 'react' import { Fragment, type ReactNode } from 'react'
import { Dialog, Transition } from '@headlessui/react' import { Dialog, Transition } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
type ModalProps = { type ModalProps = {
open: boolean open: boolean
onClose: () => void onClose: () => void
title?: string title?: string
children?: React.ReactNode children?: ReactNode
footer?: React.ReactNode footer?: ReactNode
icon?: React.ReactNode icon?: ReactNode
/**
* Tailwind max-width Klasse für Dialog.Panel, z.B.:
* "max-w-lg" (default), "max-w-2xl", "max-w-4xl", "max-w-5xl"
*/
width?: string
} }
export default function Modal({ export default function Modal({
@ -19,6 +26,7 @@ export default function Modal({
children, children,
footer, footer,
icon, icon,
width = 'max-w-lg',
}: ModalProps) { }: ModalProps) {
return ( return (
<Transition show={open} as={Fragment}> <Transition show={open} as={Fragment}>
@ -26,34 +34,83 @@ export default function Modal({
{/* Backdrop */} {/* Backdrop */}
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enterFrom="opacity-0" enterTo="opacity-100" enter="ease-out duration-300"
leave="ease-in duration-200" leaveFrom="opacity-100" leaveTo="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
> >
<div className="fixed inset-0 bg-gray-500/75 dark:bg-gray-900/50" /> <div className="fixed inset-0 bg-gray-500/75 dark:bg-gray-900/50" />
</Transition.Child> </Transition.Child>
{/* Modal Panel */} {/* Modal Panel */}
<div className="fixed inset-0 z-50 flex items-center justify-center px-4 py-6 sm:p-0"> <div className="fixed inset-0 z-50 overflow-y-auto px-4 py-6 sm:px-6">
<Transition.Child <div className="min-h-full flex items-start justify-center sm:items-center">
as={Fragment} <Transition.Child
enter="ease-out duration-300" enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enterTo="opacity-100 translate-y-0 sm:scale-100" as={Fragment}
leave="ease-in duration-200" leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enter="ease-out duration-300"
> enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
<Dialog.Panel className="relative w-full max-w-lg transform overflow-hidden rounded-lg bg-white p-6 text-left shadow-xl transition-all dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"> enterTo="opacity-100 translate-y-0 sm:scale-100"
{icon && ( leave="ease-in duration-200"
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10"> leaveFrom="opacity-100 translate-y-0 sm:scale-100"
{icon} leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
className={[
'relative w-full transform rounded-lg bg-white text-left shadow-xl transition-all',
'max-h-[calc(100vh-3rem)] sm:max-h-[calc(100vh-4rem)]',
'flex flex-col',
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
width, // <- hier greift deine max-w-… Klasse
].join(' ')}
>
{icon && (
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10">
{icon}
</div>
)}
{/* Header */}
<div className="px-6 pt-6 flex items-start justify-between gap-3">
<div className="min-w-0">
{title ? (
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white truncate">
{title}
</Dialog.Title>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="
inline-flex shrink-0 items-center justify-center rounded-lg p-1.5
text-gray-500 hover:text-gray-900 hover:bg-black/5
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600
dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500
"
aria-label="Schließen"
title="Schließen"
>
<XMarkIcon className="size-5" />
</button>
</div> </div>
)}
{title && ( {/* Body (scrollable) */}
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white"> <div className="px-6 pb-6 pt-4 text-sm text-gray-700 dark:text-gray-300 overflow-y-auto">
{title} {children}
</Dialog.Title> </div>
)}
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{children}</div> {/* Footer */}
{footer && <div className="mt-6 flex justify-end gap-3">{footer}</div>} {footer ? (
</Dialog.Panel> <div className="px-6 py-4 border-t border-gray-200/70 dark:border-white/10 flex justify-end gap-3">
</Transition.Child> {footer}
</div>
) : null}
</Dialog.Panel>
</Transition.Child>
</div>
</div> </div>
</Dialog> </Dialog>
</Transition> </Transition>

File diff suppressed because it is too large Load Diff

View File

@ -11,11 +11,19 @@ type Props = {
thumbTick?: number thumbTick?: number
autoTickMs?: number autoTickMs?: number
blur?: boolean blur?: boolean
className?: string
fit?: 'cover' | 'contain'
// ✅ NEU: aligned refresh (z.B. exakt bei 10s/20s/30s seit startedAt) // ✅ NEU: aligned refresh (z.B. exakt bei 10s/20s/30s seit startedAt)
alignStartAt?: string | number | Date alignStartAt?: string | number | Date
alignEndAt?: string | number | Date | null alignEndAt?: string | number | Date | null
alignEveryMs?: number alignEveryMs?: number
// ✅ NEU: schneller Retry am Anfang (nur bei Running sinnvoll)
fastRetryMs?: number
fastRetryMax?: number
fastRetryWindowMs?: number
} }
export default function ModelPreview({ export default function ModelPreview({
@ -26,14 +34,29 @@ export default function ModelPreview({
alignStartAt, alignStartAt,
alignEndAt = null, alignEndAt = null,
alignEveryMs, alignEveryMs,
fastRetryMs,
fastRetryMax,
fastRetryWindowMs,
className,
}: Props) { }: Props) {
const [pageVisible, setPageVisible] = useState(() => {
if (typeof document === 'undefined') return true
return !document.hidden
})
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
const retryT = useRef<number | null>(null)
const fastTries = useRef(0)
const hadSuccess = useRef(false)
const enteredViewOnce = useRef(false)
const toMs = (v: any): number => { const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime() if (v instanceof Date) return v.getTime()
@ -41,12 +64,34 @@ export default function ModelPreview({
return Number.isFinite(ms) ? ms : NaN return Number.isFinite(ms) ? ms : NaN
} }
useEffect(() => {
const onVis = () => setPageVisible(!document.hidden)
document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
useEffect(() => {
return () => {
if (retryT.current) window.clearTimeout(retryT.current)
}
}, [])
useEffect(() => {
if (typeof thumbTick === 'number') return
if (!inView || !pageVisible) return
if (enteredViewOnce.current) return
enteredViewOnce.current = true
setLocalTick((x) => x + 1)
}, [inView, thumbTick, pageVisible])
useEffect(() => { useEffect(() => {
// Wenn Parent tickt, kein lokales Ticken // Wenn Parent tickt, kein lokales Ticken
if (typeof thumbTick === 'number') return if (typeof thumbTick === 'number') return
// Nur animieren, wenn im Sichtbereich UND Tab sichtbar // Nur animieren, wenn im Sichtbereich UND Tab sichtbar
if (!inView || document.hidden) return if (!inView || !pageVisible) return
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000) const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
if (!Number.isFinite(period) || period <= 0) return if (!Number.isFinite(period) || period <= 0) return
@ -84,7 +129,8 @@ export default function ModelPreview({
}, period) }, period)
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView, alignStartAt, alignEndAt, alignEveryMs]) }, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs])
useEffect(() => { useEffect(() => {
const el = rootRef.current const el = rootRef.current
@ -93,14 +139,16 @@ export default function ModelPreview({
const obs = new IntersectionObserver( const obs = new IntersectionObserver(
(entries) => { (entries) => {
const entry = entries[0] const entry = entries[0]
setInView(Boolean(entry?.isIntersecting)) setInView(Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0)))
}, },
{ {
root: null, root: null,
threshold: 0.1, threshold: 0, // wichtiger: nicht 0.1
rootMargin: '300px 0px', // preload: 300px vor/nach Viewport
} }
) )
obs.observe(el) obs.observe(el)
return () => obs.disconnect() return () => obs.disconnect()
}, []) }, [])
@ -112,6 +160,15 @@ export default function ModelPreview({
setImgError(false) setImgError(false)
}, [tick]) }, [tick])
useEffect(() => {
// bei Job-Wechsel alles sauber neu starten
hadSuccess.current = false
fastTries.current = 0
enteredViewOnce.current = false
setImgError(false)
setLocalTick((x) => x + 1) // sofort neuer Request
}, [jobId])
// Thumbnail mit Cache-Buster (?v=...) // Thumbnail mit Cache-Buster (?v=...)
const thumb = useMemo( const thumb = useMemo(
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${tick}`, () => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${tick}`,
@ -131,7 +188,7 @@ export default function ModelPreview({
open && ( open && (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]"> <div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div className="relative aspect-video overflow-hidden rounded-lg bg-black"> <div className="relative aspect-video overflow-hidden rounded-lg bg-black">
<LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0', blurCls].filter(Boolean).join(' ')} /> <LiveHlsVideo src={hq} muted={false} className={['w-full h-full relative z-0'].filter(Boolean).join(' ')} />
{/* LIVE badge */} {/* LIVE badge */}
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm"> <div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
@ -142,7 +199,7 @@ export default function ModelPreview({
{/* Close */} {/* Close */}
<button <button
type="button" type="button"
className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md bg-black/45 p-1.5 text-white hover:bg-black/65 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70" className="absolute right-2 top-2 z-20 inline-flex items-center justify-center rounded-md p-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/70 bg-white/75 text-gray-900 ring-1 ring-black/10 hover:bg-white/90 dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/55"
aria-label="Live-Vorschau schließen" aria-label="Live-Vorschau schließen"
title="Vorschau schließen" title="Vorschau schließen"
onClick={(e) => { onClick={(e) => {
@ -160,19 +217,49 @@ export default function ModelPreview({
> >
<div <div
ref={rootRef} ref={rootRef}
className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden flex items-center justify-center" className={[
'block relative rounded bg-gray-100 dark:bg-white/5 overflow-hidden',
className || 'w-full h-full',
].join(' ')}
> >
{!imgError ? ( {!imgError ? (
<img <img
src={thumb} src={thumb}
loading="lazy" loading={inView ? 'eager' : 'lazy'}
fetchPriority={inView ? 'high' : 'auto'}
alt="" alt=""
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')} className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
onError={() => setImgError(true)} onLoad={() => {
onLoad={() => setImgError(false)} hadSuccess.current = true
fastTries.current = 0
if (retryT.current) window.clearTimeout(retryT.current)
setImgError(false)
}}
onError={() => {
setImgError(true)
// ✅ Fast-Retry nur wenn aktiviert & sinnvoll
if (!fastRetryMs) return
if (!inView || !pageVisible) return
if (hadSuccess.current) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const windowMs = Number(fastRetryWindowMs ?? 60_000)
const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs
if (!withinWindow) return
const max = Number(fastRetryMax ?? 25)
if (fastTries.current >= max) return
if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => {
fastTries.current += 1
setLocalTick((x) => x + 1) // triggert neuen Request via ?v=
}, fastRetryMs)
}}
/> />
) : ( ) : (
<div className="text-[10px] text-gray-500 dark:text-gray-400 px-1 text-center"> <div className="absolute inset-0 grid place-items-center px-1 text-center text-[10px] text-gray-500 dark:text-gray-400">
keine Vorschau keine Vorschau
</div> </div>
)} )}

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\ModelsTab.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -7,6 +9,9 @@ import Button from './Button'
import Table, { type Column } from './Table' import Table, { type Column } from './Table'
import Modal from './Modal' import Modal from './Modal'
import Pagination from './Pagination' import Pagination from './Pagination'
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types'
type ParsedModel = { type ParsedModel = {
@ -81,7 +86,7 @@ function splitTags(raw?: string): string[] {
if (!raw) return [] if (!raw) return []
const tags = raw const tags = raw
.split(',') .split(/[\n,;|]+/g)
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean) .filter(Boolean)
@ -93,24 +98,36 @@ function splitTags(raw?: string): string[] {
return uniq return uniq
} }
function canonicalHost(raw?: string): string {
function TagBadge({ return String(raw ?? '')
children, .trim()
title, .toLowerCase()
}: { .replace(/^www\./, '')
children: React.ReactNode
title?: string
}) {
return (
<span
title={title}
className="inline-flex max-w-[11rem] items-center truncate rounded-md bg-sky-50 px-2 py-0.5 text-xs text-sky-700 dark:bg-sky-500/10 dark:text-sky-200"
>
{children}
</span>
)
} }
function modelHref(m: StoredModel): string | null {
// 1) Wenn Backend eine echte URL gespeichert hat
if (m.isUrl && /^https?:\/\//i.test(String(m.input ?? ''))) {
return String(m.input)
}
// 2) Fallback: aus host + modelKey bauen (für manual Models)
const host = canonicalHost(m.host)
const key = String(m.modelKey ?? '').trim()
if (!host || !key) return null
if (host.includes('chaturbate.com') || host.includes('chaturbate')) {
return `https://chaturbate.com/${encodeURIComponent(key)}/`
}
if (host.includes('myfreecams.com') || host.includes('myfreecams')) {
// MFC oft mit #username
return `https://www.myfreecams.com/#${encodeURIComponent(key)}`
}
// unbekannter Host → lieber gar nix öffnen als Müll
return null
}
function IconToggle({ function IconToggle({
title, title,
@ -153,6 +170,9 @@ function IconToggle({
export default function ModelsTab() { export default function ModelsTab() {
const [models, setModels] = React.useState<StoredModel[]>([]) const [models, setModels] = React.useState<StoredModel[]>([])
// ✅ verhindert Doppel-Requests pro Model (wie in App.tsx)
const flagsInFlightRef = React.useRef<Record<string, true>>({})
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const [err, setErr] = React.useState<string | null>(null) const [err, setErr] = React.useState<string | null>(null)
@ -160,6 +180,23 @@ export default function ModelsTab() {
const [page, setPage] = React.useState(1) const [page, setPage] = React.useState(1)
const pageSize = 10 const pageSize = 10
// 🏷️ Tag-Filter (klickbar)
const [tagFilter, setTagFilter] = React.useState<string[]>([])
const activeTagSet = React.useMemo(() => {
return new Set(tagFilter.map((t) => t.toLowerCase()))
}, [tagFilter])
const toggleTagFilter = React.useCallback((tag: string) => {
const k = tag.toLowerCase()
setTagFilter((prev) => {
const has = prev.some((t) => t.toLowerCase() === k)
return has ? prev.filter((t) => t.toLowerCase() !== k) : [...prev, tag]
})
}, [])
const clearTagFilter = React.useCallback(() => setTagFilter([]), [])
const [input, setInput] = React.useState('') const [input, setInput] = React.useState('')
const [parsed, setParsed] = React.useState<ParsedModel | null>(null) const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
const [parseError, setParseError] = React.useState<string | null>(null) const [parseError, setParseError] = React.useState<string | null>(null)
@ -191,6 +228,7 @@ export default function ModelsTab() {
setImportOpen(false) setImportOpen(false)
setImportFile(null) setImportFile(null)
await refresh() await refresh()
window.dispatchEvent(new Event('models-changed'))
} catch (e: any) { } catch (e: any) {
setImportErr(e?.message ?? String(e)) setImportErr(e?.message ?? String(e))
} finally { } finally {
@ -198,6 +236,14 @@ export default function ModelsTab() {
} }
} }
function jobForDetails(modelKey: string): RecordJob {
// RecordJobActions braucht nur `output`, um modelKeyFromOutput() zu finden.
// Wir geben ein Output, das dem Dateinamen-Schema entspricht: <modelKey>_MM_DD_YYYY__HH-MM-SS.ext
return {
output: `${modelKey}_01_01_2000__00-00-00.mp4`,
} as any
}
const openImport = () => { const openImport = () => {
setImportErr(null) setImportErr(null)
setImportMsg(null) setImportMsg(null)
@ -210,7 +256,7 @@ export default function ModelsTab() {
setLoading(true) setLoading(true)
setErr(null) setErr(null)
try { try {
const list = await apiJSON<StoredModel[]>('/api/models/list') const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' })
setModels(Array.isArray(list) ? list : []) setModels(Array.isArray(list) ? list : [])
} catch (e: any) { } catch (e: any) {
setErr(e?.message ?? String(e)) setErr(e?.message ?? String(e))
@ -220,11 +266,36 @@ export default function ModelsTab() {
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
refresh() void refresh()
}, [refresh]) }, [refresh])
React.useEffect(() => { React.useEffect(() => {
const onChanged = () => { void refresh() } const onChanged = (ev: Event) => {
const e = ev as CustomEvent<any>
const detail = e?.detail ?? {}
if (detail?.model) {
const updated = detail.model as StoredModel
setModels((prev) => {
const idx = prev.findIndex((m) => m.id === updated.id)
if (idx === -1) return prev
const next = prev.slice()
next[idx] = updated
return next
})
return
}
if (detail?.removed && detail?.id) {
const rid = String(detail.id)
setModels((prev) => prev.filter((m) => m.id !== rid))
return
}
// fallback
void refresh()
}
window.addEventListener('models-changed', onChanged as any) window.addEventListener('models-changed', onChanged as any)
return () => window.removeEventListener('models-changed', onChanged as any) return () => window.removeEventListener('models-changed', onChanged as any)
}, [refresh]) }, [refresh])
@ -281,13 +352,29 @@ export default function ModelsTab() {
const filtered = React.useMemo(() => { const filtered = React.useMemo(() => {
const needle = deferredQ.trim().toLowerCase() const needle = deferredQ.trim().toLowerCase()
if (!needle) return models
return modelsWithHay.filter(x => x.hay.includes(needle)).map(x => x.m) // 1) Text-Filter (q)
}, [models, modelsWithHay, deferredQ]) const base = !needle
? models
: modelsWithHay.filter((x) => x.hay.includes(needle)).map((x) => x.m)
// 2) Tag-Filter (AND: alle ausgewählten Tags müssen passen)
if (activeTagSet.size === 0) return base
return base.filter((m) => {
const tags = splitTags(m.tags)
if (tags.length === 0) return false
const have = new Set(tags.map((t) => t.toLowerCase()))
for (const t of activeTagSet) {
if (!have.has(t)) return false
}
return true
})
}, [models, modelsWithHay, deferredQ, activeTagSet])
React.useEffect(() => { React.useEffect(() => {
setPage(1) setPage(1)
}, [q]) }, [q, tagFilter])
const totalItems = filtered.length const totalItems = filtered.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])
@ -322,6 +409,7 @@ export default function ModelsTab() {
}) })
setInput('') setInput('')
setParsed(null) setParsed(null)
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: saved } }))
} catch (e: any) { } catch (e: any) {
setErr(e?.message ?? String(e)) setErr(e?.message ?? String(e))
} finally { } finally {
@ -329,14 +417,65 @@ export default function ModelsTab() {
} }
} }
const patch = async (id: string, body: any) => { const patch = async (id: string, body: any) => {
setErr(null) setErr(null)
// ✅ In-flight guard
if (flagsInFlightRef.current[id]) return
flagsInFlightRef.current[id] = true
// ✅ optimistic update + rollback snapshot
const prevModel = models.find((m) => m.id === id) ?? null
if (prevModel) {
const optimistic: StoredModel = { ...prevModel, ...body }
// ✅ watched -> watching mappen (UI-Feld heißt watching)
if (typeof body?.watched === 'boolean') {
optimistic.watching = body.watched
}
// Exklusivität wie in App.tsx:
if (body?.favorite === true) optimistic.liked = false
if (body?.liked === true) optimistic.favorite = false
setModels((prev) => prev.map((m) => (m.id === id ? optimistic : m)))
// ✅ sofort App informieren, OHNE /api/models/list
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: optimistic } }))
}
try { try {
const updated = await apiJSON<StoredModel>('/api/models/flags', { const res = await fetch('/api/models/flags', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...body }), body: JSON.stringify({ id, ...body }),
}) })
// Backend kann 204 liefern, wenn das Model aus dem Store entfernt wurde
if (res.status === 204) {
setModels((prev) => prev.filter((m) => m.id !== id))
// ✅ App informieren: removed => kein Full-Refresh
if (prevModel) {
window.dispatchEvent(
new CustomEvent('models-changed', {
detail: { removed: true, id: prevModel.id, modelKey: prevModel.modelKey },
})
)
} else {
window.dispatchEvent(new CustomEvent('models-changed', { detail: { removed: true, id } }))
}
return
}
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const updated = (await res.json()) as StoredModel
// ✅ final reconcile (Server truth)
setModels((prev) => { setModels((prev) => {
const idx = prev.findIndex((m) => m.id === updated.id) const idx = prev.findIndex((m) => m.id === updated.id)
if (idx === -1) return prev if (idx === -1) return prev
@ -344,8 +483,18 @@ export default function ModelsTab() {
next[idx] = updated next[idx] = updated
return next return next
}) })
// ✅ App informieren: updated Model als detail => kein /api/models/list
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
} catch (e: any) { } catch (e: any) {
// ✅ rollback
if (prevModel) {
setModels((prev) => prev.map((m) => (m.id === id ? prevModel : m)))
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: prevModel } }))
}
setErr(e?.message ?? String(e)) setErr(e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[id]
} }
} }
@ -383,7 +532,7 @@ export default function ModelsTab() {
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'} title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
patch(m.id, { watching: !watch }) patch(m.id, { watched: !watch })
}} }}
> >
<span className={clsx('text-base leading-none', watch ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500')}> <span className={clsx('text-base leading-none', watch ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500')}>
@ -403,7 +552,7 @@ export default function ModelsTab() {
patch(m.id, { favorite: false }) patch(m.id, { favorite: false })
} else { } else {
// exklusiv: Favorit setzt ♥ zurück // exklusiv: Favorit setzt ♥ zurück
patch(m.id, { favorite: true, clearLiked: true }) patch(m.id, { favorite: true, liked: false })
} }
}} }}
icon={<span className={fav ? 'text-amber-500' : 'text-gray-400 dark:text-gray-500'}></span>} icon={<span className={fav ? 'text-amber-500' : 'text-gray-400 dark:text-gray-500'}></span>}
@ -417,7 +566,7 @@ export default function ModelsTab() {
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (liked) { if (liked) {
patch(m.id, { clearLiked: true }) patch(m.id, { liked: false })
} else { } else {
// exklusiv: ♥ setzt Favorit zurück // exklusiv: ♥ setzt Favorit zurück
patch(m.id, { liked: true, favorite: false }) patch(m.id, { liked: true, favorite: false })
@ -429,7 +578,6 @@ export default function ModelsTab() {
) )
}, },
}, },
{ {
key: 'model', key: 'model',
header: 'Model', header: 'Model',
@ -443,18 +591,27 @@ export default function ModelsTab() {
{ {
key: 'url', key: 'url',
header: 'URL', header: 'URL',
cell: (m) => ( cell: (m) => {
<a const href = modelHref(m)
href={m.input} const label = href ?? (m.isUrl ? (m.input || '—') : '—')
target="_blank"
rel="noreferrer" if (!href) {
className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block max-w-[520px]" return <span className="text-gray-400 dark:text-gray-500"></span>
onClick={(e) => e.stopPropagation()} }
title={m.input}
> return (
{m.input} <a
</a> href={href}
), target="_blank"
rel="noreferrer"
className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block max-w-[520px]"
onClick={(e) => e.stopPropagation()}
title={href}
>
{label}
</a>
)
},
}, },
{ {
key: 'tags', key: 'tags',
@ -471,9 +628,13 @@ export default function ModelsTab() {
{m.keep ? badge(true, '📌 Behalten') : null} {m.keep ? badge(true, '📌 Behalten') : null}
{shown.map((t) => ( {shown.map((t) => (
<TagBadge key={t} title={t}> <TagBadge
{t} key={t}
</TagBadge> tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))} ))}
{rest > 0 ? <TagBadge title={full}>+{rest}</TagBadge> : null} {rest > 0 ? <TagBadge title={full}>+{rest}</TagBadge> : null}
@ -485,9 +646,23 @@ export default function ModelsTab() {
) )
}, },
}, },
{
key: 'actions',
header: '',
align: 'right',
cell: (m) => (
<div className="flex justify-end">
<RecordJobActions
job={jobForDetails(m.modelKey)}
variant="table"
order={['details']}
className="flex items-center"
/>
</div>
),
},
] ]
}, []) }, [activeTagSet, toggleTagFilter, patch])
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -531,23 +706,72 @@ export default function ModelsTab() {
<Card <Card
header={ header={
<div className="flex items-center justify-between gap-2"> <div className="space-y-2">
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="grid gap-2 sm:flex sm:items-center sm:justify-between">
Models ({filtered.length}) <div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Models <span className="text-gray-500 dark:text-gray-400">({filtered.length})</span>
</div>
{/* Mobile: Import Button rechts */}
<div className="sm:hidden">
<Button variant="secondary" size="md" onClick={openImport}>
Import
</Button>
</div>
</div>
<div className="flex items-center gap-2">
{/* Desktop: Import links von Suche */}
<div className="hidden sm:block">
<Button variant="secondary" size="md" onClick={openImport}>
Importieren
</Button>
</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 className="flex items-center gap-2"> {tagFilter.length > 0 ? (
<Button variant="secondary" size="sm" onClick={openImport}> <div className="flex flex-wrap items-center gap-2">
Importieren <span className="text-xs font-medium text-gray-500 dark:text-gray-400">
</Button> Tag-Filter:
</span>
<input <div className="flex flex-wrap items-center gap-2">
value={q} {tagFilter.map((t) => (
onChange={(e) => setQ(e.target.value)} <TagBadge
placeholder="Suchen…" key={t}
className="w-[220px] rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white" tag={t}
/> active
</div> onClick={toggleTagFilter}
title={t}
/>
))}
<Button
size='sm'
variant='soft'
className="text-xs font-medium text-gray-600 hover:underline dark:text-gray-300"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</div>
</div>
) : null}
</div> </div>
} }
noBodyPadding noBodyPadding
@ -560,7 +784,10 @@ export default function ModelsTab() {
compact compact
fullWidth fullWidth
stickyHeader stickyHeader
onRowClick={(m) => m.input && window.open(m.input, '_blank', 'noreferrer')} onRowClick={(m) => {
const href = modelHref(m)
if (href) window.open(href, '_blank', 'noreferrer')
}}
/> />
<Pagination <Pagination

View File

@ -0,0 +1,274 @@
// frontend\src\components\ui\PerformanceMonitor.tsx
import React from 'react'
import { subscribeSSE } from '../../lib/sseSingleton'
type Props = {
mode?: 'inline' | 'floating'
className?: string
/** Server perf poll Intervall */
pollMs?: number
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function barTone(t: 'good' | 'warn' | 'bad') {
if (t === 'good') return 'bg-emerald-500'
if (t === 'warn') return 'bg-amber-500'
return 'bg-red-500'
}
function formatMs(v: number | null) {
if (v == null) return ''
return `${Math.round(v)}ms`
}
function formatPct(v: number | null) {
if (v == null) return ''
return `${Math.round(v)}%`
}
function formatBytes(bytes: number | null) {
if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return ''
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 10 ? 1 : 2
return `${v.toFixed(digits)} ${units[i]}`
}
function useFps(sampleMs = 1000) {
const [fps, setFps] = React.useState<number | null>(null)
React.useEffect(() => {
let raf = 0
let last = performance.now()
let frames = 0
let alive = true
let running = false
const loop = (t: number) => {
if (!alive || !running) return
frames += 1
const dt = t - last
if (dt >= sampleMs) {
const next = Math.round((frames * 1000) / dt)
setFps(next)
frames = 0
last = t
}
raf = requestAnimationFrame(loop)
}
const start = () => {
if (!alive) return
if (document.hidden) return
if (running) return
running = true
last = performance.now()
frames = 0
raf = requestAnimationFrame(loop)
}
const stop = () => {
running = false
cancelAnimationFrame(raf)
}
const onVis = () => {
if (document.hidden) stop()
else start()
}
start()
document.addEventListener('visibilitychange', onVis)
return () => {
alive = false
document.removeEventListener('visibilitychange', onVis)
stop()
}
}, [sampleMs])
return fps
}
export default function PerformanceMonitor({
mode = 'inline',
className,
pollMs = 3000,
}: Props) {
const fps = useFps(1000)
const [ping, setPing] = React.useState<number | null>(null)
const [cpu, setCpu] = React.useState<number | null>(null)
const [diskFreeBytes, setDiskFreeBytes] = React.useState<number | null>(null)
const [diskTotalBytes, setDiskTotalBytes] = React.useState<number | null>(null)
const [diskUsedPercent, setDiskUsedPercent] = React.useState<number | null>(null)
const LOW_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB
const RESET_BYTES = 8 * 1024 * 1024 * 1024 // Hysterese (8 GB)
const emergencyRef = React.useRef(false)
React.useEffect(() => {
const url = `/api/perf/stream?ms=${encodeURIComponent(String(pollMs))}`
const unsub = subscribeSSE<any>(url, 'perf', (data) => {
const v = typeof data?.cpuPercent === 'number' ? data.cpuPercent : null
const free = typeof data?.diskFreeBytes === 'number' ? data.diskFreeBytes : null
const total = typeof data?.diskTotalBytes === 'number' ? data.diskTotalBytes : null
const usedPct = typeof data?.diskUsedPercent === 'number' ? data.diskUsedPercent : null
setCpu(v)
setDiskFreeBytes(free)
setDiskTotalBytes(total)
setDiskUsedPercent(usedPct)
const serverMs = typeof data?.serverMs === 'number' ? data.serverMs : null
setPing(serverMs != null ? Math.max(0, Date.now() - serverMs) : null)
})
return () => unsub()
}, [pollMs])
// -------------------------
// Meter config
// -------------------------
const pingTone: 'good' | 'warn' | 'bad' =
ping == null ? 'bad' : ping <= 120 ? 'good' : ping <= 300 ? 'warn' : 'bad'
const fpsTone: 'good' | 'warn' | 'bad' =
fps == null ? 'bad' : fps >= 55 ? 'good' : fps >= 30 ? 'warn' : 'bad'
const cpuTone: 'good' | 'warn' | 'bad' =
cpu == null ? 'bad' : cpu <= 60 ? 'good' : cpu <= 85 ? 'warn' : 'bad'
// Balken-Füllstände
const pingFill = clamp01(((ping ?? 999) as number) / 500) // 0..500ms
const fpsFill = clamp01(((fps ?? 0) as number) / 60) // 0..60fps
const cpuFill = clamp01(((cpu ?? 0) as number) / 100) // 0..100%
const freePct =
diskFreeBytes != null && diskTotalBytes != null && diskTotalBytes > 0
? diskFreeBytes / diskTotalBytes
: null
// "Used %" Anzeige: vom Server, sonst selbst aus freePct berechnen
const usedPctShown =
diskUsedPercent != null
? diskUsedPercent
: freePct != null
? (1 - freePct) * 100
: null
const usedFill = clamp01(((usedPctShown ?? 0) as number) / 100) // 0..1
// Disk "emergency" Hysterese (absolute Bytes)
// - wenn free <= LOW_FREE_BYTES => emergency an
// - bleibt an bis free >= RESET_BYTES
if (diskFreeBytes == null) {
emergencyRef.current = false
} else if (emergencyRef.current) {
if (diskFreeBytes >= RESET_BYTES) emergencyRef.current = false
} else {
if (diskFreeBytes <= LOW_FREE_BYTES) emergencyRef.current = true
}
const diskTone: 'good' | 'warn' | 'bad' =
diskFreeBytes == null
? 'bad'
: emergencyRef.current
? 'bad'
: freePct == null
? 'bad'
: freePct >= 0.15
? 'good'
: freePct >= 0.07
? 'warn'
: 'bad'
const diskTitle =
diskFreeBytes == null
? 'Disk: '
: `Free: ${formatBytes(diskFreeBytes)} / Total: ${formatBytes(diskTotalBytes)} · Used: ${formatPct(usedPctShown)}`
const wrapperClass =
mode === 'floating'
? 'fixed bottom-4 right-4 z-[80]'
: 'flex items-center' // inline: sichtbar, Caller entscheidet per className
return (
<div className={`${wrapperClass} ${className ?? ''}`}>
<div
className="
rounded-lg border border-gray-200/70 bg-white/70 px-2.5 py-1.5 shadow-sm backdrop-blur
dark:border-white/10 dark:bg-white/5
grid grid-cols-2 gap-x-3 gap-y-2
sm:flex sm:items-center sm:gap-3
"
>
{/* DISK */}
<div className="flex items-center gap-2" title={diskTitle}>
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Disk</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div
className={`h-full ${barTone(diskTone)}`}
style={{ width: `${Math.round(usedFill * 100)}%` }}
/>
</div>
<span className="text-[11px] w-11 tabular-nums text-gray-900 dark:text-gray-100">
{formatBytes(diskFreeBytes)}
</span>
</div>
{/* PING */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Ping</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div
className={`h-full ${barTone(pingTone)}`}
style={{ width: `${Math.round(pingFill * 100)}%` }}
/>
</div>
<span className="text-[11px] w-10 tabular-nums text-gray-900 dark:text-gray-100">
{formatMs(ping)}
</span>
</div>
{/* FPS */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">FPS</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div
className={`h-full ${barTone(fpsTone)}`}
style={{ width: `${Math.round(fpsFill * 100)}%` }}
/>
</div>
<span className="text-[11px] w-8 tabular-nums text-gray-900 dark:text-gray-100">
{fps != null ? fps : ''}
</span>
</div>
{/* CPU */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">CPU</span>
<div className="h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
<div
className={`h-full ${barTone(cpuTone)}`}
style={{ width: `${Math.round(cpuFill * 100)}%` }}
/>
</div>
<span className="text-[11px] w-10 tabular-nums text-gray-900 dark:text-gray-100">
{formatPct(cpu)}
</span>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -6,10 +6,10 @@ type ProgressBarProps = {
label?: React.ReactNode label?: React.ReactNode
value?: number | null // 0..100 value?: number | null // 0..100
indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent
showPercent?: boolean // zeigt rechts “xx%” (nur determinate) showPercent?: boolean // zeigt “xx%” (nur determinate)
rightLabel?: React.ReactNode // optionaler Text links unten (z.B. 3/10) rightLabel?: React.ReactNode // optionaler Text unter der Bar (z.B. 3/10)
steps?: string[] // optional: Step-Labels (wie in deinem Beispiel) steps?: string[] // optional: Step-Labels
currentStep?: number // 0-basiert, z.B. 1 = Step 2 aktiv currentStep?: number // 0-basiert
size?: 'sm' | 'md' size?: 'sm' | 'md'
className?: string className?: string
} }
@ -42,15 +42,30 @@ export default function ProgressBar({
return i <= currentStep return i <= currentStep
} }
const showPct = showPercent && !indeterminate
return ( return (
<div className={className}> <div className={className}>
{label ? ( {/* ✅ Label + Prozent jetzt ÜBER der Bar */}
<p className="text-sm font-medium text-gray-900 dark:text-white"> {(label || showPct) ? (
{label} <div className="flex items-center justify-between gap-2">
</p> {label ? (
<p className="flex-1 min-w-0 truncate text-xs font-medium text-gray-900 dark:text-white">
{label}
</p>
) : (
<span className="flex-1" />
)}
{showPct ? (
<span className="shrink-0 text-xs font-medium text-gray-700 dark:text-gray-300">
{Math.round(clamped)}%
</span>
) : null}
</div>
) : null} ) : null}
<div aria-hidden="true" className={label ? 'mt-2' : ''}> <div aria-hidden="true" className={(label || showPct) ? 'mt-2' : ''}>
<div className="overflow-hidden rounded-full bg-gray-200 dark:bg-white/10"> <div className="overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
{indeterminate ? ( {indeterminate ? (
<div className={`${h} w-full rounded-full bg-indigo-600/70 dark:bg-indigo-500/70 animate-pulse`} /> <div className={`${h} w-full rounded-full bg-indigo-600/70 dark:bg-indigo-500/70 animate-pulse`} />
@ -62,10 +77,10 @@ export default function ProgressBar({
)} )}
</div> </div>
{(rightLabel || (showPercent && !indeterminate)) ? ( {/* ✅ rightLabel bleibt unter der Bar (links), Prozent ist jetzt oben */}
<div className="mt-2 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"> {rightLabel ? (
<span>{rightLabel ?? ''}</span> <div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
{showPercent && !indeterminate ? <span>{Math.round(clamped)}%</span> : <span />} {rightLabel}
</div> </div>
) : null} ) : null}

View File

@ -0,0 +1,726 @@
// frontend\src\components\ui\RecordJobActions.tsx
'use client'
import * as React from 'react'
import type { RecordJob } from '../../types'
import {
FireIcon as FireOutlineIcon,
TrashIcon,
BookmarkSquareIcon,
MagnifyingGlassIcon,
EllipsisVerticalIcon,
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
EyeIcon as EyeOutlineIcon,
} from '@heroicons/react/24/outline'
import {
FireIcon as FireSolidIcon,
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import { createPortal } from 'react-dom'
type Variant = 'overlay' | 'table'
type ActionKey = 'details' | 'hot' | 'favorite' | 'like' | 'watch' | 'keep' | 'delete'
type ActionResult = void | boolean
type ActionFn = (job: RecordJob) => ActionResult | Promise<ActionResult>
type Props = {
job: RecordJob
variant?: Variant
collapseToMenu?: boolean
inlineCount?: number
busy?: boolean
compact?: boolean
isHot?: boolean
isFavorite?: boolean
isLiked?: boolean
isWatching?: boolean
// Buttons gezielt ausblendbar (z.B. Cards auf mobile)
showFavorite?: boolean
showLike?: boolean
showHot?: boolean
showKeep?: boolean
showDelete?: boolean
showWatch?: boolean
showDetails?: boolean
onToggleFavorite?: ActionFn
onToggleLike?: ActionFn
onToggleHot?: ActionFn
onKeep?: ActionFn
onDelete?: ActionFn
onToggleWatch?: ActionFn
order?: ActionKey[]
className?: string
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
const baseName = (p: string) =>
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
const stripHotPrefix = (name: string) => (name.startsWith('HOT ') ? name.slice(4) : name)
// wie backend models.go / App.tsx
const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
function modelKeyFromOutput(output?: string): string | null {
const file = stripHotPrefix(baseName(output || ''))
const base = file.replace(/\.[^.]+$/, '') // ext weg
const m = base.match(reModel)
if (m?.[1]?.trim()) return m[1].trim()
const i = base.lastIndexOf('_')
if (i > 0) return base.slice(0, i)
return base ? base : null
}
export default function RecordJobActions({
job,
variant = 'overlay',
collapseToMenu = false,
inlineCount,
busy = false,
compact = false,
isHot = false,
isFavorite = false,
isLiked = false,
isWatching = false,
showFavorite,
showLike,
showHot,
showKeep,
showDelete,
showWatch,
showDetails,
onToggleFavorite,
onToggleLike,
onToggleHot,
onKeep,
onDelete,
onToggleWatch,
order,
className,
}: Props) {
const pad = variant === 'overlay' ? (compact ? 'p-1.5' : 'p-2') : 'p-1.5'
const iconBox = compact ? 'size-4' : 'size-5'
const iconFill = 'h-full w-full'
const btnBase =
variant === 'table'
? `inline-flex items-center justify-center rounded-md ${pad} hover:bg-gray-100/70 dark:hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 disabled:opacity-50 disabled:cursor-not-allowed transition-transform active:scale-95`
: 'inline-flex items-center justify-center rounded-md ' +
`bg-white/75 ${pad} text-gray-900 backdrop-blur ring-1 ring-black/10 ` +
'hover:bg-white/90 ' +
'dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/60 ' +
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:focus-visible:ring-white/40 ' +
'disabled:opacity-50 disabled:cursor-not-allowed transition-transform active:scale-95'
const colors =
variant === 'table'
? {
favOn: 'text-amber-600 dark:text-amber-300',
likeOn: 'text-rose-600 dark:text-rose-300',
hotOn: 'text-amber-600 dark:text-amber-300',
off: 'text-gray-500 dark:text-gray-300',
keep: 'text-emerald-600 dark:text-emerald-300',
del: 'text-red-600 dark:text-red-300',
watchOn: 'text-sky-600 dark:text-sky-300',
}
: {
favOn: 'text-amber-600 dark:text-amber-300',
likeOn: 'text-rose-600 dark:text-rose-300',
hotOn: 'text-amber-600 dark:text-amber-300',
off: 'text-gray-800/90 dark:text-white/90',
keep: 'text-emerald-600 dark:text-emerald-200',
del: 'text-red-600 dark:text-red-300',
watchOn: 'text-sky-600 dark:text-sky-200',
}
const wantFavorite = showFavorite ?? Boolean(onToggleFavorite)
const wantLike = showLike ?? Boolean(onToggleLike)
const wantHot = showHot ?? Boolean(onToggleHot)
const wantKeep = showKeep ?? Boolean(onKeep)
const wantDelete = showDelete ?? Boolean(onDelete)
const wantWatch = showWatch ?? Boolean(onToggleWatch)
const wantDetails = showDetails ?? true
const detailsKey = modelKeyFromOutput(job.output || '')
const detailsLabel = detailsKey ? `Mehr zu ${detailsKey} anzeigen` : 'Mehr anzeigen'
// ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…)
const [rootW, setRootW] = React.useState(0)
const [gapPx, setGapPx] = React.useState(4)
const run = (fn?: ActionFn) => (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (busy || !fn) return
void Promise.resolve(fn(job)).catch(() => {})
}
const DetailsBtn =
wantDetails && detailsKey ? (
<button
type="button"
className={cn(btnBase)}
title={detailsLabel}
aria-label={detailsLabel}
disabled={busy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (busy) return
window.dispatchEvent(new CustomEvent('open-model-details', { detail: { modelKey: detailsKey } }))
}}
>
<span className={cn('inline-flex items-center justify-center', iconBox)}>
<MagnifyingGlassIcon className={cn(iconFill, colors.off)} />
</span>
<span className="sr-only">{detailsLabel}</span>
</button>
) : null
const FavoriteBtn = wantFavorite ? (
<button
type="button"
className={btnBase}
title={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-label={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-pressed={isFavorite}
disabled={busy || !onToggleFavorite}
onClick={run(onToggleFavorite)}
>
<span className={cn('relative inline-block', iconBox)}>
<StarOutlineIcon
className={cn(
'absolute inset-0 transition-all duration-200 ease-out motion-reduce:transition-none',
isFavorite ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
iconFill,
colors.off
)}
/>
<StarSolidIcon
className={cn(
'absolute inset-0 transition-all duration-200 ease-out motion-reduce:transition-none',
isFavorite ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
iconFill,
colors.favOn
)}
/>
</span>
</button>
) : null
const LikeBtn = wantLike ? (
<button
type="button"
className={btnBase}
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
aria-pressed={isLiked}
disabled={busy || !onToggleLike}
onClick={run(onToggleLike)}
>
<span className={cn('relative inline-block', iconBox)}>
<HeartOutlineIcon
className={cn(
'absolute inset-0 transition-all duration-200 ease-out motion-reduce:transition-none',
isLiked ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
iconFill,
colors.off
)}
/>
<HeartSolidIcon
className={cn(
'absolute inset-0 transition-all duration-200 ease-out motion-reduce:transition-none',
isLiked ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
iconFill,
colors.likeOn
)}
/>
</span>
</button>
) : null
const HotBtn = wantHot ? (
<button
type="button"
className={btnBase}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-pressed={isHot}
disabled={busy || !onToggleHot}
onClick={run(onToggleHot)}
>
<span className={cn('relative inline-block', iconBox)}>
<FireOutlineIcon
className={cn(
'absolute inset-0 transition-all duration-200 ease-out',
isHot ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
iconFill,
colors.off
)}
/>
<FireSolidIcon
className={cn(
'absolute inset-0 transition-all duration-200 ease-out',
isHot ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
iconFill,
colors.hotOn
)}
/>
</span>
</button>
) : null
const WatchBtn = wantWatch ? (
<button
type="button"
className={btnBase}
title={isWatching ? 'Watched entfernen' : 'Als Watched markieren'}
aria-label={isWatching ? 'Watched entfernen' : 'Als Watched markieren'}
aria-pressed={isWatching}
disabled={busy || !onToggleWatch}
onClick={run(onToggleWatch)}
>
<span className={cn('relative inline-block', iconBox)}>
<EyeOutlineIcon
className={cn(
'absolute inset-0 transition-all duration-200 ease-out',
isWatching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
iconFill,
colors.off
)}
/>
<EyeSolidIcon
className={cn(
'absolute inset-0 transition-all duration-200 ease-out',
isWatching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
iconFill,
colors.watchOn
)}
/>
</span>
</button>
) : null
const KeepBtn = wantKeep ? (
<button
type="button"
className={btnBase}
title="Behalten (nach keep verschieben)"
aria-label="Behalten"
disabled={busy || !onKeep}
onClick={run(onKeep)}
>
<span className={cn('inline-flex items-center justify-center', iconBox)}>
<BookmarkSquareIcon className={cn(iconFill, colors.keep)} />
</span>
</button>
) : null
const DeleteBtn = wantDelete ? (
<button
type="button"
className={btnBase}
title="Löschen"
aria-label="Löschen"
disabled={busy || !onDelete}
onClick={run(onDelete)}
>
<span className={cn('inline-flex items-center justify-center', iconBox)}>
<TrashIcon className={cn(iconFill, colors.del)} />
</span>
</button>
) : null
// ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen.
const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']
const byKey: Record<ActionKey, React.ReactNode> = {
details: DetailsBtn,
favorite: FavoriteBtn,
like: LikeBtn,
watch: WatchBtn,
hot: HotBtn,
keep: KeepBtn,
delete: DeleteBtn,
}
const collapse = collapseToMenu && variant === 'overlay'
// Nur Keys, die wirklich einen Node haben
const presentKeys = actionOrder.filter((k) => Boolean(byKey[k]))
// ✅ Wenn inlineCount NICHT gesetzt ist -> Auto-Fit (so viele wie passen + Menü-Icon)
const autoFit = collapse && typeof inlineCount !== 'number'
// grobe, aber sehr stabile Button-Breite in px (Icon + Padding links/rechts)
const padPx = variant === 'overlay' ? (compact ? 6 : 8) : 6 // p-1.5=6px, p-2=8px
const iconPx = compact ? 16 : 20 // size-4 / size-5
const btnW = iconPx + padPx * 2
const fallbackLimit = compact ? 2 : 3
const fitLimit = React.useMemo(() => {
if (!autoFit) return (inlineCount ?? fallbackLimit)
// rootW kann initial 0 sein -> brauchbarer Fallback
const w = rootW || 0
if (w <= 0) return Math.min(presentKeys.length, fallbackLimit)
// Wir reservieren IMMER Platz für das Menü-Icon (btnW).
// Gaps: zwischen den inline Buttons + zwischen letztem inline und Menü => inlineCount Gaps.
for (let i = presentKeys.length; i >= 0; i--) {
const total = i * btnW + btnW + (i > 0 ? i * gapPx : 0)
if (total <= w) return i
}
return 0
}, [autoFit, inlineCount, fallbackLimit, rootW, presentKeys.length, btnW, gapPx])
const inlineKeys = collapse ? presentKeys.slice(0, fitLimit) : presentKeys
const menuKeys = collapse ? presentKeys.slice(fitLimit) : []
const [menuOpen, setMenuOpen] = React.useState(false)
const menuRef = React.useRef<HTMLDivElement | null>(null)
React.useLayoutEffect(() => {
const el = menuRef.current
if (!el || typeof window === 'undefined') return
const read = () => {
const self = menuRef.current
if (!self) return
// ✅ WICHTIG: verfügbare Breite = eigene Breite (so stimmt es auch mit Geschwistern wie "Stop")
const w = Math.floor(self.getBoundingClientRect().width || 0)
if (w > 0) setRootW(w)
const cs = window.getComputedStyle(self)
const gRaw = (cs.columnGap || (cs as any).gap || '0') as string
const g = parseFloat(gRaw)
if (Number.isFinite(g) && g >= 0) setGapPx(g)
}
read()
const ro = new ResizeObserver(() => read())
ro.observe(el)
window.addEventListener('resize', read)
return () => {
window.removeEventListener('resize', read)
ro.disconnect()
}
}, [])
// ✅ neu
const menuBtnRef = React.useRef<HTMLButtonElement | null>(null)
const menuPopupRef = React.useRef<HTMLDivElement | null>(null)
const MENU_W = 208 // entspricht Tailwind w-52 (13rem)
const GAP = 4
const MARGIN = 8
const [menuStyle, setMenuStyle] = React.useState<{ top: number; left: number; maxH: number } | null>(null)
React.useEffect(() => {
if (!menuOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false)
}
const onDown = (e: MouseEvent) => {
const root = menuRef.current
const popup = menuPopupRef.current
const target = e.target as Node
// ✅ Click innerhalb der Action-Root ODER innerhalb des Portal-Menüs -> offen lassen
if ((root && root.contains(target)) || (popup && popup.contains(target))) return
setMenuOpen(false)
}
window.addEventListener('keydown', onKey)
window.addEventListener('mousedown', onDown)
return () => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('mousedown', onDown)
}
}, [menuOpen])
React.useLayoutEffect(() => {
if (!menuOpen) {
setMenuStyle(null)
return
}
const update = () => {
const btn = menuBtnRef.current
if (!btn) return
const r = btn.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
// rechts am Button ausrichten
let left = r.right - MENU_W
left = Math.max(MARGIN, Math.min(left, vw - MENU_W - MARGIN))
let top = r.bottom + GAP
top = Math.max(MARGIN, Math.min(top, vh - MARGIN))
const maxH = Math.max(120, vh - top - MARGIN)
setMenuStyle({ top, left, maxH })
}
update()
window.addEventListener('resize', update)
// capture=true, damit auch Scroll in Container-DIVs triggert
window.addEventListener('scroll', update, true)
return () => {
window.removeEventListener('resize', update)
window.removeEventListener('scroll', update, true)
}
}, [menuOpen])
const fireDetails = () => {
if (!detailsKey) return
window.dispatchEvent(new CustomEvent('open-model-details', { detail: { modelKey: detailsKey } }))
}
const menuItem = (k: ActionKey) => {
if (k === 'details') {
return (
<button
key="details"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
disabled={busy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(false)
fireDetails()
}}
>
<MagnifyingGlassIcon className={cn('size-4', colors.off)} />
<span className="truncate">Details</span>
</button>
)
}
if (k === 'favorite') {
return (
<button
key="favorite"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
disabled={busy || !onToggleFavorite}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(false)
await onToggleFavorite?.(job)
}}
>
{isFavorite ? (
<StarSolidIcon className={cn('size-4', colors.favOn)} />
) : (
<StarOutlineIcon className={cn('size-4', colors.off)} />
)}
<span className="truncate">{isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}</span>
</button>
)
}
if (k === 'like') {
return (
<button
key="like"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
disabled={busy || !onToggleLike}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(false)
await onToggleLike?.(job)
}}
>
{isLiked ? (
<HeartSolidIcon className={cn('size-4', colors.favOn)} />
) : (
<HeartOutlineIcon className={cn('size-4', colors.off)} />
)}
<span className="truncate">{isLiked ? 'Gefällt mir entfernen' : 'Gefällt mir'}</span>
</button>
)
}
if (k === 'watch') {
return (
<button
key="watch"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
disabled={busy || !onToggleWatch}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(false)
await onToggleWatch?.(job)
}}
>
{isWatching ? (
<EyeSolidIcon className={cn('size-4', colors.favOn)} />
) : (
<EyeOutlineIcon className={cn('size-4', colors.off)} />
)}
<span className="truncate">{isWatching ? 'Watched entfernen' : 'Watched'}</span>
</button>
)
}
if (k === 'hot') {
return (
<button
key="hot"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
disabled={busy || !onToggleHot}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(false)
await onToggleHot?.(job)
}}
>
{isHot ? (
<FireSolidIcon className={cn('size-4', colors.favOn)} />
) : (
<FireOutlineIcon className={cn('size-4', colors.off)} />
)}
<span className="truncate">{isHot ? 'HOT entfernen' : 'Als HOT markieren'}</span>
</button>
)
}
if (k === 'keep') {
return (
<button
key="keep"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
disabled={busy || !onKeep}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(false)
await onKeep?.(job)
}}
>
<BookmarkSquareIcon className={cn('size-4', colors.keep)} />
<span className="truncate">Behalten</span>
</button>
)
}
if (k === 'delete') {
return (
<button
key="delete"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
disabled={busy || !onDelete}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(false)
await onDelete?.(job)
}}
>
<TrashIcon className={cn('size-4', colors.del)} />
<span className="truncate">Löschen</span>
</button>
)
}
return null
}
return (
<div ref={menuRef} className={cn('relative flex items-center flex-nowrap', className ?? 'gap-2')}>
{inlineKeys.map((k) => {
const node = byKey[k]
return node ? <React.Fragment key={k}>{node}</React.Fragment> : null
})}
{collapse && menuKeys.length > 0 ? (
<div className="relative">
<button
ref={menuBtnRef}
type="button"
className={btnBase}
aria-label="Mehr"
title="Mehr"
aria-haspopup="menu"
aria-expanded={menuOpen}
disabled={busy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (busy) return
setMenuOpen((v) => !v)
}}
>
<span className={cn('inline-flex items-center justify-center', iconBox)}>
<EllipsisVerticalIcon className={cn(iconFill, colors.off)} />
</span>
</button>
{menuOpen && menuStyle && typeof document !== 'undefined'
? createPortal(
<div
ref={menuPopupRef}
role="menu"
className="rounded-md bg-white/95 dark:bg-gray-900/95 shadow-lg ring-1 ring-black/10 dark:ring-white/10 p-1 z-[9999] overflow-auto"
style={{
position: 'fixed',
top: menuStyle.top,
left: menuStyle.left,
width: MENU_W,
maxHeight: menuStyle.maxH,
}}
onClick={(e) => e.stopPropagation()}
>
{menuKeys.map(menuItem)}
</div>,
document.body
)
: null}
</div>
) : null}
</div>
)
}

View File

@ -1,4 +1,4 @@
// RecorderSettings.tsx // frontend\src\components\ui\RecorderSettings.tsx
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -18,7 +18,15 @@ type RecorderSettings = {
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert) // ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
useChaturbateApi?: boolean useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number
blurPreviews?: boolean blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
} }
const DEFAULTS: RecorderSettings = { const DEFAULTS: RecorderSettings = {
@ -32,7 +40,13 @@ const DEFAULTS: RecorderSettings = {
autoStartAddedDownloads: true, autoStartAddedDownloads: true,
useChaturbateApi: false, useChaturbateApi: false,
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 50,
blurPreviews: false, blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 5,
} }
type Props = { type Props = {
@ -66,7 +80,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads, autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi, useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
useMyFreeCamsWatcher: data.useMyFreeCamsWatcher ?? DEFAULTS.useMyFreeCamsWatcher,
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews, blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB,
}) })
}) })
.catch(() => { .catch(() => {
@ -125,7 +145,19 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
const useChaturbateApi = !!value.useChaturbateApi const useChaturbateApi = !!value.useChaturbateApi
const useMyFreeCamsWatcher = !!value.useMyFreeCamsWatcher
const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads
const autoDeleteSmallDownloadsBelowMB = Math.max(
0,
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
)
const blurPreviews = !!value.blurPreviews const blurPreviews = !!value.blurPreviews
const teaserPlayback =
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
? value.teaserPlayback
: DEFAULTS.teaserPlayback
const teaserAudio = !!value.teaserAudio
const lowDiskPauseBelowGB = Math.max(1, Math.floor(Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB)))
setSaving(true) setSaving(true)
try { try {
@ -138,9 +170,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
ffmpegPath, ffmpegPath,
autoAddToDownloadList, autoAddToDownloadList,
autoStartAddedDownloads, autoStartAddedDownloads,
useChaturbateApi, useChaturbateApi,
useMyFreeCamsWatcher,
autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB,
blurPreviews, blurPreviews,
teaserPlayback,
teaserAudio,
lowDiskPauseBelowGB,
}), }),
}) })
if (!res.ok) { if (!res.ok) {
@ -162,6 +199,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</div> <div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Recorder-Konfiguration, Automatisierung und Tasks.
</div>
</div> </div>
<Button variant="primary" onClick={save} disabled={saving}> <Button variant="primary" onClick={save} disabled={saving}>
Speichern Speichern
@ -171,72 +211,121 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
grayBody grayBody
> >
<div className="space-y-4"> <div className="space-y-4">
{/* Alerts */}
{err && ( {err && (
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-500/10 dark:text-red-200"> <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{err} {err}
</div> </div>
)} )}
{msg && ( {msg && (
<div className="rounded-md bg-green-50 px-3 py-2 text-sm text-green-700 dark:bg-green-500/10 dark:text-green-200"> <div className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200">
{msg} {msg}
</div> </div>
)} )}
{/* Aufnahme-Ordner */} {/* ✅ Tasks (als erstes) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center"> <div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">Aufnahme-Ordner</label> <div className="flex items-start justify-between gap-4">
<div className="sm:col-span-9 flex gap-2"> <div className="min-w-0">
<input <div className="text-sm font-semibold text-gray-900 dark:text-white">Tasks</div>
value={value.recordDir} <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
onChange={(e) => setValue((v) => ({ ...v, recordDir: e.target.value }))} Generiere fehlende Vorschauen/Metadaten (z.B. Duration via meta.json) für schnelle Listenansichten.
placeholder="records (oder absolut: C:\records / /mnt/data/records)" </div>
className="min-w-0 flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 </div>
dark:bg-white/10 dark:text-white"
/> <div className="shrink-0">
<Button variant="secondary" onClick={() => browse('record')} disabled={saving || browsing !== null}> <span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-700 dark:bg-white/10 dark:text-gray-200">
Durchsuchen... Utilities
</Button> </span>
</div>
</div>
<div className="mt-3">
<GenerateAssetsTask onFinished={onAssetsGenerated} />
</div> </div>
</div> </div>
{/* Fertige Downloads */} {/* Paths */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center"> <div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3"> <div className="mb-3">
Fertige Downloads nach <div className="text-sm font-semibold text-gray-900 dark:text-white">Pfad-Einstellungen</div>
</label> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
<div className="sm:col-span-9 flex gap-2"> Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
<input </div>
value={value.doneDir}
onChange={(e) => setValue((v) => ({ ...v, doneDir: e.target.value }))}
placeholder="records/done"
className="min-w-0 flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900
dark:bg-white/10 dark:text-white"
/>
<Button variant="secondary" onClick={() => browse('done')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div> </div>
</div>
{/* ffmpeg.exe */} <div className="space-y-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center"> {/* Aufnahme-Ordner */}
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">ffmpeg.exe</label> <div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-9 flex gap-2"> <label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
<input Aufnahme-Ordner
value={value.ffmpegPath ?? ''} </label>
onChange={(e) => setValue((v) => ({ ...v, ffmpegPath: e.target.value }))} <div className="sm:col-span-9 flex gap-2">
placeholder="Leer = automatisch (FFMPEG_PATH / ffmpeg im PATH)" <input
className="min-w-0 flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 value={value.recordDir}
dark:bg-white/10 dark:text-white" onChange={(e) => setValue((v) => ({ ...v, recordDir: e.target.value }))}
/> placeholder="records (oder absolut: C:\records / /mnt/data/records)"
<Button variant="secondary" onClick={() => browse('ffmpeg')} disabled={saving || browsing !== null}> className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
Durchsuchen... focus:outline-none focus:ring-2 focus:ring-indigo-500
</Button> dark:bg-white/10 dark:text-white dark:ring-white/10"
/>
<Button variant="secondary" onClick={() => browse('record')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div>
</div>
{/* Fertige Downloads */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Fertige Downloads nach
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={value.doneDir}
onChange={(e) => setValue((v) => ({ ...v, doneDir: e.target.value }))}
placeholder="records/done"
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 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"
/>
<Button variant="secondary" onClick={() => browse('done')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div>
</div>
{/* ffmpeg.exe */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
ffmpeg.exe
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={value.ffmpegPath ?? ''}
onChange={(e) => setValue((v) => ({ ...v, ffmpegPath: e.target.value }))}
placeholder="Leer = automatisch (FFMPEG_PATH / ffmpeg im PATH)"
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 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"
/>
<Button variant="secondary" onClick={() => browse('ffmpeg')} disabled={saving || browsing !== null}>
Durchsuchen...
</Button>
</div>
</div>
</div> </div>
</div> </div>
{/* Automatisierung */} {/* Automatisierung */}
<div className="mt-2 border-t border-gray-200 pt-4 dark:border-white/10"> <div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Automatisierung & Anzeige</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen.
</div>
</div>
<div className="space-y-3"> <div className="space-y-3">
<LabeledSwitch <LabeledSwitch
checked={!!value.autoAddToDownloadList} checked={!!value.autoAddToDownloadList}
@ -244,7 +333,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setValue((v) => ({ setValue((v) => ({
...v, ...v,
autoAddToDownloadList: checked, autoAddToDownloadList: checked,
// wenn aus, Autostart gleich mit aus
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false, autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
})) }))
} }
@ -267,19 +355,129 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models." description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
/> />
<LabeledSwitch
checked={!!value.useMyFreeCamsWatcher}
onChange={(checked) => setValue((v) => ({ ...v, useMyFreeCamsWatcher: checked }))}
label="MyFreeCams Auto-Check (watched)"
description="Geht watched MyFreeCams-Models einzeln durch und startet einen Download. Wenn keine Output-Datei entsteht, ist der Stream nicht öffentlich (offline/away/private) und der Job wird wieder entfernt."
/>
{/* ✅ NEU: Auto-Delete kleine Downloads */}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
checked={!!value.autoDeleteSmallDownloads}
onChange={(checked) =>
setValue((v) => ({
...v,
autoDeleteSmallDownloads: checked,
autoDeleteSmallDownloadsBelowMB:
v.autoDeleteSmallDownloadsBelowMB ?? 50,
}))
}
label="Kleine Downloads automatisch löschen"
description="Löscht fertige Downloads automatisch, wenn die Datei kleiner als die eingestellte Mindestgröße ist."
/>
<div
className={
'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center ' +
(!value.autoDeleteSmallDownloads ? 'opacity-50 pointer-events-none' : '')
}
>
<div className="sm:col-span-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Mindestgröße</div>
<div className="text-xs text-gray-600 dark:text-gray-300">Alles darunter wird gelöscht.</div>
</div>
<div className="sm:col-span-8">
<div className="flex items-center gap-2">
<input
type="number"
min={0}
step={1}
value={value.autoDeleteSmallDownloadsBelowMB ?? 50}
onChange={(e) =>
setValue((v) => ({
...v,
autoDeleteSmallDownloadsBelowMB: Number(e.target.value || 0),
}))
}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span>
</div>
</div>
</div>
</div>
<LabeledSwitch <LabeledSwitch
checked={!!value.blurPreviews} checked={!!value.blurPreviews}
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))} onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
label="Vorschaubilder blurren" label="Vorschaubilder blurren"
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)." description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)."
/> />
</div>
</div>
{/* Tasks */} <div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="mt-2 border-t border-gray-200 pt-4 dark:border-white/10"> <div className="sm:col-span-4">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">Tasks</div> <div className="text-sm font-medium text-gray-900 dark:text-gray-200">Teaser abspielen</div>
<GenerateAssetsTask onFinished={onAssetsGenerated} /> <div className="text-xs text-gray-600 dark:text-gray-300">
Standbild spart Leistung. Bei Hover (Standard): Desktop spielt bei Hover ab, Mobile im Viewport. Alle kann viel CPU ziehen.
</div>
</div>
<div className="sm:col-span-8">
<label className="sr-only" htmlFor="teaserPlayback">Teaser abspielen</label>
<select
id="teaserPlayback"
value={value.teaserPlayback ?? 'hover'}
onChange={(e) => setValue((v) => ({ ...v, teaserPlayback: e.target.value as any }))}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]"
>
<option value="still">Standbild</option>
<option value="hover">Bei Hover (Standard)</option>
<option value="all">Alle</option>
</select>
</div>
</div>
<LabeledSwitch
checked={!!value.teaserAudio}
onChange={(checked) => setValue((v) => ({ ...v, teaserAudio: checked }))}
label="Teaser mit Ton"
description="Wenn aktiv, werden Vorschau/Teaser nicht stumm geschaltet."
/>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Speicherplatz-Notbremse</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Wenn freier Platz darunter fällt: Autostart pausieren + laufende Downloads stoppen. Resume erfolgt automatisch bei +3 GB.
</div>
{/* Pause unter */}
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Pause unter</div>
<div className="text-xs text-gray-600 dark:text-gray-300">Freier Speicher in GB</div>
</div>
<div className="sm:col-span-8 flex items-center gap-3">
<input
type="range"
min={1}
max={500}
step={1}
value={value.lowDiskPauseBelowGB ?? 5}
onChange={(e) => setValue((v) => ({ ...v, lowDiskPauseBelowGB: Number(e.target.value) }))}
className="w-full"
/>
<span className="w-16 text-right text-sm tabular-nums text-gray-900 dark:text-gray-100">
{(value.lowDiskPauseBelowGB ?? 5)} GB
</span>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</Card> </Card>

View File

@ -1,376 +0,0 @@
// RunningDownloads.tsx
'use client'
import { useMemo, useState, useCallback, useEffect } from 'react'
import Table, { type Column } from './Table'
import Card from './Card'
import Button from './Button'
import ModelPreview from './ModelPreview'
import WaitingModelsTable, { type WaitingModelRow } from './WaitingModelsTable'
import type { RecordJob } from '../../types'
import ProgressBar from './ProgressBar'
type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown
}
type Props = {
jobs: RecordJob[]
pending?: PendingWatchedRoom[]
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void
blurPreviews?: boolean
}
const phaseLabel = (p?: string) => {
switch (p) {
case 'stopping':
return 'Stop wird angefordert…'
case 'remuxing':
return 'Remux zu MP4…'
case 'moving':
return 'Verschiebe nach Done…'
case 'finalizing':
return 'Finalisiere…'
default:
return ''
}
}
function StatusCell({ job }: { job: RecordJob }) {
const phaseRaw = String((job as any).phase ?? '')
const phase = phaseRaw.toLowerCase()
const progress = Number((job as any).progress ?? 0)
// ✅ Phase-Text komplett ausblenden (auch remuxing/moving/finalizing)
const hideLabel =
phase === 'stopping' || phase === 'remuxing' || phase === 'moving' || phase === 'finalizing'
const label = hideLabel ? '' : phaseLabel(phaseRaw)
// ✅ ProgressBar soll unabhängig vom Label erscheinen
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100
return (
<div className="min-w-0">
<div className="truncate">
<span className="font-medium">{job.status}</span>
{label ? (
<span className="text-gray-600 dark:text-gray-300"> {label}</span>
) : null}
</div>
{showBar ? (
<div className="mt-1">
<ProgressBar
value={Math.max(0, Math.min(100, progress))}
showPercent
size="sm"
className="max-w-[220px]"
/>
</div>
) : null}
</div>
)
}
const baseName = (p: string) =>
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
const modelNameFromOutput = (output?: string) => {
const file = baseName(output || '')
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
// <model>_MM_DD_YYYY__HH-MM-SS
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const total = Math.floor(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const runtimeOf = (j: RecordJob, nowMs: number) => {
const start = Date.parse(String(j.startedAt || ''))
if (!Number.isFinite(start)) return '—'
const end = j.endedAt ? Date.parse(String(j.endedAt)) : nowMs
if (!Number.isFinite(end)) return '—'
return formatDuration(end - start)
}
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob, blurPreviews }: Props) {
const [stopAllBusy, setStopAllBusy] = useState(false)
const [nowMs, setNowMs] = useState(() => Date.now())
const hasActive = useMemo(() => {
// tickt solange mind. ein Job noch nicht beendet ist
return jobs.some((j) => !j.endedAt && j.status === 'running')
}, [jobs])
useEffect(() => {
if (!hasActive) return
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
return () => window.clearInterval(t)
}, [hasActive])
const stoppableIds = useMemo(() => {
return jobs
.filter((j) => {
const phase = (j as any).phase as string | undefined
const isStoppingOrFinalizing = Boolean(phase) || j.status !== 'running'
return !isStoppingOrFinalizing
})
.map((j) => j.id)
}, [jobs])
const onStopAll = useCallback(() => {
if (stopAllBusy) return
if (stoppableIds.length === 0) return
setStopAllBusy(true)
// fire-and-forget (onStopJob ist bei dir void)
for (const id of stoppableIds) onStopJob(id)
// Button kurz sperren; Polling/Phase-Updates übernehmen den Rest
setTimeout(() => setStopAllBusy(false), 800)
}, [stopAllBusy, stoppableIds, onStopJob])
const columns = useMemo<Column<RecordJob>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
cell: (j) => (
<ModelPreview
jobId={j.id}
blur={blurPreviews}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
/>
),
},
{
key: 'model',
header: 'Modelname',
cell: (j) => {
const name = modelNameFromOutput(j.output)
return (
<span className="truncate" title={name}>
{name}
</span>
)
},
},
{
key: 'sourceUrl',
header: 'Source',
cell: (j) => (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
className="text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{j.sourceUrl}
</a>
),
},
{
key: 'output',
header: 'Datei',
cell: (j) => baseName(j.output || ''),
},{
key: 'status',
header: 'Status',
cell: (j) => <StatusCell job={j} />,
},
{
key: 'runtime',
header: 'Dauer',
cell: (j) => runtimeOf(j, nowMs),
},
{
key: 'actions',
header: 'Aktion',
srOnlyHeader: true,
align: 'right',
cell: (j) => {
const phase = (j as any).phase as string | undefined
const isStoppingOrFinalizing = Boolean(phase) || j.status !== 'running'
return (
<Button
size="sm"
variant="primary"
disabled={isStoppingOrFinalizing}
onClick={(e) => {
e.stopPropagation()
if (isStoppingOrFinalizing) return
onStopJob(j.id)
}}
>
{isStoppingOrFinalizing ? 'Stoppe…' : 'Stop'}
</Button>
)
},
},
]
}, [onStopJob, blurPreviews, nowMs])
if (jobs.length === 0 && pending.length === 0) {
return (
<Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300">Keine laufenden Downloads.</div>
</Card>
)
}
return (
<>
{pending.length > 0 && (
<Card
grayBody
header={
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Online (wartend Download startet nur bei <span className="font-semibold">public</span>)
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">{pending.length}</div>
</div>
}
>
<WaitingModelsTable models={pending} />
</Card>
)}
{jobs.length === 0 && pending.length > 0 && (
<Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300">
Kein aktiver Download wartet auf public.
</div>
</Card>
)}
{jobs.length > 0 && (
<>
<div className="mb-2 flex items-center justify-end gap-2">
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.stopPropagation()
onStopAll()
}}
>
{stopAllBusy ? 'Stoppe…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
{/* ✅ Mobile: Cards */}
<div className="sm:hidden space-y-3">
{jobs.map((j) => {
const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const dur = runtimeOf(j, nowMs)
return (
<div
key={j.id}
role="button"
tabIndex={0}
className="cursor-pointer"
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<Card
header={
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div>
</div>
<Button
size="sm"
variant="primary"
onClick={(e) => {
e.stopPropagation()
onStopJob(j.id)
}}
>
Stoppen
</Button>
</div>
}
>
<div className="flex gap-3">
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<ModelPreview
jobId={j.id}
blur={blurPreviews}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-600 dark:text-gray-300">
<StatusCell job={j} />
<span className="mx-2 opacity-60"></span>
Dauer: <span className="font-medium">{dur}</span>
</div>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-1 block truncate text-xs text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{j.sourceUrl}
</a>
) : null}
</div>
</div>
</Card>
</div>
)
})}
</div>
{/* ✅ Desktop/Tablet: Tabelle */}
<div className="hidden sm:block">
<Table
rows={jobs}
columns={columns}
getRowKey={(r) => r.id}
striped
fullWidth
onRowClick={onOpenPlayer}
/>
</div>
</>
)}
</>
)
}

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\SwipeCard.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -58,6 +60,13 @@ export type SwipeCardProps = {
*/ */
ignoreSelector?: string ignoreSelector?: string
/**
* Optional: CSS-Selector, bei dem ein "Tap" NICHT onTap() auslösen soll.
* (z.B. Buttons/Inputs innerhalb der Karte)
*/
tapIgnoreSelector?: string
} }
export type SwipeCardHandle = { export type SwipeCardHandle = {
@ -93,31 +102,49 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
), ),
className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300', className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
}, },
thresholdPx = 120, //thresholdPx = 120,
thresholdRatio = 0.35, thresholdPx = 180,
//thresholdRatio = 0.35,
thresholdRatio = 0.1,
ignoreFromBottomPx = 72, ignoreFromBottomPx = 72,
ignoreSelector = '[data-swipe-ignore]', ignoreSelector = '[data-swipe-ignore]',
snapMs = 180, snapMs = 180,
commitMs = 180, commitMs = 180,
tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]',
}, },
ref ref
) { ) {
const cardRef = React.useRef<HTMLDivElement | null>(null) const cardRef = React.useRef<HTMLDivElement | null>(null)
// ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move)
const dxRef = React.useRef(0)
const rafRef = React.useRef<number | null>(null)
// ✅ Perf: Threshold einmal pro PointerDown berechnen (kein offsetWidth pro Move)
const thresholdRef = React.useRef(0)
const pointer = React.useRef<{ const pointer = React.useRef<{
id: number | null id: number | null
x: number x: number
y: number y: number
dragging: boolean dragging: boolean
captured: boolean captured: boolean
}>({ id: null, x: 0, y: 0, dragging: false, captured: false }) tapIgnored: boolean
}>({ id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false })
const [dx, setDx] = React.useState(0) const [dx, setDx] = React.useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null) const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
const [animMs, setAnimMs] = React.useState<number>(0) const [animMs, setAnimMs] = React.useState<number>(0)
const reset = React.useCallback(() => { const reset = React.useCallback(() => {
// ✅ rAF cleanup
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
dxRef.current = 0
setAnimMs(snapMs) setAnimMs(snapMs)
setDx(0) setDx(0)
setArmedDir(null) setArmedDir(null)
@ -126,13 +153,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const commit = React.useCallback( const commit = React.useCallback(
async (dir: 'left' | 'right', runAction: boolean) => { async (dir: 'left' | 'right', runAction: boolean) => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
const el = cardRef.current const el = cardRef.current
const w = el?.offsetWidth || 360 const w = el?.offsetWidth || 360
// rausfliegen lassen // rausfliegen lassen
setAnimMs(commitMs) setAnimMs(commitMs)
setArmedDir(dir === 'right' ? 'right' : 'left') setArmedDir(dir === 'right' ? 'right' : 'left')
setDx(dir === 'right' ? w + 40 : -(w + 40)) const outDx = dir === 'right' ? w + 40 : -(w + 40)
dxRef.current = outDx
setDx(outDx)
let ok: boolean | void = true let ok: boolean | void = true
if (runAction) { if (runAction) {
@ -200,18 +235,51 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
ref={cardRef} ref={cardRef}
className="relative" className="relative"
style={{ style={{
transform: `translateX(${dx}px)`, // ✅ iOS Fix: kein transform im Idle-Zustand, sonst sind Video-Controls oft nicht tappbar
transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,
transition: animMs ? `transform ${animMs}ms ease` : undefined, transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y', // wichtig: vertikales Scrollen zulassen touchAction: 'pan-y',
willChange: dx !== 0 ? 'transform' : undefined,
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
// ✅ 1) Ignoriere Start auf "No-swipe"-Elementen // ✅ 1) Ignoriere Start auf "No-swipe"-Elementen
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
const tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
if (ignoreSelector && target?.closest?.(ignoreSelector)) return if (ignoreSelector && target?.closest?.(ignoreSelector)) return
// ✅ 2) Ignoriere Start im unteren Bereich (z.B. Video-Controls/Progressbar) const root = e.currentTarget as HTMLElement
const videos = Array.from(root.querySelectorAll('video')) as HTMLVideoElement[]
const ctlVideo = videos.find((v) => v.controls)
if (ctlVideo) {
const vr = ctlVideo.getBoundingClientRect()
const inVideo =
e.clientX >= vr.left &&
e.clientX <= vr.right &&
e.clientY >= vr.top &&
e.clientY <= vr.bottom
if (inVideo) {
// unten frei für Timeline/Scrub (iPhone braucht meist etwas mehr)
const fromBottomVideo = vr.bottom - e.clientY
const scrubZonePx = 72
if (fromBottomVideo <= scrubZonePx) return
// Swipe nur aus den Seitenrändern
const edgeZonePx = 64
const xFromLeft = e.clientX - vr.left
const xFromRight = vr.right - e.clientX
const inEdge = xFromLeft <= edgeZonePx || xFromRight <= edgeZonePx
if (!inEdge) return
}
}
// ✅ 3) Optional: generelle Card-Bottom-Sperre (bei dir in CardsView auf 0 lassen)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const fromBottom = rect.bottom - e.clientY const fromBottom = rect.bottom - e.clientY
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return
@ -222,7 +290,17 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
y: e.clientY, y: e.clientY,
dragging: false, dragging: false,
captured: false, captured: false,
tapIgnored, // ✅ WICHTIG: nicht "false"
} }
// ✅ Perf: pro Gesture einmal Threshold berechnen
const el = cardRef.current
const w = el?.offsetWidth || 360
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
// ✅ dxRef reset (neue Gesture)
dxRef.current = 0
}} }}
onPointerMove={(e) => { onPointerMove={(e) => {
@ -246,6 +324,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// ✅ jetzt erst beginnen wir zu swipen // ✅ jetzt erst beginnen wir zu swipen
pointer.current.dragging = true pointer.current.dragging = true
// ✅ Anim nur 1x beim Drag-Start deaktivieren
setAnimMs(0)
// ✅ Pointer-Capture erst JETZT (nicht bei pointerdown) // ✅ Pointer-Capture erst JETZT (nicht bei pointerdown)
try { try {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
@ -255,25 +336,31 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
} }
} }
setAnimMs(0) // ✅ dx nur pro Frame in React-State schreiben
setDx(ddx) dxRef.current = ddx
const el = cardRef.current if (rafRef.current == null) {
const w = el?.offsetWidth || 360 rafRef.current = requestAnimationFrame(() => {
const threshold = Math.min(thresholdPx, w * thresholdRatio) rafRef.current = null
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null) setDx(dxRef.current)
})
}
// ✅ armedDir nur updaten wenn geändert
const threshold = thresholdRef.current
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
setArmedDir((prev) => (prev === nextDir ? prev : nextDir))
}} }}
onPointerUp={(e) => { onPointerUp={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return if (pointer.current.id !== e.pointerId) return
const el = cardRef.current const threshold = thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const w = el?.offsetWidth || 360
const threshold = Math.min(thresholdPx, w * thresholdRatio)
const wasDragging = pointer.current.dragging const wasDragging = pointer.current.dragging
const wasCaptured = pointer.current.captured const wasCaptured = pointer.current.captured
const wasTapIgnored = pointer.current.tapIgnored
pointer.current.id = null pointer.current.id = null
pointer.current.dragging = false pointer.current.dragging = false
@ -287,18 +374,38 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
} }
if (!wasDragging) { if (!wasDragging) {
// ✅ Wichtig: Wenn Tap auf Video/Controls (tapIgnored), NICHT resetten
// sonst “stiehlt” SwipeCard den Tap (iOS besonders empfindlich).
if (wasTapIgnored) {
setAnimMs(0)
setDx(0)
setArmedDir(null)
return
}
reset() reset()
onTap?.() onTap?.()
return return
} }
if (dx > threshold) { const finalDx = dxRef.current
// rAF cleanup
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (finalDx > threshold) {
void commit('right', true) void commit('right', true)
} else if (dx < -threshold) { } else if (finalDx < -threshold) {
void commit('left', true) void commit('left', true)
} else { } else {
reset() reset()
} }
dxRef.current = 0
}} }}
onPointerCancel={(e) => { onPointerCancel={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
@ -307,7 +414,13 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id) ;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
} catch {} } catch {}
} }
pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false } pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false }
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
dxRef.current = 0
reset() reset()
}} }}
> >

View File

@ -0,0 +1,99 @@
'use client'
import * as React from 'react'
import clsx from 'clsx'
type Props = {
/** Entweder tag ODER children nutzen (tag ist praktisch, wenn du onClick nutzt). */
tag?: string
children?: React.ReactNode
title?: string
/** Wenn aktiv (z.B. in Filter), etwas "stärker" highlighten */
active?: boolean
/** Wenn gesetzt, wird ein <button> gerendert und der Tag ist klickbar */
onClick?: (tag: string) => void
/** optional: default ist max-w-[11rem] wie in ModelsTab */
maxWidthClassName?: string
className?: string
/** default: true (damit Tabellenzeilen-Klick nicht triggert) */
stopPropagation?: boolean
}
export default function TagBadge({
tag,
children,
title,
active,
onClick,
maxWidthClassName = 'max-w-[11rem]',
className,
stopPropagation = true,
}: Props) {
const label =
tag ??
(typeof children === 'string' || typeof children === 'number' ? String(children) : '')
// Styling: Basis wie in ModelsTab
const base = clsx(
'inline-flex items-center truncate rounded-md px-2 py-0.5 text-xs',
maxWidthClassName,
'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'
)
const activeCls = active
? 'bg-sky-100 text-sky-800 dark:bg-sky-400/20 dark:text-sky-100'
: ''
const clickableCls = onClick
? 'cursor-pointer hover:bg-sky-100 dark:hover:bg-sky-400/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500'
: ''
const cls = clsx(base, activeCls, clickableCls, className)
const stop = (e: React.SyntheticEvent) => e.stopPropagation()
const commonHandlers = stopPropagation
? {
onPointerDown: stop,
onMouseDown: stop,
}
: {}
if (!onClick) {
return (
<span
className={cls}
title={title ?? label}
{...commonHandlers}
onClick={stopPropagation ? stop : undefined}
>
{children ?? tag}
</span>
)
}
return (
<button
type="button"
className={cls}
title={title ?? label}
aria-pressed={!!active}
{...commonHandlers}
onClick={(e) => {
if (stopPropagation) {
e.preventDefault()
e.stopPropagation()
}
if (!label) return
onClick(label)
}}
>
{children ?? tag}
</button>
)
}

View File

@ -0,0 +1,211 @@
'use client'
import * as React from 'react'
import { Transition } from '@headlessui/react'
import {
CheckCircleIcon,
XCircleIcon,
InformationCircleIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline'
import { XMarkIcon } from '@heroicons/react/20/solid'
type ToastType = 'success' | 'error' | 'info' | 'warning'
export type Toast = {
id: string
type: ToastType
title?: string
message?: string
durationMs?: number // auto close
}
type ToastContextValue = {
push: (t: Omit<Toast, 'id'>) => string
remove: (id: string) => void
clear: () => void
}
const ToastContext = React.createContext<ToastContextValue | null>(null)
function iconFor(type: ToastType) {
switch (type) {
case 'success':
return { Icon: CheckCircleIcon, cls: 'text-emerald-500' }
case 'error':
return { Icon: XCircleIcon, cls: 'text-rose-500' }
case 'warning':
return { Icon: ExclamationTriangleIcon, cls: 'text-amber-500' }
default:
return { Icon: InformationCircleIcon, cls: 'text-sky-500' }
}
}
function borderFor(type: ToastType) {
switch (type) {
case 'success':
return 'border-emerald-200/70 dark:border-emerald-400/20'
case 'error':
return 'border-rose-200/70 dark:border-rose-400/20'
case 'warning':
return 'border-amber-200/70 dark:border-amber-400/20'
default:
return 'border-sky-200/70 dark:border-sky-400/20'
}
}
function titleDefault(type: ToastType) {
switch (type) {
case 'success':
return 'Erfolg'
case 'error':
return 'Fehler'
case 'warning':
return 'Hinweis'
default:
return 'Info'
}
}
function uid() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
export function ToastProvider({
children,
maxToasts = 3,
defaultDurationMs = 3500,
position = 'bottom-right',
}: {
children: React.ReactNode
maxToasts?: number
defaultDurationMs?: number
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
}) {
const [toasts, setToasts] = React.useState<Toast[]>([])
const remove = React.useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const clear = React.useCallback(() => setToasts([]), [])
const push = React.useCallback(
(t: Omit<Toast, 'id'>) => {
const id = uid()
const durationMs = t.durationMs ?? defaultDurationMs
setToasts((prev) => {
const next = [{ ...t, id, durationMs }, ...prev]
return next.slice(0, Math.max(1, maxToasts))
})
if (durationMs && durationMs > 0) {
window.setTimeout(() => remove(id), durationMs)
}
return id
},
[defaultDurationMs, maxToasts, remove]
)
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}
{/* Live region */}
<div
aria-live="assertive"
className={[
'pointer-events-none fixed z-[80] inset-x-0',
insetCls,
].join(' ')}
>
<div className={['flex w-full px-4 py-6 sm:p-6', posCls].join(' ')}>
<div className={['flex w-full flex-col space-y-3', alignCls].join(' ')}>
{toasts.map((t) => {
const { Icon, cls } = iconFor(t.type)
const title = (t.title || '').trim() || titleDefault(t.type)
const msg = (t.message || '').trim()
return (
<Transition key={t.id} appear show={true}>
<div
className={[
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-xl',
'border bg-white/90 shadow-lg backdrop-blur',
'outline-1 outline-black/5',
'dark:bg-gray-950/70 dark:-outline-offset-1 dark:outline-white/10',
borderFor(t.type),
// animation classes (headlessui v2 data-*)
'transition data-closed:opacity-0 data-enter:transform data-enter:duration-200 data-enter:ease-out',
'data-closed:data-enter:translate-y-2 sm:data-closed:data-enter:translate-y-0',
position.endsWith('right')
? 'sm:data-closed:data-enter:translate-x-2'
: 'sm:data-closed:data-enter:-translate-x-2',
].join(' ')}
>
<div className="p-4">
<div className="flex items-start gap-3">
<div className="shrink-0">
<Icon className={['size-6', cls].join(' ')} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-white">
{title}
</p>
{msg ? (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300 break-words">
{msg}
</p>
) : null}
</div>
<button
type="button"
onClick={() => remove(t.id)}
className="shrink-0 rounded-md text-gray-400 hover:text-gray-600 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:hover:text-white dark:focus:outline-indigo-500"
>
<span className="sr-only">Close</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</button>
</div>
</div>
</div>
</Transition>
)
})}
</div>
</div>
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const ctx = React.useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
return ctx
}

View File

@ -1,91 +0,0 @@
// WaitingModelsTable.tsx
'use client'
import { useMemo } from 'react'
import Table, { type Column } from './Table'
export type WaitingModelRow = {
id: string
modelKey: string
url: string
currentShow?: string // public / private / hidden / away / unknown
}
type Props = {
models: WaitingModelRow[]
}
export default function WaitingModelsTable({ models }: Props) {
const columns = useMemo<Column<WaitingModelRow>[]>(() => {
return [
{
key: 'model',
header: 'Model',
cell: (m) => (
<span className="truncate font-medium text-gray-900 dark:text-white" title={m.modelKey}>
{m.modelKey}
</span>
),
},
{
key: 'status',
header: 'Status',
cell: (m) => <span className="font-medium">{m.currentShow || 'unknown'}</span>,
},
{
key: 'open',
header: 'Öffnen',
srOnlyHeader: true,
align: 'right',
cell: (m) => (
<a
href={m.url}
target="_blank"
rel="noreferrer"
className="text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
Öffnen
</a>
),
},
]
}, [])
if (models.length === 0) return null
return (
<>
{/* ✅ Mobile: kompakte Liste */}
<div className="sm:hidden space-y-2">
{models.map((m) => (
<div
key={m.id}
className="flex items-center justify-between gap-3 rounded-md bg-white/60 dark:bg-white/5 px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{m.modelKey}</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
Status: <span className="font-medium">{m.currentShow || 'unknown'}</span>
</div>
</div>
<a
href={m.url}
target="_blank"
rel="noreferrer"
className="shrink-0 text-xs text-indigo-600 dark:text-indigo-400 hover:underline"
>
Öffnen
</a>
</div>
))}
</div>
{/* ✅ Desktop/Tablet: Tabelle */}
<div className="hidden sm:block">
<Table rows={models} columns={columns} getRowKey={(r) => r.id} striped fullWidth />
</div>
</>
)
}

View File

@ -0,0 +1,22 @@
'use client'
import type { Toast } from './ToastProvider'
import { useToast } from './ToastProvider'
// Hook-Wrapper (komfortabel)
export function useNotify() {
const { push, remove, clear } = useToast()
const base = (type: Toast['type']) => (title: string, message?: string, opts?: Partial<Omit<Toast, 'id' | 'type'>>) =>
push({ type, title, message, ...opts })
return {
push,
remove,
clear,
success: base('success'),
error: base('error'),
info: base('info'),
warning: base('warning'),
}
}

View File

@ -0,0 +1,174 @@
// frontend/src/lib/chaturbateOnlinePoller.ts
export type ChaturbateOnlineRoom = {
username?: string
current_show?: string
chat_room_url?: string
image_url?: string
}
export type ChaturbateOnlineResponse = {
enabled: boolean
rooms: ChaturbateOnlineRoom[]
}
type OnlineState = ChaturbateOnlineResponse
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
return out
}
function dedupeRooms(rooms: ChaturbateOnlineRoom[]): ChaturbateOnlineRoom[] {
const seen = new Set<string>()
const out: ChaturbateOnlineRoom[] = []
for (const r of rooms) {
const u = String(r?.username ?? '').trim().toLowerCase()
if (!u || seen.has(u)) continue
seen.add(u)
out.push(r)
}
return out
}
export function startChaturbateOnlinePolling(opts: {
getModels: () => string[]
getShow: () => string[]
onData: (data: OnlineState) => void
intervalMs?: number
/** Optional: wird bei Fehlern aufgerufen (für Debug) */
onError?: (err: unknown) => void
}) {
const baseIntervalMs = opts.intervalMs ?? 5000
let timer: number | null = null
let inFlight: AbortController | null = null
let lastKey = ''
let lastResult: OnlineState | null = null
let stopped = false
const clearTimer = () => {
if (timer != null) {
window.clearTimeout(timer)
timer = null
}
}
const closeInFlight = () => {
if (inFlight) {
try {
inFlight.abort()
} catch {}
inFlight = null
}
}
const schedule = (ms: number) => {
if (stopped) return
clearTimer()
timer = window.setTimeout(() => void tick(), ms)
}
const tick = async () => {
if (stopped) return
try {
const models = (opts.getModels?.() ?? [])
.map((x) => String(x || '').trim())
.filter(Boolean)
const showRaw = (opts.getShow?.() ?? [])
.map((x) => String(x || '').trim())
.filter(Boolean)
// stabilisieren
const show = showRaw.slice().sort()
const modelsSorted = models.slice().sort()
// keine Models -> rooms leeren (enabled nicht neu erfinden)
if (modelsSorted.length === 0) {
closeInFlight()
const empty: OnlineState = { enabled: lastResult?.enabled ?? false, rooms: [] }
lastResult = empty
opts.onData(empty)
// hidden tab -> seltener pollen
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
schedule(nextMs)
return
}
const key = `${show.join(',')}|${modelsSorted.join(',')}`
const requestKey = key
lastKey = key
// dedupe / cancel previous
closeInFlight()
const controller = new AbortController()
inFlight = controller
// ✅ Wichtig: "keepalive" NICHT setzen (kann Ressourcen kosten)
const CHUNK_SIZE = 350 // wenn du extrem viele Keys hast: 200300 nehmen
const parts = chunk(modelsSorted, CHUNK_SIZE)
let mergedRooms: ChaturbateOnlineRoom[] = []
let mergedEnabled = false
let hadAnyOk = false
for (const part of parts) {
if (controller.signal.aborted) return
if (requestKey !== lastKey) return
if (stopped) return
const res = await fetch('/api/chaturbate/online', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: part, show, refresh: false }),
signal: controller.signal,
cache: 'no-store',
})
if (!res.ok) continue
hadAnyOk = true
const data = (await res.json()) as OnlineState
mergedEnabled = mergedEnabled || Boolean(data?.enabled)
mergedRooms.push(...(Array.isArray(data?.rooms) ? data.rooms : []))
}
if (!hadAnyOk) {
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
schedule(nextMs)
return
}
const merged: OnlineState = { enabled: mergedEnabled, rooms: dedupeRooms(mergedRooms) }
if (controller.signal.aborted) return
if (requestKey !== lastKey) return
if (stopped) return
lastResult = merged
opts.onData(merged)
} catch (e: any) {
if (e?.name === 'AbortError') return
opts.onError?.(e)
} finally {
// ✅ adaptive backoff: hidden tab = viel seltener pollen
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
schedule(nextMs)
}
}
// sofort einmal
void tick()
// stop function
return () => {
stopped = true
clearTimer()
closeInFlight()
}
}

View File

@ -0,0 +1,144 @@
// frontend/src/lib/sseSingleton.ts
type Handler<T = any> = (data: T) => void
type Stream = {
url: string
es: EventSource | null
refs: number
listeners: Map<string, Set<Handler>>
dispatchers: Map<string, (ev: MessageEvent) => void>
}
const streams = new Map<string, Stream>()
let visHookInstalled = false
function ensureVisHook() {
if (visHookInstalled) return
visHookInstalled = true
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Tab hidden -> alle Streams schließen
for (const s of streams.values()) {
if (s.es) {
s.es.close()
s.es = null
s.dispatchers.clear()
}
}
} else {
// Tab visible -> Streams mit refs>0 wieder öffnen
for (const s of streams.values()) {
if (s.refs > 0 && !s.es) openStream(s)
}
}
})
}
function openStream(s: Stream) {
if (s.es) return
if (document.hidden) return
s.es = new EventSource(s.url)
// pro eventName genau EIN dispatcher registrieren
for (const [eventName, set] of s.listeners.entries()) {
const dispatcher = (ev: MessageEvent) => {
let data: any = null
try {
data = JSON.parse(String(ev.data ?? 'null'))
} catch {
// ignore
return
}
for (const fn of set) fn(data)
}
s.dispatchers.set(eventName, dispatcher)
s.es.addEventListener(eventName, dispatcher as any)
}
s.es.onerror = () => {
// EventSource reconnectet selbst.
// Bei Server-Restart kann es sinnvoll sein, kurz zu schließen -> Browser baut neu auf.
// (optional)
}
}
function closeStream(s: Stream) {
if (!s.es) return
s.es.close()
s.es = null
s.dispatchers.clear()
}
function getStream(url: string): Stream {
let s = streams.get(url)
if (!s) {
s = {
url,
es: null,
refs: 0,
listeners: new Map(),
dispatchers: new Map(),
}
streams.set(url, s)
}
return s
}
/**
* Subscribe auf SSE JSON Events.
* - öffnet pro URL genau eine EventSource (pro Tab)
* - schließt automatisch, wenn kein Subscriber mehr da ist
* - pausiert in hidden Tabs
*/
export function subscribeSSE<T = any>(url: string, eventName: string, handler: Handler<T>) {
ensureVisHook()
const s = getStream(url)
let set = s.listeners.get(eventName)
if (!set) {
set = new Set()
s.listeners.set(eventName, set)
}
set.add(handler as Handler)
s.refs += 1
// wenn Stream schon offen: ggf. Listener an EventSource nachrüsten
if (s.es) {
if (!s.dispatchers.has(eventName)) {
const dispatcher = (ev: MessageEvent) => {
let data: any = null
try {
data = JSON.parse(String(ev.data ?? 'null'))
} catch {
return
}
for (const fn of s.listeners.get(eventName) ?? []) fn(data)
}
s.dispatchers.set(eventName, dispatcher)
s.es.addEventListener(eventName, dispatcher as any)
}
} else {
openStream(s)
}
return () => {
const s2 = streams.get(url)
if (!s2) return
const set2 = s2.listeners.get(eventName)
if (set2) {
set2.delete(handler as Handler)
if (set2.size === 0) s2.listeners.delete(eventName)
}
s2.refs = Math.max(0, s2.refs - 1)
if (s2.refs === 0) {
closeStream(s2)
streams.delete(url)
}
}
}

View File

@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { ToastProvider } from './components/ui/ToastProvider.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <ToastProvider position="bottom-right" maxToasts={3} defaultDurationMs={3500}>
<App />
</ToastProvider>
</StrictMode>, </StrictMode>,
) )

View File

@ -2,16 +2,26 @@
export type RecordJob = { export type RecordJob = {
id: string id: string
sourceUrl: string sourceUrl?: string
output: string output: string
status: 'running' | 'finished' | 'failed' | 'stopped' status: 'running' | 'finished' | 'failed' | 'stopped'
startedAt: string startedAt: string
endedAt?: string endedAt?: string
// ✅ kommt aus dem Backend bei done-list (und ggf. später auch live)
durationSeconds?: number
sizeBytes?: number
// ✅ wird fürs UI genutzt (Stop/Finalize Fortschritt)
phase?: string
progress?: number
exitCode?: number exitCode?: number
error?: string error?: string
logTail?: string logTail?: string
} }
export type ParsedModel = { export type ParsedModel = {
input: string input: string
isUrl: boolean isUrl: boolean
@ -19,3 +29,24 @@ export type ParsedModel = {
path?: string path?: string
modelKey: string modelKey: string
} }
export type Model = {
id: string
input: string
isUrl: boolean
host?: string
path?: string
key: string
tags?: string
watching?: boolean
favorite?: boolean
hot?: boolean
keep?: boolean
liked?: boolean | null
createdAt?: string
updatedAt?: string
lastStream?: string
}

View File

@ -16,6 +16,10 @@ export default defineConfig({
target: 'http://10.0.1.25:9999', target: 'http://10.0.1.25:9999',
changeOrigin: true, changeOrigin: true,
}, },
'/generated': {
target: 'http://localhost:9999',
changeOrigin: true,
},
}, },
}, },
}) })