updated
This commit is contained in:
parent
ab3b55bcf8
commit
7d7387d8bb
222
backend/autostart_pause.go
Normal file
222
backend/autostart_pause.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
243
backend/chaturbate_biocontext.go
Normal file
243
backend/chaturbate_biocontext.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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.
@ -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
|
||||||
|
|||||||
@ -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=
|
||||||
|
|||||||
3163
backend/main.go
3163
backend/main.go
File diff suppressed because it is too large
Load Diff
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
185
backend/myfreecams_autostart.go
Normal file
185
backend/myfreecams_autostart.go
Normal 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.
@ -1,3 +1,5 @@
|
|||||||
|
// backend\sharedelete_other.go
|
||||||
|
|
||||||
//go:build !windows
|
//go:build !windows
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// backend\sharedelete_windows.go
|
||||||
|
|
||||||
//go:build windows
|
//go:build windows
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|||||||
1
backend/web/dist/assets/index-ZZZa38Qs.css
vendored
1
backend/web/dist/assets/index-ZZZa38Qs.css
vendored
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-ie8TR6qH.css
vendored
Normal file
1
backend/web/dist/assets/index-ie8TR6qH.css
vendored
Normal file
File diff suppressed because one or more lines are too long
332
backend/web/dist/assets/index-jMGU1_s9.js
vendored
Normal file
332
backend/web/dist/assets/index-jMGU1_s9.js
vendored
Normal file
File diff suppressed because one or more lines are too long
267
backend/web/dist/assets/index-zKk-xTZ_.js
vendored
267
backend/web/dist/assets/index-zKk-xTZ_.js
vendored
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.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>
|
||||||
|
|||||||
1991
frontend/src/App.tsx
1991
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
1109
frontend/src/components/ui/Downloads.tsx
Normal file
1109
frontend/src/components/ui/Downloads.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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 1–2 Frames geben
|
||||||
}
|
requestAnimationFrame(() => {
|
||||||
|
if (!tryAutoplayInline(domId)) {
|
||||||
|
requestAnimationFrame(() => tryAutoplayInline(domId))
|
||||||
|
}
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
onSwipeLeft={() => deleteVideo(j)}
|
onSwipeLeft={() => deleteVideo(j)}
|
||||||
onSwipeRight={() => keepVideo(j)}
|
onSwipeRight={() => keepVideo(j)}
|
||||||
|
|||||||
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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/<id>/</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/<id>/</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/components/ui/LazyMount.tsx
Normal file
55
frontend/src/components/ui/LazyMount.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
1298
frontend/src/components/ui/ModelDetails.tsx
Normal file
1298
frontend/src/components/ui/ModelDetails.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
274
frontend/src/components/ui/PerformanceMonitor.tsx
Normal file
274
frontend/src/components/ui/PerformanceMonitor.tsx
Normal 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
@ -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}
|
||||||
|
|
||||||
|
|||||||
726
frontend/src/components/ui/RecordJobActions.tsx
Normal file
726
frontend/src/components/ui/RecordJobActions.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
99
frontend/src/components/ui/TagBadge.tsx
Normal file
99
frontend/src/components/ui/TagBadge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
211
frontend/src/components/ui/ToastProvider.tsx
Normal file
211
frontend/src/components/ui/ToastProvider.tsx
Normal 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
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
22
frontend/src/components/ui/notify.ts
Normal file
22
frontend/src/components/ui/notify.ts
Normal 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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
174
frontend/src/lib/chaturbateOnlinePoller.ts
Normal file
174
frontend/src/lib/chaturbateOnlinePoller.ts
Normal 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: 200–300 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
144
frontend/src/lib/sseSingleton.ts
Normal file
144
frontend/src/lib/sseSingleton.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user