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
|
||||
|
||||
for {
|
||||
if isAutostartPaused() {
|
||||
// optional: Queue behalten oder leeren – ich würde sie behalten.
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
// ✅ Autostart nur wenn Feature aktiviert ist
|
||||
// (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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@ -46,24 +51,50 @@ type ChaturbateRoom struct {
|
||||
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 {
|
||||
Rooms []ChaturbateRoom
|
||||
FetchedAt time.Time
|
||||
LastErr string
|
||||
Rooms []ChaturbateRoom
|
||||
RoomsByUser map[string]ChaturbateRoom
|
||||
|
||||
// ✅ 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 (
|
||||
cbHTTP = &http.Client{Timeout: 12 * time.Second}
|
||||
cbHTTP = &http.Client{Timeout: 30 * time.Second}
|
||||
cbMu sync.RWMutex
|
||||
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) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, chaturbateOnlineRoomsURL, nil)
|
||||
if err != nil {
|
||||
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("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)))
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
|
||||
// Erwartet: JSON Array
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != '[' {
|
||||
return nil, fmt.Errorf("chaturbate online rooms: expected JSON array")
|
||||
}
|
||||
|
||||
var rooms []ChaturbateRoom
|
||||
if err := json.Unmarshal(data, &rooms); err != nil {
|
||||
rooms := make([]ChaturbateRoom, 0, 4096)
|
||||
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 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,
|
||||
// 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
|
||||
|
||||
// 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
|
||||
lastLoggedErr := ""
|
||||
|
||||
// Tags-Fill Throttle (lokal in der Funktion)
|
||||
var tagsMu sync.Mutex
|
||||
var tagsLast time.Time
|
||||
|
||||
// sofort ein initialer Tick
|
||||
first := time.NewTimer(0)
|
||||
defer first.Stop()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@ -115,18 +202,25 @@ func startChaturbateOnlinePoller() {
|
||||
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)
|
||||
cancel()
|
||||
|
||||
cbMu.Lock()
|
||||
if err != nil {
|
||||
// ❗️WICHTIG: bei Fehler NICHT fetchedAt aktualisieren,
|
||||
// ❗️bei Fehler NICHT fetchedAt aktualisieren,
|
||||
// sonst wirkt der Cache "frisch", obwohl rooms alt sind.
|
||||
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.RoomsByUser = nil
|
||||
cb.LiteByUser = nil
|
||||
|
||||
cbMu.Unlock()
|
||||
|
||||
@ -137,12 +231,31 @@ func startChaturbateOnlinePoller() {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ Erfolg: komplette Liste ersetzen + fetchedAt setzen
|
||||
// ✅ Erfolg: komplette Liste ersetzen + indices + fetchedAt setzen
|
||||
cb.LastErr = ""
|
||||
cb.Rooms = rooms
|
||||
cb.RoomsByUser = indexRoomsByUser(rooms)
|
||||
cb.LiteByUser = indexLiteByUser(rooms)
|
||||
cb.FetchedAt = time.Now()
|
||||
|
||||
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
|
||||
if lastLoggedErr != "" {
|
||||
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) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
|
||||
out := map[string]any{
|
||||
"enabled": false,
|
||||
"fetchedAt": time.Time{},
|
||||
"count": 0,
|
||||
"lastError": "",
|
||||
"rooms": []ChaturbateRoom{},
|
||||
})
|
||||
"rooms": []any{},
|
||||
}
|
||||
body, _ := json.Marshal(out)
|
||||
setCachedOnline(cacheKey, body)
|
||||
_, _ = w.Write(body)
|
||||
return
|
||||
}
|
||||
|
||||
// optional: ?refresh=1 triggert einen direkten Fetch (falls aktiviert)
|
||||
q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh")))
|
||||
wantRefresh := q == "1" || q == "true" || q == "yes"
|
||||
|
||||
// Snapshot des Caches
|
||||
// ---------------------------
|
||||
// Snapshot Cache (nur Lite-Index nutzen)
|
||||
// ---------------------------
|
||||
cbMu.RLock()
|
||||
rooms := cb.Rooms
|
||||
fetchedAt := cb.FetchedAt
|
||||
lastErr := cb.LastErr
|
||||
lastAttempt := cb.LastAttempt
|
||||
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
|
||||
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.)
|
||||
const staleAfter = 20 * time.Second
|
||||
isStale := fetchedAt.IsZero() || time.Since(fetchedAt) > staleAfter
|
||||
// ---------------------------
|
||||
// Refresh/Bootstrap-Strategie:
|
||||
// - Handler blockiert NICHT auf Remote-Fetch (Performance!)
|
||||
// - 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)
|
||||
freshRooms, err := fetchChaturbateOnlineRooms(ctx)
|
||||
cancel()
|
||||
cbMu.Lock()
|
||||
if err != nil {
|
||||
cb.LastErr = err.Error()
|
||||
|
||||
// ❗️WICHTIG: keine alten rooms weitergeben
|
||||
cb.Rooms = nil
|
||||
|
||||
// ❗️FetchedAt NICHT aktualisieren (bleibt letzte erfolgreiche Zeit)
|
||||
if shouldTriggerFetch {
|
||||
cbRefreshMu.Lock()
|
||||
if cbRefreshInFlight {
|
||||
cbRefreshMu.Unlock()
|
||||
} else {
|
||||
cb.LastErr = ""
|
||||
cb.Rooms = freshRooms
|
||||
cb.FetchedAt = time.Now()
|
||||
cbRefreshInFlight = true
|
||||
cbRefreshMu.Unlock()
|
||||
|
||||
// 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 = []ChaturbateRoom{}
|
||||
// ---------------------------
|
||||
// Rooms bauen (LITE, O(Anzahl requested Users))
|
||||
// ---------------------------
|
||||
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
|
||||
showFilter := strings.TrimSpace(r.URL.Query().Get("show"))
|
||||
if showFilter != "" {
|
||||
allowed := map[string]bool{}
|
||||
for _, s := range strings.Split(showFilter, ",") {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s != "" {
|
||||
allowed[s] = true
|
||||
outRooms := make([]outRoom, 0, len(users))
|
||||
|
||||
if onlySpecificUsers && liteByUser != nil {
|
||||
for _, u := range users {
|
||||
rm, ok := liteByUser[u]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(allowed) > 0 {
|
||||
filtered := make([]ChaturbateRoom, 0, len(rooms))
|
||||
for _, rm := range rooms {
|
||||
if allowed[strings.ToLower(strings.TrimSpace(rm.CurrentShow))] {
|
||||
filtered = append(filtered, rm)
|
||||
// show filter
|
||||
if len(allowedShow) > 0 {
|
||||
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
|
||||
if !allowedShow[s] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
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("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{
|
||||
"enabled": enabled,
|
||||
"enabled": true,
|
||||
"fetchedAt": fetchedAt,
|
||||
"count": len(rooms),
|
||||
"count": len(outRooms),
|
||||
"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
|
||||
)
|
||||
|
||||
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 (
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // 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/ncruces/go-strftime v0.1.9 // 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
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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/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/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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
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/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/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/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/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/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/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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
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.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-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-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-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.5.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.11.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.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.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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
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 = 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]) != "" {
|
||||
return m[1]
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
// fallback: bis zum letzten "_" (wie bisher)
|
||||
if i := strings.LastIndex(base, "_"); i > 0 {
|
||||
return base[:i]
|
||||
return strings.TrimSpace(base[:i])
|
||||
}
|
||||
if base == "" {
|
||||
return "—"
|
||||
}
|
||||
return base
|
||||
return strings.TrimSpace(base)
|
||||
}
|
||||
|
||||
func modelsEnsureLoaded() error {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// backend\models_api.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -128,13 +130,20 @@ func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult,
|
||||
idx[strings.ToLower(strings.TrimSpace(h))] = i
|
||||
}
|
||||
|
||||
need := []string{"url", "last_stream", "tags", "watch"}
|
||||
need := []string{"url", "last_stream", "tags"}
|
||||
for _, k := range need {
|
||||
if _, ok := idx[k]; !ok {
|
||||
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{}
|
||||
out := importResult{}
|
||||
|
||||
@ -171,6 +180,10 @@ func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult,
|
||||
lastStream := get("last_stream")
|
||||
|
||||
watchStr := get("watch")
|
||||
if watchStr == "" {
|
||||
watchStr = get("watched")
|
||||
}
|
||||
|
||||
watch := false
|
||||
if watchStr != "" {
|
||||
if n, err := strconv.Atoi(watchStr); err == nil {
|
||||
@ -274,19 +287,24 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
||||
|
||||
var req struct {
|
||||
ModelKey string `json:"modelKey"`
|
||||
Host string `json:"host,omitempty"`
|
||||
}
|
||||
|
||||
if err := modelsReadJSON(r, &req); err != nil {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(req.ModelKey)
|
||||
host := strings.ToLower(strings.TrimSpace(req.Host))
|
||||
host = strings.TrimPrefix(host, "www.")
|
||||
|
||||
if key == "" {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "modelKey fehlt"})
|
||||
return
|
||||
}
|
||||
|
||||
m, err := store.EnsureByModelKey(key)
|
||||
m, err := store.EnsureByHostModelKey(host, key)
|
||||
if err != nil {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@ -338,11 +356,38 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
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)
|
||||
if err != nil {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@ -58,13 +58,13 @@ type ParsedModelDTO struct {
|
||||
}
|
||||
|
||||
type ModelFlagsPatch struct {
|
||||
ID string `json:"id"`
|
||||
Watching *bool `json:"watching,omitempty"`
|
||||
Favorite *bool `json:"favorite,omitempty"`
|
||||
Hot *bool `json:"hot,omitempty"`
|
||||
Keep *bool `json:"keep,omitempty"`
|
||||
Liked *bool `json:"liked,omitempty"`
|
||||
ClearLiked bool `json:"clearLiked,omitempty"`
|
||||
Host string `json:"host,omitempty"` // ✅ neu
|
||||
ModelKey string `json:"modelKey,omitempty"` // ✅ wenn id fehlt
|
||||
ID string `json:"id,omitempty"` // ✅ optional
|
||||
|
||||
Watched *bool `json:"watched,omitempty"`
|
||||
Favorite *bool `json:"favorite,omitempty"`
|
||||
Liked *bool `json:"liked,omitempty"`
|
||||
}
|
||||
|
||||
type ModelStore struct {
|
||||
@ -79,6 +79,71 @@ type ModelStore struct {
|
||||
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:
|
||||
// - liefert ein bestehendes Model (best match) wenn vorhanden
|
||||
// - 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")
|
||||
}
|
||||
|
||||
// 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
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id
|
||||
FROM models
|
||||
WHERE lower(model_key) = lower(?)
|
||||
ORDER BY favorite DESC, updated_at DESC
|
||||
LIMIT 1;
|
||||
`, key).Scan(&existingID)
|
||||
SELECT id
|
||||
FROM models
|
||||
WHERE lower(trim(model_key)) = lower(trim(?))
|
||||
ORDER BY
|
||||
CASE WHEN is_url=1 THEN 1 ELSE 0 END DESC,
|
||||
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 != "" {
|
||||
return s.getByID(existingID)
|
||||
@ -141,6 +211,52 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
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:
|
||||
// - 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.
|
||||
@ -188,7 +304,9 @@ func (s *ModelStore) init() error {
|
||||
return err
|
||||
}
|
||||
// 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)
|
||||
_, _ = 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
|
||||
}
|
||||
|
||||
@ -230,6 +353,8 @@ CREATE TABLE IF NOT EXISTS models (
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
last_stream TEXT,
|
||||
|
||||
biocontext_json TEXT,
|
||||
biocontext_fetched_at TEXT,
|
||||
|
||||
watching INTEGER NOT NULL DEFAULT 0,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
@ -245,7 +370,6 @@ CREATE TABLE IF NOT EXISTS models (
|
||||
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 INDEX IF NOT EXISTS idx_models_updated ON models(updated_at);`)
|
||||
return nil
|
||||
@ -281,6 +405,19 @@ func ensureModelsColumns(db *sql.DB) error {
|
||||
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
|
||||
}
|
||||
|
||||
@ -321,6 +458,104 @@ func ptrLikedFromNull(n sql.NullInt64) *bool {
|
||||
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 {
|
||||
// DB leer?
|
||||
var cnt int
|
||||
@ -433,20 +668,172 @@ func bytesTrimSpace(b []byte) []byte {
|
||||
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 {
|
||||
if err := s.ensureInit(); err != nil {
|
||||
return []StoredModel{}
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags, COALESCE(last_stream,''),
|
||||
watching,favorite,hot,keep,liked,
|
||||
created_at,updated_at
|
||||
FROM models
|
||||
ORDER BY updated_at DESC;
|
||||
`)
|
||||
SELECT
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags, COALESCE(last_stream,''),
|
||||
watching,favorite,hot,keep,liked,
|
||||
created_at,updated_at
|
||||
FROM models
|
||||
ORDER BY updated_at DESC;
|
||||
`)
|
||||
if err != nil {
|
||||
return []StoredModel{}
|
||||
}
|
||||
@ -643,31 +1030,31 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
|
||||
return StoredModel{}, err
|
||||
}
|
||||
|
||||
if patch.Watching != nil {
|
||||
watching = boolToInt(*patch.Watching)
|
||||
// ✅ watched -> watching (DB)
|
||||
if patch.Watched != nil {
|
||||
watching = boolToInt(*patch.Watched)
|
||||
}
|
||||
|
||||
if patch.Favorite != nil {
|
||||
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)
|
||||
}
|
||||
// ✅ Business-Rule (robust, auch wenn Frontend es mal nicht mitsendet):
|
||||
// - Liked=true => Favorite=false
|
||||
// - Favorite=true => Liked wird gelöscht (NULL)
|
||||
|
||||
// ✅ Exklusivität serverseitig (robust):
|
||||
// - liked=true => favorite=false
|
||||
// - favorite=true => liked=false (nicht NULL)
|
||||
if patch.Liked != nil && *patch.Liked {
|
||||
favorite = int64(0)
|
||||
}
|
||||
if patch.Favorite != nil && *patch.Favorite {
|
||||
liked = sql.NullInt64{Valid: false}
|
||||
}
|
||||
if patch.ClearLiked {
|
||||
liked = sql.NullInt64{Valid: false}
|
||||
} else if patch.Liked != nil {
|
||||
liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)}
|
||||
// Wenn Frontend nicht explizit liked=true sendet, force liked=false
|
||||
if patch.Liked == nil || !*patch.Liked {
|
||||
liked = sql.NullInt64{Valid: true, Int64: 0}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
package main
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// backend\sharedelete_windows.go
|
||||
|
||||
//go:build windows
|
||||
|
||||
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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<script type="module" crossorigin src="/assets/index-zKk-xTZ_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ZZZa38Qs.css">
|
||||
<script type="module" crossorigin src="/assets/index-jMGU1_s9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ie8TR6qH.css">
|
||||
</head>
|
||||
<body>
|
||||
<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>> = {
|
||||
indigo: {
|
||||
primary:
|
||||
'bg-indigo-600 text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-indigo-600 ' +
|
||||
'dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500',
|
||||
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' +
|
||||
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
|
||||
secondary:
|
||||
'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',
|
||||
@ -51,10 +51,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
||||
'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',
|
||||
},
|
||||
|
||||
blue: {
|
||||
primary:
|
||||
'bg-blue-600 text-white shadow-xs hover:bg-blue-500 focus-visible:outline-blue-600 ' +
|
||||
'dark:bg-blue-500 dark:shadow-none dark:hover:bg-blue-400 dark:focus-visible:outline-blue-500',
|
||||
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' +
|
||||
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
|
||||
secondary:
|
||||
'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',
|
||||
@ -62,10 +63,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
||||
'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',
|
||||
},
|
||||
|
||||
emerald: {
|
||||
primary:
|
||||
'bg-emerald-600 text-white shadow-xs hover:bg-emerald-500 focus-visible:outline-emerald-600 ' +
|
||||
'dark:bg-emerald-500 dark:shadow-none dark:hover:bg-emerald-400 dark:focus-visible:outline-emerald-500',
|
||||
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' +
|
||||
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
|
||||
secondary:
|
||||
'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',
|
||||
@ -73,10 +75,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
||||
'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',
|
||||
},
|
||||
|
||||
red: {
|
||||
primary:
|
||||
'bg-red-600 text-white shadow-xs hover:bg-red-500 focus-visible:outline-red-600 ' +
|
||||
'dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400 dark:focus-visible:outline-red-500',
|
||||
'!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' +
|
||||
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
|
||||
secondary:
|
||||
'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',
|
||||
@ -84,10 +87,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
||||
'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',
|
||||
},
|
||||
|
||||
amber: {
|
||||
primary:
|
||||
'bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:outline-amber-500 ' +
|
||||
'dark:bg-amber-500 dark:shadow-none dark:hover:bg-amber-400 dark: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:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
|
||||
secondary:
|
||||
'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',
|
||||
@ -100,8 +104,22 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
||||
function Spinner() {
|
||||
return (
|
||||
<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" />
|
||||
<path d="M22 12a10 10 0 0 1-10 10" fill="none" stroke="currentColor" strokeWidth="4" opacity="0.9" />
|
||||
<circle
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -57,30 +57,36 @@ export default function ButtonGroup({
|
||||
onClick={() => onChange(it.id)}
|
||||
aria-pressed={active}
|
||||
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 && 'rounded-l-md',
|
||||
isLast && 'rounded-r-md',
|
||||
|
||||
// Base (wie im TailwindUI Beispiel)
|
||||
'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50',
|
||||
'dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
|
||||
|
||||
// Active-Style (dezente Hervorhebung)
|
||||
active && 'bg-gray-50 dark:bg-white/20',
|
||||
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-800 inset-ring-1 inset-ring-indigo-300 hover:bg-indigo-200 ' +
|
||||
'dark:bg-indigo-500/40 dark:text-indigo-100 dark:inset-ring-indigo-400/50 dark:hover:bg-indigo-500/50'
|
||||
: 'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 ' +
|
||||
'dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
|
||||
|
||||
// Disabled
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
|
||||
// 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}
|
||||
>
|
||||
{iconOnly && it.srLabel ? <span className="sr-only">{it.srLabel}</span> : null}
|
||||
|
||||
{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}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@ -71,13 +71,13 @@ export default function CookieModal({
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
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>
|
||||
|
||||
|
||||
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'
|
||||
|
||||
import * as React from 'react'
|
||||
@ -5,15 +7,15 @@ import Card from './Card'
|
||||
import type { RecordJob } from '../../types'
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
||||
import { flushSync } from 'react-dom'
|
||||
import {
|
||||
TrashIcon,
|
||||
FireIcon,
|
||||
BookmarkSquareIcon,
|
||||
StarIcon as StarOutlineIcon,
|
||||
HeartIcon as HeartOutlineIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { StarIcon as StarSolidIcon, HeartIcon as HeartSolidIcon } from '@heroicons/react/24/solid'
|
||||
StarIcon as StarSolidIcon,
|
||||
HeartIcon as HeartSolidIcon,
|
||||
EyeIcon as EyeSolidIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import TagBadge from './TagBadge'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import LazyMount from './LazyMount'
|
||||
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
@ -24,6 +26,9 @@ type InlinePlayState = { key: string; nonce: number } | null
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
isSmall: boolean
|
||||
teaserPlayback: 'still' | 'hover' | 'all'
|
||||
teaserAudio?: boolean
|
||||
hoverTeaserKey?: string | null
|
||||
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
@ -48,6 +53,7 @@ type Props = {
|
||||
lower: (s: string) => string
|
||||
|
||||
// callbacks/actions
|
||||
onHoverPreviewKeyChange?: (key: string | null) => void
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
openPlayer: (job: RecordJob) => void
|
||||
startInline: (key: string) => void
|
||||
@ -61,17 +67,42 @@ type Props = {
|
||||
|
||||
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>
|
||||
onToggleFavorite?: (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({
|
||||
rows,
|
||||
isSmall,
|
||||
|
||||
teaserPlayback,
|
||||
teaserAudio,
|
||||
hoverTeaserKey,
|
||||
blurPreviews,
|
||||
durations,
|
||||
teaserKey,
|
||||
@ -93,6 +124,7 @@ export default function FinishedDownloadsCardsView({
|
||||
formatBytes,
|
||||
lower,
|
||||
|
||||
onHoverPreviewKeyChange,
|
||||
onOpenPlayer,
|
||||
openPlayer,
|
||||
startInline,
|
||||
@ -107,16 +139,57 @@ export default function FinishedDownloadsCardsView({
|
||||
releasePlayingFile,
|
||||
|
||||
modelsByKey,
|
||||
activeTagSet,
|
||||
onToggleTagFilter,
|
||||
|
||||
onToggleHot,
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
onToggleWatch
|
||||
}: 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 (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{rows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
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 busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
@ -127,6 +200,11 @@ export default function FinishedDownloadsCardsView({
|
||||
const flags = modelsByKey[lower(model)]
|
||||
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(', ')
|
||||
|
||||
const statusCls =
|
||||
j.status === 'failed'
|
||||
@ -141,13 +219,17 @@ export default function FinishedDownloadsCardsView({
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
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 = (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
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',
|
||||
deletingKeys.has(k) &&
|
||||
'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}
|
||||
ref={registerTeaserHost(k)}
|
||||
className="relative aspect-video bg-black/5 dark:bg-white/5"
|
||||
onMouseEnter={isSmall ? undefined : () => onHoverPreviewKeyChange?.(k)}
|
||||
onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@ -175,22 +259,29 @@ export default function FinishedDownloadsCardsView({
|
||||
startInline(k)
|
||||
}}
|
||||
>
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||
durationSeconds={durations[k]}
|
||||
onDuration={handleDuration}
|
||||
className="w-full h-full"
|
||||
showPopover={false}
|
||||
blur={blurPreviews}
|
||||
animated={teaserKey === k}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
inlineVideo={inlineActive ? 'always' : false}
|
||||
inlineNonce={inlineNonce}
|
||||
inlineControls={inlineActive}
|
||||
inlineLoop={false}
|
||||
/>
|
||||
<LazyMount
|
||||
force={inlineActive}
|
||||
rootMargin={mobileRootMargin}
|
||||
placeholder={<div className="w-full h-full bg-black/5 dark:bg-white/5 animate-pulse" />}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||
className="w-full h-full"
|
||||
showPopover={false}
|
||||
blur={isSmall ? false : (inlineActive ? false : blurPreviews)}
|
||||
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
inlineVideo={inlineActive ? 'always' : false}
|
||||
inlineNonce={inlineNonce}
|
||||
inlineControls={inlineActive}
|
||||
inlineLoop={false}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
/>
|
||||
</LazyMount>
|
||||
|
||||
{/* Gradient overlay bottom */}
|
||||
<div
|
||||
@ -238,126 +329,58 @@ export default function FinishedDownloadsCardsView({
|
||||
)}
|
||||
|
||||
{/* Actions top-right */}
|
||||
<div className="absolute right-2 top-2 flex items-center gap-2">
|
||||
{(() => {
|
||||
const iconBtn =
|
||||
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
|
||||
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
{/* Favorite */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
disabled={busy || !onToggleFavorite}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
await onToggleFavorite?.(j)
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = isFav ? StarSolidIcon : StarOutlineIcon
|
||||
return <Icon className={cn('size-5', isFav ? 'text-amber-300' : 'text-white/90')} />
|
||||
})()}
|
||||
</button>
|
||||
|
||||
{/* Like */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
||||
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
||||
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
|
||||
className="absolute right-2 top-2 flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RecordJobActions
|
||||
job={j}
|
||||
variant="overlay"
|
||||
busy={busy}
|
||||
isHot={isHot}
|
||||
isFavorite={isFav}
|
||||
isLiked={isLiked}
|
||||
isWatching={isWatching}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleHot={
|
||||
onToggleHot
|
||||
? async (job) => {
|
||||
const file = baseName(job.output || '')
|
||||
if (file) {
|
||||
// wichtig gegen File-Lock beim Rename:
|
||||
await releasePlayingFile(file, { close: true })
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
}
|
||||
await onToggleHot(job)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showKeep={!isSmall}
|
||||
showDelete={!isSmall}
|
||||
onKeep={keepVideo}
|
||||
onDelete={deleteVideo}
|
||||
order={['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']}
|
||||
className="flex items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
@ -372,6 +395,90 @@ export default function FinishedDownloadsCardsView({
|
||||
</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',
|
||||
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>
|
||||
</Card>
|
||||
</div>
|
||||
@ -390,10 +497,14 @@ export default function FinishedDownloadsCardsView({
|
||||
ignoreFromBottomPx={110}
|
||||
onTap={() => {
|
||||
const domId = `inline-prev-${encodeURIComponent(k)}`
|
||||
flushSync(() => startInline(k))
|
||||
if (!tryAutoplayInline(domId)) {
|
||||
requestAnimationFrame(() => tryAutoplayInline(domId))
|
||||
}
|
||||
startInline(k)
|
||||
|
||||
// ✅ nach dem State-Update dem DOM 1–2 Frames geben
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryAutoplayInline(domId)) {
|
||||
requestAnimationFrame(() => tryAutoplayInline(domId))
|
||||
}
|
||||
})
|
||||
}}
|
||||
onSwipeLeft={() => deleteVideo(j)}
|
||||
onSwipeRight={() => keepVideo(j)}
|
||||
|
||||
@ -3,22 +3,25 @@
|
||||
import * as React from 'react'
|
||||
import type { RecordJob } from '../../types'
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import {
|
||||
TrashIcon,
|
||||
BookmarkSquareIcon,
|
||||
FireIcon,
|
||||
StarIcon as StarOutlineIcon,
|
||||
HeartIcon as HeartOutlineIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import {
|
||||
StarIcon as StarSolidIcon,
|
||||
HeartIcon as HeartSolidIcon,
|
||||
EyeIcon as EyeSolidIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import TagBadge from './TagBadge'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import LazyMount from './LazyMount'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
teaserPlayback: 'still' | 'hover' | 'all'
|
||||
teaserAudio?: boolean
|
||||
hoverTeaserKey?: string | null
|
||||
teaserKey: string | null
|
||||
|
||||
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
|
||||
keyFor: (j: RecordJob) => string
|
||||
@ -39,20 +42,27 @@ type Props = {
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||||
|
||||
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>
|
||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||
|
||||
|
||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||||
}
|
||||
|
||||
export default function FinishedDownloadsGalleryView({
|
||||
rows,
|
||||
blurPreviews,
|
||||
durations,
|
||||
teaserPlayback,
|
||||
teaserAudio,
|
||||
hoverTeaserKey,
|
||||
teaserKey,
|
||||
handleDuration,
|
||||
|
||||
keyFor,
|
||||
@ -70,253 +80,334 @@ export default function FinishedDownloadsGalleryView({
|
||||
|
||||
registerTeaserHost,
|
||||
|
||||
onHoverPreviewKeyChange,
|
||||
onOpenPlayer,
|
||||
deleteVideo,
|
||||
keepVideo,
|
||||
onToggleHot,
|
||||
lower,
|
||||
modelsByKey,
|
||||
activeTagSet,
|
||||
onToggleTagFilter,
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
onToggleWatch,
|
||||
}: 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 (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{rows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const modelKey = lower(model)
|
||||
const flags = modelsByKey[modelKey]
|
||||
const isFav = Boolean(flags?.favorite)
|
||||
const isLiked = flags?.liked === true
|
||||
const file = baseName(j.output || '')
|
||||
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'
|
||||
<>
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
|
||||
>
|
||||
{rows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
// Sound nur bei Hover auf genau diesem Teaser
|
||||
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
|
||||
const previewMuted = !allowSound
|
||||
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const deleted = deletedKeys.has(k)
|
||||
const model = modelNameFromOutput(j.output)
|
||||
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 (
|
||||
<div
|
||||
key={k}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={[
|
||||
'group relative overflow-hidden rounded-lg 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)
|
||||
}}
|
||||
>
|
||||
{/* Thumb */}
|
||||
const file = baseName(j.output || '')
|
||||
const isHot = file.startsWith('HOT ')
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const deleted = deletedKeys.has(k)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative aspect-video bg-black/5 dark:bg-white/5"
|
||||
ref={registerTeaserHost(k)}
|
||||
key={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
|
||||
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 */}
|
||||
{/* Thumb */}
|
||||
<div
|
||||
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 (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
|
||||
"
|
||||
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||
ref={registerTeaserHost(k)}
|
||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] opacity-90">
|
||||
<span className={`rounded px-1.5 py-0.5 font-semibold ${statusCls}`}>
|
||||
{j.status}
|
||||
</span>
|
||||
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
<LazyMount
|
||||
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">
|
||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
|
||||
<span className="rounded bg-black/40 px-1.5 py-0.5 font-medium">{size}</span>
|
||||
{/* Gradient overlay bottom */}
|
||||
<div
|
||||
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>
|
||||
|
||||
{/* Quick actions (top-right, wie Cards) */}
|
||||
<div
|
||||
className={[
|
||||
'absolute right-2 top-2 z-10 flex items-center gap-1.5',
|
||||
'opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 transition-opacity',
|
||||
].join(' ')}
|
||||
>
|
||||
{(() => {
|
||||
const iconBtn =
|
||||
'pointer-events-auto inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
|
||||
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' +
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Favorite */}
|
||||
{onToggleFavorite ? (
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
aria-label={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
title={isFav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
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}
|
||||
{/* Actions (top-right) */}
|
||||
<div
|
||||
className="absolute inset-x-2 top-2 z-10 flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RecordJobActions
|
||||
job={j}
|
||||
variant="overlay"
|
||||
busy={busy}
|
||||
collapseToMenu
|
||||
isHot={isHot}
|
||||
isFavorite={isFav}
|
||||
isLiked={isLiked}
|
||||
isWatching={isWatching}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleHot={onToggleHot}
|
||||
onKeep={keepVideo}
|
||||
onDelete={deleteVideo}
|
||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']}
|
||||
className="w-full justify-end gap-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
|
||||
{/* Footer / Meta */}
|
||||
<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 ? (
|
||||
<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 className="mt-0.5 flex items-center gap-2 min-w-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="truncate">{stripHotPrefix(file) || '—'}</span>
|
||||
|
||||
{isHot ? (
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// frontend\src\components\ui\FinishedDownloadsTableView.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Table, { type Column, type SortState } from './Table'
|
||||
import type { RecordJob } from '../../types'
|
||||
|
||||
@ -31,7 +32,8 @@ export default function FinishedDownloadsTableView({
|
||||
striped
|
||||
fullWidth
|
||||
stickyHeader
|
||||
compact
|
||||
compact={false}
|
||||
card
|
||||
sort={sort}
|
||||
onSortChange={onSortChange}
|
||||
onRowClick={onRowClick}
|
||||
|
||||
@ -20,7 +20,6 @@ export type FinishedVideoPreviewProps = {
|
||||
animated?: boolean
|
||||
animatedMode?: AnimatedMode
|
||||
animatedTrigger?: AnimatedTrigger
|
||||
active?: boolean
|
||||
|
||||
/** nur für frames */
|
||||
autoTickMs?: number
|
||||
@ -70,8 +69,6 @@ export default function FinishedVideoPreview({
|
||||
animated = false,
|
||||
animatedMode = 'frames',
|
||||
animatedTrigger = 'always',
|
||||
active,
|
||||
|
||||
autoTickMs = 15000,
|
||||
thumbStepSec,
|
||||
thumbSpread,
|
||||
@ -107,10 +104,15 @@ export default function FinishedVideoPreview({
|
||||
const [videoOk, setVideoOk] = useState(true)
|
||||
const [metaLoaded, setMetaLoaded] = useState(false)
|
||||
|
||||
const [teaserReady, setTeaserReady] = useState(false)
|
||||
|
||||
// inView (Viewport)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
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
|
||||
const [localTick, setLocalTick] = useState(0)
|
||||
|
||||
@ -139,9 +141,6 @@ export default function FinishedVideoPreview({
|
||||
[file]
|
||||
)
|
||||
|
||||
// ✅ Teaser-Video (vorgerendert)
|
||||
const isActive = active !== undefined ? Boolean(active) : true
|
||||
|
||||
const hasDuration =
|
||||
typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||
|
||||
@ -162,6 +161,10 @@ export default function FinishedVideoPreview({
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTeaserReady(false)
|
||||
}, [previewId, assetNonce])
|
||||
|
||||
useEffect(() => {
|
||||
const onRelease = (ev: any) => {
|
||||
const f = String(ev?.detail?.file ?? '')
|
||||
@ -184,9 +187,17 @@ export default function FinishedVideoPreview({
|
||||
if (!el) return
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => setInView(Boolean(entries[0]?.isIntersecting)),
|
||||
{ threshold: 0.1 }
|
||||
(entries) => {
|
||||
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)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
@ -281,6 +292,9 @@ export default function FinishedVideoPreview({
|
||||
inlineMode === '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)
|
||||
const clipTimes = useMemo(() => {
|
||||
if (!animated) return []
|
||||
@ -308,6 +322,28 @@ export default function FinishedVideoPreview({
|
||||
const clipIdxRef = 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
|
||||
useEffect(() => {
|
||||
const v = teaserRef.current
|
||||
@ -371,71 +407,90 @@ export default function FinishedVideoPreview({
|
||||
onBlur={wantsHover ? () => setHovered(false) : undefined}
|
||||
>
|
||||
{/* 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 ? (
|
||||
<video
|
||||
{...commonVideoProps}
|
||||
ref={inlineRef}
|
||||
key={`inline-${previewId}-${inlineNonce}`}
|
||||
src={videoSrc}
|
||||
className={[
|
||||
'w-full h-full object-cover bg-black',
|
||||
'absolute inset-0 w-full h-full object-cover',
|
||||
blurCls,
|
||||
inlineControls ? 'pointer-events-auto' : 'pointer-events-none',
|
||||
].filter(Boolean).join(' ')}
|
||||
autoPlay
|
||||
muted={muted}
|
||||
controls={inlineControls}
|
||||
loop={inlineLoop}
|
||||
poster={thumbSrc || undefined}
|
||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : teaserActive && animatedMode === 'teaser' ? (
|
||||
/* 2a) ✅ Teaser MP4 (vorgerendert) */
|
||||
) : null}
|
||||
|
||||
{/* ✅ Teaser MP4: nur im Viewport (teaserActive) – Thumb bleibt drunter sichtbar */}
|
||||
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
|
||||
<video
|
||||
ref={teaserRef}
|
||||
ref={teaserMp4Ref}
|
||||
key={`teaser-mp4-${previewId}`}
|
||||
src={teaserSrc}
|
||||
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
|
||||
muted
|
||||
className={[
|
||||
'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
|
||||
preload="metadata"
|
||||
autoPlay
|
||||
loop
|
||||
poster={thumbSrc || undefined}
|
||||
// ❗️kein onLoadedMetadata -> sonst würdest du Teaser-Länge als Dauer speichern
|
||||
preload="metadata"
|
||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||
onLoadedData={() => setTeaserReady(true)}
|
||||
onPlaying={() => setTeaserReady(true)}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : teaserActive && animatedMode === 'clips' ? (
|
||||
/* 2b) Legacy: Teaser Clips (1s Segmente) aus Vollvideo */
|
||||
) : null}
|
||||
|
||||
{/* ✅ Legacy clips (falls noch genutzt) */}
|
||||
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
|
||||
<video
|
||||
ref={teaserRef}
|
||||
key={`clips-${previewId}-${clipTimesKey}`}
|
||||
src={videoSrc}
|
||||
className={['w-full h-full object-cover bg-black pointer-events-none', blurCls].filter(Boolean).join(' ')}
|
||||
muted
|
||||
className={[
|
||||
'absolute inset-0 w-full h-full object-cover pointer-events-none',
|
||||
blurCls,
|
||||
].filter(Boolean).join(' ')}
|
||||
muted={muted}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={thumbSrc || undefined}
|
||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : thumbSrc && thumbOk ? (
|
||||
/* 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" />
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Metadaten nur laden wenn nötig (und nicht inline) */}
|
||||
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
|
||||
<video
|
||||
src={videoSrc}
|
||||
preload="metadata"
|
||||
muted
|
||||
muted={muted}
|
||||
playsInline
|
||||
className="hidden"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
|
||||
@ -49,6 +49,8 @@ export default function GenerateAssetsTask({ onFinished }: Props) {
|
||||
const [state, setState] = useState<TaskState | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [stopping, setStopping] = useState(false)
|
||||
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
@ -78,7 +80,7 @@ export default function GenerateAssetsTask({ onFinished }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!state?.running) return
|
||||
const t = window.setInterval(loadStatus, 2000)
|
||||
const t = window.setInterval(loadStatus, 1200)
|
||||
return () => window.clearInterval(t)
|
||||
}, [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 total = state?.total ?? 0
|
||||
const done = state?.done ?? 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 (
|
||||
<div className="rounded-md border border-gray-200 p-3 dark:border-white/10">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div
|
||||
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="text-sm font-medium text-gray-900 dark:text-white">Fehlende Assets generieren</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 dark:text-white/70">
|
||||
Erzeugt pro fertiger Datei unter <span className="font-mono">/generated/<id>/</span> die Dateien{' '}
|
||||
<span className="font-mono">thumbs.jpg</span> und <span className="font-mono">preview.mp4</span>.
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Assets-Generator
|
||||
</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>
|
||||
|
||||
<Button variant="primary" onClick={start} disabled={starting || running}>
|
||||
{running ? 'Läuft…' : 'Generieren'}
|
||||
</Button>
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{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>
|
||||
|
||||
{error ? <div className="mt-2 text-xs text-red-600 dark:text-red-400">{error}</div> : null}
|
||||
{state?.error ? <div className="mt-2 text-xs text-amber-600 dark:text-amber-400">{state.error}</div> : null}
|
||||
{/* Errors */}
|
||||
{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 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="mt-4 space-y-3">
|
||||
<ProgressBar
|
||||
value={pct}
|
||||
showPercent
|
||||
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>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="mt-4 text-xs text-gray-600 dark:text-white/70">
|
||||
Status wird geladen…
|
||||
</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'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
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({
|
||||
src,
|
||||
muted = DEFAULT_INLINE_MUTED,
|
||||
@ -15,50 +20,138 @@ export default function LiveHlsVideo({
|
||||
}) {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
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(() => {
|
||||
let cancelled = false
|
||||
let hls: Hls | null = null
|
||||
let stallTimer: number | null = null
|
||||
let watchdogTimer: number | null = null
|
||||
|
||||
const videoEl = ref.current
|
||||
if (!videoEl) return
|
||||
|
||||
const video = videoEl // <- jetzt: HTMLVideoElement (nicht null)
|
||||
|
||||
setBroken(false)
|
||||
setBrokenReason(null)
|
||||
|
||||
// ✅ zentral
|
||||
applyInlineVideoPolicy(videoEl, { muted })
|
||||
applyInlineVideoPolicy(video, { muted })
|
||||
|
||||
async function waitForManifest() {
|
||||
const started = Date.now()
|
||||
while (!cancelled && Date.now() - started < 20_000) {
|
||||
try {
|
||||
const r = await fetch(src, { cache: 'no-store' })
|
||||
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
|
||||
const cleanupTimers = () => {
|
||||
if (stallTimer) window.clearTimeout(stallTimer)
|
||||
if (watchdogTimer) window.clearInterval(watchdogTimer)
|
||||
stallTimer = null
|
||||
watchdogTimer = null
|
||||
}
|
||||
|
||||
async function start(video: HTMLVideoElement) {
|
||||
const ok = await waitForManifest()
|
||||
if (!ok || cancelled) {
|
||||
if (!cancelled) setBroken(true)
|
||||
const hardReloadNative = () => {
|
||||
if (cancelled) return
|
||||
cleanupTimers()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Safari kann HLS nativ
|
||||
|
||||
// ✅ Safari / iOS: Native HLS
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
video.src = manifestUrl
|
||||
video.load()
|
||||
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()) {
|
||||
setBroken(true)
|
||||
return
|
||||
@ -67,14 +160,27 @@ export default function LiveHlsVideo({
|
||||
hls = new Hls({
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 2,
|
||||
maxBufferLength: 4,
|
||||
maxBufferLength: 8, // etwas entspannter
|
||||
})
|
||||
|
||||
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.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 () => {
|
||||
cancelled = true
|
||||
cleanupTimers()
|
||||
try {
|
||||
nativeCleanup?.()
|
||||
} catch {}
|
||||
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 (
|
||||
<video
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment } from 'react'
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
children?: ReactNode
|
||||
footer?: 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({
|
||||
@ -19,6 +26,7 @@ export default function Modal({
|
||||
children,
|
||||
footer,
|
||||
icon,
|
||||
width = 'max-w-lg',
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
@ -26,34 +34,83 @@ export default function Modal({
|
||||
{/* Backdrop */}
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300" enterFrom="opacity-0" enterTo="opacity-100"
|
||||
leave="ease-in duration-200" leaveFrom="opacity-100" leaveTo="opacity-0"
|
||||
enter="ease-out duration-300"
|
||||
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" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center px-4 py-6 sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{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 className="fixed inset-0 z-50 overflow-y-auto px-4 py-6 sm:px-6">
|
||||
<div className="min-h-full flex items-start justify-center sm:items-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{title && (
|
||||
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{children}</div>
|
||||
{footer && <div className="mt-6 flex justify-end gap-3">{footer}</div>}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
||||
{/* Body (scrollable) */}
|
||||
<div className="px-6 pb-6 pt-4 text-sm text-gray-700 dark:text-gray-300 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer ? (
|
||||
<div className="px-6 py-4 border-t border-gray-200/70 dark:border-white/10 flex justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</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
|
||||
autoTickMs?: number
|
||||
blur?: boolean
|
||||
className?: string
|
||||
fit?: 'cover' | 'contain'
|
||||
|
||||
// ✅ NEU: aligned refresh (z.B. exakt bei 10s/20s/30s seit startedAt)
|
||||
alignStartAt?: string | number | Date
|
||||
alignEndAt?: string | number | Date | null
|
||||
alignEveryMs?: number
|
||||
|
||||
// ✅ NEU: schneller Retry am Anfang (nur bei Running sinnvoll)
|
||||
fastRetryMs?: number
|
||||
fastRetryMax?: number
|
||||
fastRetryWindowMs?: number
|
||||
|
||||
}
|
||||
|
||||
export default function ModelPreview({
|
||||
@ -26,14 +34,29 @@ export default function ModelPreview({
|
||||
alignStartAt,
|
||||
alignEndAt = null,
|
||||
alignEveryMs,
|
||||
fastRetryMs,
|
||||
fastRetryMax,
|
||||
fastRetryWindowMs,
|
||||
className,
|
||||
}: Props) {
|
||||
|
||||
const [pageVisible, setPageVisible] = useState(() => {
|
||||
if (typeof document === 'undefined') return true
|
||||
return !document.hidden
|
||||
})
|
||||
|
||||
const blurCls = blur ? 'blur-md' : ''
|
||||
const [localTick, setLocalTick] = useState(0)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
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 => {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||
if (v instanceof Date) return v.getTime()
|
||||
@ -41,12 +64,34 @@ export default function ModelPreview({
|
||||
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(() => {
|
||||
// Wenn Parent tickt, kein lokales Ticken
|
||||
if (typeof thumbTick === 'number') return
|
||||
|
||||
// Nur animieren, wenn im Sichtbereich UND Tab sichtbar
|
||||
if (!inView || document.hidden) return
|
||||
if (!inView || !pageVisible) return
|
||||
|
||||
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
|
||||
if (!Number.isFinite(period) || period <= 0) return
|
||||
@ -84,7 +129,8 @@ export default function ModelPreview({
|
||||
}, period)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [thumbTick, autoTickMs, inView, alignStartAt, alignEndAt, alignEveryMs])
|
||||
}, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
@ -93,14 +139,16 @@ export default function ModelPreview({
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
setInView(Boolean(entry?.isIntersecting))
|
||||
setInView(Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0)))
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: 0.1,
|
||||
threshold: 0, // wichtiger: nicht 0.1
|
||||
rootMargin: '300px 0px', // preload: 300px vor/nach Viewport
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
@ -112,6 +160,15 @@ export default function ModelPreview({
|
||||
setImgError(false)
|
||||
}, [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=...)
|
||||
const thumb = useMemo(
|
||||
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${tick}`,
|
||||
@ -131,7 +188,7 @@ export default function ModelPreview({
|
||||
open && (
|
||||
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
||||
<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 */}
|
||||
<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 */}
|
||||
<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"
|
||||
title="Vorschau schließen"
|
||||
onClick={(e) => {
|
||||
@ -160,19 +217,49 @@ export default function ModelPreview({
|
||||
>
|
||||
<div
|
||||
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 ? (
|
||||
<img
|
||||
src={thumb}
|
||||
loading="lazy"
|
||||
loading={inView ? 'eager' : 'lazy'}
|
||||
fetchPriority={inView ? 'high' : 'auto'}
|
||||
alt=""
|
||||
className={['w-full h-full object-cover', blurCls].filter(Boolean).join(' ')}
|
||||
onError={() => setImgError(true)}
|
||||
onLoad={() => setImgError(false)}
|
||||
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
|
||||
onLoad={() => {
|
||||
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
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\components\ui\ModelsTab.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
@ -7,6 +9,9 @@ import Button from './Button'
|
||||
import Table, { type Column } from './Table'
|
||||
import Modal from './Modal'
|
||||
import Pagination from './Pagination'
|
||||
import TagBadge from './TagBadge'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import type { RecordJob } from '../../types'
|
||||
|
||||
|
||||
type ParsedModel = {
|
||||
@ -81,7 +86,7 @@ function splitTags(raw?: string): string[] {
|
||||
if (!raw) return []
|
||||
|
||||
const tags = raw
|
||||
.split(',')
|
||||
.split(/[\n,;|]+/g)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
@ -93,24 +98,36 @@ function splitTags(raw?: string): string[] {
|
||||
return uniq
|
||||
}
|
||||
|
||||
|
||||
function TagBadge({
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
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 canonicalHost(raw?: string): string {
|
||||
return String(raw ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^www\./, '')
|
||||
}
|
||||
|
||||
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({
|
||||
title,
|
||||
@ -153,6 +170,9 @@ function IconToggle({
|
||||
|
||||
export default function ModelsTab() {
|
||||
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 [err, setErr] = React.useState<string | null>(null)
|
||||
|
||||
@ -160,6 +180,23 @@ export default function ModelsTab() {
|
||||
const [page, setPage] = React.useState(1)
|
||||
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 [parsed, setParsed] = React.useState<ParsedModel | null>(null)
|
||||
const [parseError, setParseError] = React.useState<string | null>(null)
|
||||
@ -191,6 +228,7 @@ export default function ModelsTab() {
|
||||
setImportOpen(false)
|
||||
setImportFile(null)
|
||||
await refresh()
|
||||
window.dispatchEvent(new Event('models-changed'))
|
||||
} catch (e: any) {
|
||||
setImportErr(e?.message ?? String(e))
|
||||
} 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 = () => {
|
||||
setImportErr(null)
|
||||
setImportMsg(null)
|
||||
@ -210,7 +256,7 @@ export default function ModelsTab() {
|
||||
setLoading(true)
|
||||
setErr(null)
|
||||
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 : [])
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? String(e))
|
||||
@ -220,11 +266,36 @@ export default function ModelsTab() {
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh()
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
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)
|
||||
return () => window.removeEventListener('models-changed', onChanged as any)
|
||||
}, [refresh])
|
||||
@ -281,13 +352,29 @@ export default function ModelsTab() {
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const needle = deferredQ.trim().toLowerCase()
|
||||
if (!needle) return models
|
||||
return modelsWithHay.filter(x => x.hay.includes(needle)).map(x => x.m)
|
||||
}, [models, modelsWithHay, deferredQ])
|
||||
|
||||
// 1) Text-Filter (q)
|
||||
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(() => {
|
||||
setPage(1)
|
||||
}, [q])
|
||||
}, [q, tagFilter])
|
||||
|
||||
const totalItems = filtered.length
|
||||
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
||||
@ -322,6 +409,7 @@ export default function ModelsTab() {
|
||||
})
|
||||
setInput('')
|
||||
setParsed(null)
|
||||
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: saved } }))
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? String(e))
|
||||
} 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)
|
||||
|
||||
// ✅ 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 {
|
||||
const updated = await apiJSON<StoredModel>('/api/models/flags', {
|
||||
const res = await fetch('/api/models/flags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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) => {
|
||||
const idx = prev.findIndex((m) => m.id === updated.id)
|
||||
if (idx === -1) return prev
|
||||
@ -344,8 +483,18 @@ export default function ModelsTab() {
|
||||
next[idx] = updated
|
||||
return next
|
||||
})
|
||||
|
||||
// ✅ App informieren: updated Model als detail => kein /api/models/list
|
||||
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
||||
} 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))
|
||||
} finally {
|
||||
delete flagsInFlightRef.current[id]
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,7 +532,7 @@ export default function ModelsTab() {
|
||||
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||
onClick={(e) => {
|
||||
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')}>
|
||||
@ -403,7 +552,7 @@ export default function ModelsTab() {
|
||||
patch(m.id, { favorite: false })
|
||||
} else {
|
||||
// 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>}
|
||||
@ -417,7 +566,7 @@ export default function ModelsTab() {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (liked) {
|
||||
patch(m.id, { clearLiked: true })
|
||||
patch(m.id, { liked: false })
|
||||
} else {
|
||||
// exklusiv: ♥ setzt Favorit zurück
|
||||
patch(m.id, { liked: true, favorite: false })
|
||||
@ -429,7 +578,6 @@ export default function ModelsTab() {
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'model',
|
||||
header: 'Model',
|
||||
@ -443,18 +591,27 @@ export default function ModelsTab() {
|
||||
{
|
||||
key: 'url',
|
||||
header: 'URL',
|
||||
cell: (m) => (
|
||||
<a
|
||||
href={m.input}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block max-w-[520px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={m.input}
|
||||
>
|
||||
{m.input}
|
||||
</a>
|
||||
),
|
||||
cell: (m) => {
|
||||
const href = modelHref(m)
|
||||
const label = href ?? (m.isUrl ? (m.input || '—') : '—')
|
||||
|
||||
if (!href) {
|
||||
return <span className="text-gray-400 dark:text-gray-500">—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<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',
|
||||
@ -471,9 +628,13 @@ export default function ModelsTab() {
|
||||
{m.keep ? badge(true, '📌 Behalten') : null}
|
||||
|
||||
{shown.map((t) => (
|
||||
<TagBadge key={t} title={t}>
|
||||
{t}
|
||||
</TagBadge>
|
||||
<TagBadge
|
||||
key={t}
|
||||
tag={t}
|
||||
title={t}
|
||||
active={activeTagSet.has(t.toLowerCase())}
|
||||
onClick={toggleTagFilter}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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 (
|
||||
<div className="space-y-4">
|
||||
@ -531,23 +706,72 @@ export default function ModelsTab() {
|
||||
|
||||
<Card
|
||||
header={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Models ({filtered.length})
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-2 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
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 className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={openImport}>
|
||||
Importieren
|
||||
</Button>
|
||||
{tagFilter.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Tag-Filter:
|
||||
</span>
|
||||
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Suchen…"
|
||||
className="w-[220px] rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{tagFilter.map((t) => (
|
||||
<TagBadge
|
||||
key={t}
|
||||
tag={t}
|
||||
active
|
||||
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>
|
||||
}
|
||||
noBodyPadding
|
||||
@ -560,7 +784,10 @@ export default function ModelsTab() {
|
||||
compact
|
||||
fullWidth
|
||||
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
|
||||
|
||||
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
|
||||
value?: number | null // 0..100
|
||||
indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent
|
||||
showPercent?: boolean // zeigt rechts “xx%” (nur determinate)
|
||||
rightLabel?: React.ReactNode // optionaler Text links unten (z.B. 3/10)
|
||||
steps?: string[] // optional: Step-Labels (wie in deinem Beispiel)
|
||||
currentStep?: number // 0-basiert, z.B. 1 = Step 2 aktiv
|
||||
showPercent?: boolean // zeigt “xx%” (nur determinate)
|
||||
rightLabel?: React.ReactNode // optionaler Text unter der Bar (z.B. 3/10)
|
||||
steps?: string[] // optional: Step-Labels
|
||||
currentStep?: number // 0-basiert
|
||||
size?: 'sm' | 'md'
|
||||
className?: string
|
||||
}
|
||||
@ -42,15 +42,30 @@ export default function ProgressBar({
|
||||
return i <= currentStep
|
||||
}
|
||||
|
||||
const showPct = showPercent && !indeterminate
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label ? (
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{label}
|
||||
</p>
|
||||
{/* ✅ Label + Prozent jetzt ÜBER der Bar */}
|
||||
{(label || showPct) ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{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}
|
||||
|
||||
<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">
|
||||
{indeterminate ? (
|
||||
<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>
|
||||
|
||||
{(rightLabel || (showPercent && !indeterminate)) ? (
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>{rightLabel ?? ''}</span>
|
||||
{showPercent && !indeterminate ? <span>{Math.round(clamped)}%</span> : <span />}
|
||||
{/* ✅ rightLabel bleibt unter der Bar (links), Prozent ist jetzt oben */}
|
||||
{rightLabel ? (
|
||||
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{rightLabel}
|
||||
</div>
|
||||
) : 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'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
@ -18,7 +18,15 @@ type RecorderSettings = {
|
||||
|
||||
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
|
||||
useChaturbateApi?: boolean
|
||||
useMyFreeCamsWatcher?: boolean
|
||||
autoDeleteSmallDownloads?: boolean
|
||||
autoDeleteSmallDownloadsBelowMB?: number
|
||||
blurPreviews?: boolean
|
||||
teaserPlayback?: 'still' | 'hover' | 'all'
|
||||
teaserAudio?: boolean
|
||||
|
||||
lowDiskPauseBelowGB?: number
|
||||
|
||||
}
|
||||
|
||||
const DEFAULTS: RecorderSettings = {
|
||||
@ -32,7 +40,13 @@ const DEFAULTS: RecorderSettings = {
|
||||
autoStartAddedDownloads: true,
|
||||
|
||||
useChaturbateApi: false,
|
||||
useMyFreeCamsWatcher: false,
|
||||
autoDeleteSmallDownloads: false,
|
||||
autoDeleteSmallDownloadsBelowMB: 50,
|
||||
blurPreviews: false,
|
||||
teaserPlayback: 'hover',
|
||||
teaserAudio: false,
|
||||
lowDiskPauseBelowGB: 5,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@ -66,7 +80,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
|
||||
|
||||
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,
|
||||
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
|
||||
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
|
||||
lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
@ -125,7 +145,19 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
|
||||
|
||||
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 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)
|
||||
try {
|
||||
@ -138,9 +170,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
ffmpegPath,
|
||||
autoAddToDownloadList,
|
||||
autoStartAddedDownloads,
|
||||
|
||||
useChaturbateApi,
|
||||
useMyFreeCamsWatcher,
|
||||
autoDeleteSmallDownloads,
|
||||
autoDeleteSmallDownloadsBelowMB,
|
||||
blurPreviews,
|
||||
teaserPlayback,
|
||||
teaserAudio,
|
||||
lowDiskPauseBelowGB,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
@ -162,6 +199,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<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>
|
||||
<Button variant="primary" onClick={save} disabled={saving}>
|
||||
Speichern
|
||||
@ -171,72 +211,121 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
grayBody
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Alerts */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aufnahme-Ordner */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">Aufnahme-Ordner</label>
|
||||
<div className="sm:col-span-9 flex gap-2">
|
||||
<input
|
||||
value={value.recordDir}
|
||||
onChange={(e) => setValue((v) => ({ ...v, recordDir: e.target.value }))}
|
||||
placeholder="records (oder absolut: C:\records / /mnt/data/records)"
|
||||
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('record')} disabled={saving || browsing !== null}>
|
||||
Durchsuchen...
|
||||
</Button>
|
||||
{/* ✅ Tasks (als erstes) */}
|
||||
<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="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Tasks</div>
|
||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Generiere fehlende Vorschauen/Metadaten (z.B. Duration via meta.json) für schnelle Listenansichten.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<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">
|
||||
Utilities
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<GenerateAssetsTask onFinished={onAssetsGenerated} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fertige Downloads */}
|
||||
<div className="grid grid-cols-1 gap-4 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-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>
|
||||
{/* Paths */}
|
||||
<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">Pfad-Einstellungen</div>
|
||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ffmpeg.exe */}
|
||||
<div className="grid grid-cols-1 gap-4 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-md px-3 py-2 text-sm bg-white text-gray-900
|
||||
dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
<Button variant="secondary" onClick={() => browse('ffmpeg')} disabled={saving || browsing !== null}>
|
||||
Durchsuchen...
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
{/* Aufnahme-Ordner */}
|
||||
<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">
|
||||
Aufnahme-Ordner
|
||||
</label>
|
||||
<div className="sm:col-span-9 flex gap-2">
|
||||
<input
|
||||
value={value.recordDir}
|
||||
onChange={(e) => setValue((v) => ({ ...v, recordDir: e.target.value }))}
|
||||
placeholder="records (oder absolut: C:\records / /mnt/data/records)"
|
||||
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('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>
|
||||
|
||||
{/* 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">
|
||||
<LabeledSwitch
|
||||
checked={!!value.autoAddToDownloadList}
|
||||
@ -244,7 +333,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
autoAddToDownloadList: checked,
|
||||
// wenn aus, Autostart gleich mit aus
|
||||
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."
|
||||
/>
|
||||
|
||||
<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
|
||||
checked={!!value.blurPreviews}
|
||||
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
|
||||
label="Vorschaubilder blurren"
|
||||
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="mt-2 border-t border-gray-200 pt-4 dark:border-white/10">
|
||||
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">Tasks</div>
|
||||
<GenerateAssetsTask onFinished={onAssetsGenerated} />
|
||||
<div className="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">Teaser abspielen</div>
|
||||
<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>
|
||||
</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'
|
||||
|
||||
import * as React from 'react'
|
||||
@ -58,6 +60,13 @@ export type SwipeCardProps = {
|
||||
*/
|
||||
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 = {
|
||||
@ -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',
|
||||
},
|
||||
thresholdPx = 120,
|
||||
thresholdRatio = 0.35,
|
||||
//thresholdPx = 120,
|
||||
thresholdPx = 180,
|
||||
//thresholdRatio = 0.35,
|
||||
thresholdRatio = 0.1,
|
||||
ignoreFromBottomPx = 72,
|
||||
ignoreSelector = '[data-swipe-ignore]',
|
||||
snapMs = 180,
|
||||
commitMs = 180,
|
||||
tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]',
|
||||
},
|
||||
ref
|
||||
) {
|
||||
|
||||
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<{
|
||||
id: number | null
|
||||
x: number
|
||||
y: number
|
||||
dragging: 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 [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
|
||||
const [animMs, setAnimMs] = React.useState<number>(0)
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
// ✅ rAF cleanup
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
dxRef.current = 0
|
||||
|
||||
setAnimMs(snapMs)
|
||||
setDx(0)
|
||||
setArmedDir(null)
|
||||
@ -126,13 +153,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
|
||||
const commit = React.useCallback(
|
||||
async (dir: 'left' | 'right', runAction: boolean) => {
|
||||
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
|
||||
const el = cardRef.current
|
||||
const w = el?.offsetWidth || 360
|
||||
|
||||
// rausfliegen lassen
|
||||
setAnimMs(commitMs)
|
||||
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
|
||||
if (runAction) {
|
||||
@ -200,18 +235,51 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
ref={cardRef}
|
||||
className="relative"
|
||||
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,
|
||||
touchAction: 'pan-y', // wichtig: vertikales Scrollen zulassen
|
||||
touchAction: 'pan-y',
|
||||
willChange: dx !== 0 ? 'transform' : undefined,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
|
||||
// ✅ 1) Ignoriere Start auf "No-swipe"-Elementen
|
||||
const target = e.target as HTMLElement | null
|
||||
const tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
|
||||
|
||||
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 fromBottom = rect.bottom - e.clientY
|
||||
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return
|
||||
@ -222,7 +290,17 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
y: e.clientY,
|
||||
dragging: 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) => {
|
||||
@ -246,6 +324,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
// ✅ jetzt erst beginnen wir zu swipen
|
||||
pointer.current.dragging = true
|
||||
|
||||
// ✅ Anim nur 1x beim Drag-Start deaktivieren
|
||||
setAnimMs(0)
|
||||
|
||||
// ✅ Pointer-Capture erst JETZT (nicht bei pointerdown)
|
||||
try {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
@ -255,25 +336,31 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}
|
||||
}
|
||||
|
||||
setAnimMs(0)
|
||||
setDx(ddx)
|
||||
// ✅ dx nur pro Frame in React-State schreiben
|
||||
dxRef.current = ddx
|
||||
|
||||
const el = cardRef.current
|
||||
const w = el?.offsetWidth || 360
|
||||
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
||||
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
|
||||
if (rafRef.current == null) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = 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) => {
|
||||
if (!enabled || disabled) return
|
||||
if (pointer.current.id !== e.pointerId) return
|
||||
|
||||
const el = cardRef.current
|
||||
const w = el?.offsetWidth || 360
|
||||
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
||||
const threshold = thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
||||
|
||||
const wasDragging = pointer.current.dragging
|
||||
const wasCaptured = pointer.current.captured
|
||||
const wasTapIgnored = pointer.current.tapIgnored
|
||||
|
||||
pointer.current.id = null
|
||||
pointer.current.dragging = false
|
||||
@ -287,18 +374,38 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}
|
||||
|
||||
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()
|
||||
onTap?.()
|
||||
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)
|
||||
} else if (dx < -threshold) {
|
||||
} else if (finalDx < -threshold) {
|
||||
void commit('left', true)
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
|
||||
dxRef.current = 0
|
||||
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
@ -307,7 +414,13 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
|
||||
} 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()
|
||||
}}
|
||||
>
|
||||
|
||||
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 './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ToastProvider } from './components/ui/ToastProvider.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ToastProvider position="bottom-right" maxToasts={3} defaultDurationMs={3500}>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@ -2,16 +2,26 @@
|
||||
|
||||
export type RecordJob = {
|
||||
id: string
|
||||
sourceUrl: string
|
||||
sourceUrl?: string
|
||||
output: string
|
||||
status: 'running' | 'finished' | 'failed' | 'stopped'
|
||||
startedAt: 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
|
||||
error?: string
|
||||
logTail?: string
|
||||
}
|
||||
|
||||
|
||||
export type ParsedModel = {
|
||||
input: string
|
||||
isUrl: boolean
|
||||
@ -19,3 +29,24 @@ export type ParsedModel = {
|
||||
path?: 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',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/generated': {
|
||||
target: 'http://localhost:9999',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user