updated
This commit is contained in:
parent
d578d4e6aa
commit
0fac07f620
@ -29,14 +29,14 @@ type Model struct {
|
|||||||
Liked *bool `json:"liked"` // nil = keine Angabe
|
Liked *bool `json:"liked"` // nil = keine Angabe
|
||||||
}
|
}
|
||||||
|
|
||||||
type modelStore struct {
|
type jsonModelStore struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
path string
|
path string
|
||||||
loaded bool
|
loaded bool
|
||||||
items []Model
|
items []Model
|
||||||
}
|
}
|
||||||
|
|
||||||
var models = &modelStore{
|
var models = &jsonModelStore{
|
||||||
path: filepath.Join("data", "models.json"),
|
path: filepath.Join("data", "models.json"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -262,7 +262,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
|||||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
modelsWriteJSON(w, http.StatusOK, store.List())
|
|
||||||
|
// ✅ Wenn du List() als ([]T, error) hast -> Fehler sichtbar machen:
|
||||||
|
// Falls List() aktuell nur []T zurückgibt, siehe Schritt 2 unten.
|
||||||
|
list := store.List()
|
||||||
|
|
||||||
|
modelsWriteJSON(w, http.StatusOK, list)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ✅ Profilbild-Blob aus DB ausliefern
|
// ✅ Profilbild-Blob aus DB ausliefern
|
||||||
|
|||||||
@ -3,7 +3,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@ -25,6 +27,11 @@ type StoredModel struct {
|
|||||||
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
|
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
|
||||||
LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano
|
LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano
|
||||||
|
|
||||||
|
// ✅ Chaturbate Online Snapshot (persistiert aus chaturbate_online.go)
|
||||||
|
CbOnlineJSON string `json:"cbOnlineJson,omitempty"`
|
||||||
|
CbOnlineFetchedAt string `json:"cbOnlineFetchedAt,omitempty"`
|
||||||
|
CbOnlineLastError string `json:"cbOnlineLastError,omitempty"`
|
||||||
|
|
||||||
ProfileImageURL string `json:"profileImageUrl,omitempty"`
|
ProfileImageURL string `json:"profileImageUrl,omitempty"`
|
||||||
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
|
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
|
||||||
ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano
|
ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano
|
||||||
@ -776,7 +783,7 @@ UPDATE models SET
|
|||||||
|
|
||||||
-- ✅ last_stream ist timestamptz: nur setzen, wenn aktuell NULL und wir einen gültigen Wert haben
|
-- ✅ last_stream ist timestamptz: nur setzen, wenn aktuell NULL und wir einen gültigen Wert haben
|
||||||
last_stream = CASE
|
last_stream = CASE
|
||||||
WHEN last_stream IS NULL AND $6 IS NOT NULL THEN $6
|
WHEN last_stream IS NULL AND $6::timestamptz IS NOT NULL THEN $6::timestamptz
|
||||||
ELSE last_stream
|
ELSE last_stream
|
||||||
END,
|
END,
|
||||||
|
|
||||||
@ -784,7 +791,10 @@ UPDATE models SET
|
|||||||
favorite = CASE WHEN $8=true THEN true ELSE favorite END,
|
favorite = CASE WHEN $8=true THEN true ELSE favorite END,
|
||||||
hot = CASE WHEN $9=true THEN true ELSE hot END,
|
hot = CASE WHEN $9=true THEN true ELSE hot END,
|
||||||
keep = CASE WHEN $10=true THEN true ELSE keep END,
|
keep = CASE WHEN $10=true THEN true ELSE keep END,
|
||||||
liked = CASE WHEN liked IS NULL AND $11 IS NOT NULL THEN $11 ELSE liked END,
|
liked = CASE
|
||||||
|
WHEN liked IS NULL AND $11::boolean IS NOT NULL THEN $11::boolean
|
||||||
|
ELSE liked
|
||||||
|
END,
|
||||||
|
|
||||||
updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END
|
updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END
|
||||||
WHERE id = $13;
|
WHERE id = $13;
|
||||||
@ -817,12 +827,21 @@ func (s *ModelStore) List() []StoredModel {
|
|||||||
return []StoredModel{}
|
return []StoredModel{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ last_stream ist TIMESTAMPTZ -> direkt lesen (NullTime), niemals COALESCE(...,'')
|
q1 := `
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT
|
SELECT
|
||||||
id,input,is_url,host,path,model_key,
|
id,
|
||||||
tags, last_stream,
|
COALESCE(input,'') as input,
|
||||||
last_seen_online, last_seen_online_at,
|
is_url,
|
||||||
|
COALESCE(host,'') as host,
|
||||||
|
COALESCE(path,'') as path,
|
||||||
|
COALESCE(model_key,'') as model_key,
|
||||||
|
COALESCE(tags,'') as tags,
|
||||||
|
last_stream,
|
||||||
|
last_seen_online,
|
||||||
|
last_seen_online_at,
|
||||||
|
COALESCE(cb_online_json,''),
|
||||||
|
cb_online_fetched_at,
|
||||||
|
COALESCE(cb_online_last_error,''), -- optional
|
||||||
COALESCE(profile_image_url,''),
|
COALESCE(profile_image_url,''),
|
||||||
profile_image_updated_at,
|
profile_image_updated_at,
|
||||||
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
|
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
|
||||||
@ -830,10 +849,42 @@ SELECT
|
|||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM models
|
FROM models
|
||||||
ORDER BY updated_at DESC;
|
ORDER BY updated_at DESC;
|
||||||
`)
|
`
|
||||||
|
|
||||||
|
q2 := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
COALESCE(input,'') as input,
|
||||||
|
is_url,
|
||||||
|
COALESCE(host,'') as host,
|
||||||
|
COALESCE(path,'') as path,
|
||||||
|
COALESCE(model_key,'') as model_key,
|
||||||
|
COALESCE(tags,'') as tags,
|
||||||
|
last_stream,
|
||||||
|
last_seen_online,
|
||||||
|
last_seen_online_at,
|
||||||
|
COALESCE(cb_online_json,''),
|
||||||
|
cb_online_fetched_at,
|
||||||
|
''::text as cb_online_last_error, -- fallback dummy
|
||||||
|
COALESCE(profile_image_url,''),
|
||||||
|
profile_image_updated_at,
|
||||||
|
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
|
||||||
|
watching,favorite,hot,keep,liked,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM models
|
||||||
|
ORDER BY updated_at DESC;
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := s.db.Query(q1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// ✅ genau dein Fall: "Spalte existiert nicht" -> fallback
|
||||||
|
fmt.Println("models List query err (q1):", err)
|
||||||
|
rows, err = s.db.Query(q2)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("models List query err (q2):", err)
|
||||||
return []StoredModel{}
|
return []StoredModel{}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
out := make([]StoredModel, 0, 64)
|
out := make([]StoredModel, 0, 64)
|
||||||
@ -841,7 +892,6 @@ ORDER BY updated_at DESC;
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
id, input, host, path, modelKey, tags string
|
id, input, host, path, modelKey, tags string
|
||||||
|
|
||||||
isURL bool
|
isURL bool
|
||||||
|
|
||||||
lastStream sql.NullTime
|
lastStream sql.NullTime
|
||||||
@ -849,6 +899,10 @@ ORDER BY updated_at DESC;
|
|||||||
lastSeenOnline sql.NullBool
|
lastSeenOnline sql.NullBool
|
||||||
lastSeenOnlineAt sql.NullTime
|
lastSeenOnlineAt sql.NullTime
|
||||||
|
|
||||||
|
cbOnlineJSON string
|
||||||
|
cbOnlineFetchedAt sql.NullTime
|
||||||
|
cbOnlineLastError string
|
||||||
|
|
||||||
profileImageURL string
|
profileImageURL string
|
||||||
profileImageUpdatedAt sql.NullTime
|
profileImageUpdatedAt sql.NullTime
|
||||||
hasProfileImage int64
|
hasProfileImage int64
|
||||||
@ -863,6 +917,7 @@ ORDER BY updated_at DESC;
|
|||||||
&id, &input, &isURL, &host, &path, &modelKey,
|
&id, &input, &isURL, &host, &path, &modelKey,
|
||||||
&tags, &lastStream,
|
&tags, &lastStream,
|
||||||
&lastSeenOnline, &lastSeenOnlineAt,
|
&lastSeenOnline, &lastSeenOnlineAt,
|
||||||
|
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
|
||||||
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
|
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
|
||||||
&watching, &favorite, &hot, &keep, &liked,
|
&watching, &favorite, &hot, &keep, &liked,
|
||||||
&createdAt, &updatedAt,
|
&createdAt, &updatedAt,
|
||||||
@ -882,6 +937,10 @@ ORDER BY updated_at DESC;
|
|||||||
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
||||||
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
|
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
|
||||||
|
|
||||||
|
CbOnlineJSON: cbOnlineJSON,
|
||||||
|
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
|
||||||
|
CbOnlineLastError: cbOnlineLastError,
|
||||||
|
|
||||||
Watching: watching,
|
Watching: watching,
|
||||||
Favorite: favorite,
|
Favorite: favorite,
|
||||||
Hot: hot,
|
Hot: hot,
|
||||||
@ -919,6 +978,166 @@ func (s *ModelStore) Meta() ModelsMeta {
|
|||||||
return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt)}
|
return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChaturbateOnlineSnapshot struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
CurrentShow string `json:"current_show,omitempty"` // public/private/hidden/away
|
||||||
|
RoomSubject string `json:"room_subject,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
SpokenLanguages string `json:"spoken_languages,omitempty"`
|
||||||
|
Gender string `json:"gender,omitempty"`
|
||||||
|
|
||||||
|
NumUsers int `json:"num_users,omitempty"`
|
||||||
|
NumFollowers int `json:"num_followers,omitempty"`
|
||||||
|
IsHD bool `json:"is_hd,omitempty"`
|
||||||
|
IsNew bool `json:"is_new,omitempty"`
|
||||||
|
Age int `json:"age,omitempty"`
|
||||||
|
SecondsOnline int `json:"seconds_online,omitempty"`
|
||||||
|
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
ImageURL360 string `json:"image_url_360x270,omitempty"`
|
||||||
|
ChatRoomURL string `json:"chat_room_url,omitempty"`
|
||||||
|
ChatRoomURLRS string `json:"chat_room_url_revshare,omitempty"`
|
||||||
|
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelStore) ListModelKeysByHost(host string) ([]string, error) {
|
||||||
|
if err := s.ensureInit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
host = canonicalHost(host)
|
||||||
|
if host == "" {
|
||||||
|
return nil, errors.New("host fehlt")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT model_key
|
||||||
|
FROM models
|
||||||
|
WHERE lower(trim(host)) = lower(trim($1));
|
||||||
|
`, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]string, 0, 128)
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
if err := rows.Scan(&k); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k = strings.ToLower(strings.TrimSpace(k))
|
||||||
|
if k != "" {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelStore) SetChaturbateOnlineSnapshot(host, modelKey string, snap *ChaturbateOnlineSnapshot, fetchedAt string, lastErr 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonStr string
|
||||||
|
if snap != nil {
|
||||||
|
b, err := json.Marshal(snap)
|
||||||
|
if err == nil {
|
||||||
|
jsonStr = strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ft := parseRFC3339Nano(fetchedAt)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
// NOTE: cb_online_last_error nur updaten, wenn Spalte existiert.
|
||||||
|
// Wenn du die optionale Spalte nicht anlegst: entferne die beiden Stellen.
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
UPDATE models
|
||||||
|
SET
|
||||||
|
cb_online_json=$1,
|
||||||
|
cb_online_fetched_at=$2,
|
||||||
|
cb_online_last_error=$3,
|
||||||
|
updated_at=$4
|
||||||
|
WHERE lower(trim(host)) = lower(trim($5))
|
||||||
|
AND lower(trim(model_key)) = lower(trim($6));
|
||||||
|
`, nullableStringArg(jsonStr), nullableTimeArg(ft), strings.TrimSpace(lastErr), now, host, key)
|
||||||
|
if err != nil {
|
||||||
|
// falls cb_online_last_error nicht existiert -> fallback ohne die Spalte
|
||||||
|
_, err2 := s.db.Exec(`
|
||||||
|
UPDATE models
|
||||||
|
SET
|
||||||
|
cb_online_json=$1,
|
||||||
|
cb_online_fetched_at=$2,
|
||||||
|
updated_at=$3
|
||||||
|
WHERE lower(trim(host)) = lower(trim($4))
|
||||||
|
AND lower(trim(model_key)) = lower(trim($5));
|
||||||
|
`, nullableStringArg(jsonStr), nullableTimeArg(ft), now, host, key)
|
||||||
|
return err2
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelStore) GetChaturbateOnlineSnapshot(host, modelKey string) (*ChaturbateOnlineSnapshot, string, bool, error) {
|
||||||
|
if err := s.ensureInit(); err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
host = canonicalHost(host)
|
||||||
|
key := strings.TrimSpace(modelKey)
|
||||||
|
if host == "" || key == "" {
|
||||||
|
return nil, "", false, errors.New("host/modelKey fehlt")
|
||||||
|
}
|
||||||
|
|
||||||
|
var js sql.NullString
|
||||||
|
var fetchedAt sql.NullTime
|
||||||
|
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT cb_online_json, cb_online_fetched_at
|
||||||
|
FROM models
|
||||||
|
WHERE lower(trim(host)) = lower(trim($1))
|
||||||
|
AND lower(trim(model_key)) = lower(trim($2))
|
||||||
|
LIMIT 1;
|
||||||
|
`, host, key).Scan(&js, &fetchedAt)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := strings.TrimSpace(js.String)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmtNullTime(fetchedAt), false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var snap ChaturbateOnlineSnapshot
|
||||||
|
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
|
||||||
|
return nil, fmtNullTime(fetchedAt), false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &snap, fmtNullTime(fetchedAt), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableStringArg(s string) any {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// hostFilter: z.B. "chaturbate.com" (leer => alle Hosts)
|
// hostFilter: z.B. "chaturbate.com" (leer => alle Hosts)
|
||||||
func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
|
func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
|
||||||
if err := s.ensureInit(); err != nil {
|
if err := s.ensureInit(); err != nil {
|
||||||
@ -933,14 +1152,14 @@ func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
|
|||||||
)
|
)
|
||||||
if hostFilter == "" {
|
if hostFilter == "" {
|
||||||
rows, err = s.db.Query(`
|
rows, err = s.db.Query(`
|
||||||
SELECT id,input,host,model_key,watching
|
SELECT id,input,COALESCE(host,'') as host,model_key,watching
|
||||||
FROM models
|
FROM models
|
||||||
WHERE watching = true
|
WHERE watching = true
|
||||||
ORDER BY updated_at DESC;
|
ORDER BY updated_at DESC;
|
||||||
`)
|
`)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(`
|
rows, err = s.db.Query(`
|
||||||
SELECT id,input,host,model_key,watching
|
SELECT id,input,COALESCE(host,'') as host,model_key,watching
|
||||||
FROM models
|
FROM models
|
||||||
WHERE watching = true AND host = $1
|
WHERE watching = true AND host = $1
|
||||||
ORDER BY updated_at DESC;
|
ORDER BY updated_at DESC;
|
||||||
@ -1200,6 +1419,10 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
|
|||||||
lastSeenOnline sql.NullBool
|
lastSeenOnline sql.NullBool
|
||||||
lastSeenOnlineAt sql.NullTime
|
lastSeenOnlineAt sql.NullTime
|
||||||
|
|
||||||
|
cbOnlineJSON string
|
||||||
|
cbOnlineFetchedAt sql.NullTime
|
||||||
|
cbOnlineLastError string
|
||||||
|
|
||||||
profileImageURL string
|
profileImageURL string
|
||||||
profileImageUpdatedAt sql.NullTime
|
profileImageUpdatedAt sql.NullTime
|
||||||
hasProfileImage int64
|
hasProfileImage int64
|
||||||
@ -1210,11 +1433,21 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
|
|||||||
createdAt, updatedAt time.Time
|
createdAt, updatedAt time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
err := s.db.QueryRow(`
|
// q1: mit optionaler Spalte cb_online_last_error
|
||||||
|
q1 := `
|
||||||
SELECT
|
SELECT
|
||||||
input,is_url,host,path,model_key,
|
COALESCE(input,'') as input,
|
||||||
tags, last_stream,
|
is_url,
|
||||||
last_seen_online, last_seen_online_at,
|
COALESCE(host,'') as host,
|
||||||
|
COALESCE(path,'') as path,
|
||||||
|
COALESCE(model_key,'') as model_key,
|
||||||
|
COALESCE(tags,'') as tags,
|
||||||
|
last_stream,
|
||||||
|
last_seen_online,
|
||||||
|
last_seen_online_at,
|
||||||
|
COALESCE(cb_online_json,''),
|
||||||
|
cb_online_fetched_at,
|
||||||
|
COALESCE(cb_online_last_error,''),
|
||||||
COALESCE(profile_image_url,''),
|
COALESCE(profile_image_url,''),
|
||||||
profile_image_updated_at,
|
profile_image_updated_at,
|
||||||
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
|
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
|
||||||
@ -1222,19 +1455,62 @@ SELECT
|
|||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM models
|
FROM models
|
||||||
WHERE id=$1;
|
WHERE id=$1;
|
||||||
`, id).Scan(
|
`
|
||||||
|
|
||||||
|
// q2: Fallback, falls cb_online_last_error nicht existiert (oder q1 aus anderen Gründen scheitert)
|
||||||
|
// Wichtig: gleiche Spaltenreihenfolge + Typen kompatibel halten.
|
||||||
|
q2 := `
|
||||||
|
SELECT
|
||||||
|
COALESCE(input,'') as input,
|
||||||
|
is_url,
|
||||||
|
COALESCE(host,'') as host,
|
||||||
|
COALESCE(path,'') as path,
|
||||||
|
COALESCE(model_key,'') as model_key,
|
||||||
|
COALESCE(tags,'') as tags,
|
||||||
|
last_stream,
|
||||||
|
last_seen_online,
|
||||||
|
last_seen_online_at,
|
||||||
|
COALESCE(cb_online_json,''),
|
||||||
|
cb_online_fetched_at,
|
||||||
|
'' as cb_online_last_error,
|
||||||
|
COALESCE(profile_image_url,''),
|
||||||
|
profile_image_updated_at,
|
||||||
|
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
|
||||||
|
watching,favorite,hot,keep,liked,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM models
|
||||||
|
WHERE id=$1;
|
||||||
|
`
|
||||||
|
|
||||||
|
scan := func(q string) error {
|
||||||
|
return s.db.QueryRow(q, id).Scan(
|
||||||
&input, &isURL, &host, &path, &modelKey,
|
&input, &isURL, &host, &path, &modelKey,
|
||||||
&tags, &lastStream,
|
&tags, &lastStream,
|
||||||
&lastSeenOnline, &lastSeenOnlineAt,
|
&lastSeenOnline, &lastSeenOnlineAt,
|
||||||
|
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
|
||||||
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
|
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
|
||||||
&watching, &favorite, &hot, &keep, &liked,
|
&watching, &favorite, &hot, &keep, &liked,
|
||||||
&createdAt, &updatedAt,
|
&createdAt, &updatedAt,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := scan(q1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Wenn die Zeile nicht existiert, nicht noch fallbacken.
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return StoredModel{}, errors.New("model nicht gefunden")
|
return StoredModel{}, errors.New("model nicht gefunden")
|
||||||
}
|
}
|
||||||
return StoredModel{}, err
|
|
||||||
|
// Fallback versuchen (typisch: "column cb_online_last_error does not exist")
|
||||||
|
err2 := scan(q2)
|
||||||
|
if err2 != nil {
|
||||||
|
// wenn fallback auch kein Row findet, sauber melden
|
||||||
|
if errors.Is(err2, sql.ErrNoRows) {
|
||||||
|
return StoredModel{}, errors.New("model nicht gefunden")
|
||||||
|
}
|
||||||
|
// sonst ursprünglichen Fehler behalten? -> ich gebe hier err2 zurück, weil er meist aussagekräftiger ist.
|
||||||
|
return StoredModel{}, err2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m := StoredModel{
|
m := StoredModel{
|
||||||
@ -1249,6 +1525,10 @@ WHERE id=$1;
|
|||||||
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
||||||
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
|
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
|
||||||
|
|
||||||
|
CbOnlineJSON: cbOnlineJSON,
|
||||||
|
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
|
||||||
|
CbOnlineLastError: cbOnlineLastError,
|
||||||
|
|
||||||
Watching: watching,
|
Watching: watching,
|
||||||
Favorite: favorite,
|
Favorite: favorite,
|
||||||
Hot: hot,
|
Hot: hot,
|
||||||
|
|||||||
@ -501,7 +501,7 @@ function DownloadsCardRow({
|
|||||||
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
|
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
|
{ /* Status-Badge */}
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import {
|
|||||||
PhotoIcon,
|
PhotoIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
FilmIcon,
|
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
EyeIcon as EyeOutlineIcon,
|
EyeIcon as EyeOutlineIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
@ -32,7 +31,6 @@ import {
|
|||||||
StarIcon as StarSolidIcon,
|
StarIcon as StarSolidIcon,
|
||||||
EyeIcon as EyeSolidIcon,
|
EyeIcon as EyeSolidIcon,
|
||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
import { useMediaQuery } from '../../lib/useMediaQuery'
|
|
||||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||||
import TagOverflowRow from './TagOverflowRow'
|
import TagOverflowRow from './TagOverflowRow'
|
||||||
import PreviewScrubber from './PreviewScrubber'
|
import PreviewScrubber from './PreviewScrubber'
|
||||||
@ -255,16 +253,6 @@ function pill(cls: string) {
|
|||||||
const previewBlurCls = (blur?: boolean) =>
|
const previewBlurCls = (blur?: boolean) =>
|
||||||
blur ? 'blur-md scale-[1.03] brightness-90' : ''
|
blur ? 'blur-md scale-[1.03] brightness-90' : ''
|
||||||
|
|
||||||
function niceFileLabel(file: string) {
|
|
||||||
const s = stripHotPrefix(file || '').trim()
|
|
||||||
return s || '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
function endedLabel(job: RecordJob) {
|
|
||||||
const ended = (job as any).endedAt ?? (job as any).completedAt ?? job.endedAt
|
|
||||||
return ended ? shortDate(ended as any) : '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
||||||
for (const v of values) {
|
for (const v of values) {
|
||||||
if (typeof v === 'string') {
|
if (typeof v === 'string') {
|
||||||
@ -335,6 +323,7 @@ function chooseSpriteGrid(count: number): [number, number] {
|
|||||||
type ChaturbateRoom = {
|
type ChaturbateRoom = {
|
||||||
gender?: string
|
gender?: string
|
||||||
location?: string
|
location?: string
|
||||||
|
country?: string
|
||||||
current_show?: string
|
current_show?: string
|
||||||
username?: string
|
username?: string
|
||||||
room_subject?: string
|
room_subject?: string
|
||||||
@ -446,6 +435,9 @@ type StoredModel = {
|
|||||||
keep?: boolean
|
keep?: boolean
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
|
cbOnlineJson?: string | null
|
||||||
|
cbOnlineFetchedAt?: string | null
|
||||||
|
cbOnlineLastError?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -536,10 +528,9 @@ export default function ModelDetails({
|
|||||||
onStopJob
|
onStopJob
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
//const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||||
|
|
||||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
const [models, setModels] = React.useState<StoredModel[]>([])
|
||||||
const [, setModelsLoading] = React.useState(false)
|
|
||||||
|
|
||||||
const [room, setRoom] = React.useState<ChaturbateRoom | null>(null)
|
const [room, setRoom] = React.useState<ChaturbateRoom | null>(null)
|
||||||
const [roomMeta, setRoomMeta] = React.useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
|
const [roomMeta, setRoomMeta] = React.useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
|
||||||
@ -554,11 +545,13 @@ export default function ModelDetails({
|
|||||||
const [running, setRunning] = React.useState<RecordJob[]>([])
|
const [running, setRunning] = React.useState<RecordJob[]>([])
|
||||||
const [runningLoading, setRunningLoading] = React.useState(false)
|
const [runningLoading, setRunningLoading] = React.useState(false)
|
||||||
|
|
||||||
const [bioRefreshSeq, setBioRefreshSeq] = React.useState(0)
|
const runningReqSeqRef = React.useRef(0)
|
||||||
|
|
||||||
|
const [, setBioRefreshSeq] = React.useState(0)
|
||||||
|
|
||||||
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
|
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
|
||||||
|
|
||||||
const [runningHover, setRunningHover] = React.useState(false)
|
const [, setRunningHover] = React.useState(false)
|
||||||
|
|
||||||
const [stopPending, setStopPending] = React.useState(false)
|
const [stopPending, setStopPending] = React.useState(false)
|
||||||
|
|
||||||
@ -569,6 +562,12 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
const key = normalizeModelKey(modelKey)
|
const key = normalizeModelKey(modelKey)
|
||||||
|
|
||||||
|
type TabKey = 'info' | 'downloads' | 'running'
|
||||||
|
const [tab, setTab] = React.useState<TabKey>('info')
|
||||||
|
|
||||||
|
const bioReqRef = React.useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
|
||||||
// ===== Gallery UI State (wie FinishedDownloadsGalleryView) =====
|
// ===== Gallery UI State (wie FinishedDownloadsGalleryView) =====
|
||||||
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
||||||
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
|
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
|
||||||
@ -623,6 +622,54 @@ export default function ModelDetails({
|
|||||||
setStopPending(false)
|
setStopPending(false)
|
||||||
}, [open, key])
|
}, [open, key])
|
||||||
|
|
||||||
|
const refreshBio = React.useCallback(async () => {
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
// vorherigen abbrechen
|
||||||
|
bioReqRef.current?.abort()
|
||||||
|
const ac = new AbortController()
|
||||||
|
bioReqRef.current = ac
|
||||||
|
|
||||||
|
setBioLoading(true)
|
||||||
|
try {
|
||||||
|
const cookieHeader = buildChaturbateCookieHeader(cookies)
|
||||||
|
const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1`
|
||||||
|
|
||||||
|
const r = await fetch(url, {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: ac.signal,
|
||||||
|
headers: cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
const text = await r.text().catch(() => '')
|
||||||
|
throw new Error(text || `HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await r.json().catch(() => null)) as BioResp
|
||||||
|
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
|
||||||
|
const nextBio = (data?.bio as BioContext) ?? null
|
||||||
|
|
||||||
|
setBioMeta(meta)
|
||||||
|
setBio(nextBio)
|
||||||
|
|
||||||
|
const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta }
|
||||||
|
mdBioMem.set(key, entry)
|
||||||
|
ssSet(ssKeyBio(key), entry)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === 'AbortError') return
|
||||||
|
setBioMeta({ enabled: undefined, fetchedAt: undefined, lastError: e?.message || 'Fetch fehlgeschlagen' })
|
||||||
|
} finally {
|
||||||
|
setBioLoading(false)
|
||||||
|
}
|
||||||
|
}, [key, cookies])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) return
|
||||||
|
bioReqRef.current?.abort()
|
||||||
|
bioReqRef.current = null
|
||||||
|
}, [open])
|
||||||
|
|
||||||
const refetchModels = React.useCallback(async () => {
|
const refetchModels = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/models', { cache: 'no-store' })
|
const r = await fetch('/api/models', { cache: 'no-store' })
|
||||||
@ -657,6 +704,35 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [key, donePage])
|
}, [key, donePage])
|
||||||
|
|
||||||
|
const refetchDoneRef = React.useRef(refetchDone)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
refetchDoneRef.current = refetchDone
|
||||||
|
}, [refetchDone])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
const es = new EventSource('/api/stream')
|
||||||
|
|
||||||
|
const onJobs = () => {
|
||||||
|
// optional
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDone = () => {
|
||||||
|
void refetchDoneRef.current()
|
||||||
|
}
|
||||||
|
|
||||||
|
es.addEventListener('jobs', onJobs)
|
||||||
|
es.addEventListener('doneChanged', onDone)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.removeEventListener('jobs', onJobs)
|
||||||
|
es.removeEventListener('doneChanged', onDone)
|
||||||
|
es.close()
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
// erzeugt ein "Job"-Objekt, das für deine Toggle-Handler reicht
|
// erzeugt ein "Job"-Objekt, das für deine Toggle-Handler reicht
|
||||||
function jobFromModelKey(key: string): RecordJob {
|
function jobFromModelKey(key: string): RecordJob {
|
||||||
// muss zum Regex in App.tsx passen: <model>_MM_DD_YYYY__HH-MM-SS.ext
|
// muss zum Regex in App.tsx passen: <model>_MM_DD_YYYY__HH-MM-SS.ext
|
||||||
@ -687,141 +763,10 @@ export default function ModelDetails({
|
|||||||
setDonePage(1)
|
setDonePage(1)
|
||||||
}, [open, modelKey])
|
}, [open, modelKey])
|
||||||
|
|
||||||
// Models list (local flags + stored tags)
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
let alive = true
|
void refetchModels()
|
||||||
setModelsLoading(true)
|
}, [open, refetchModels])
|
||||||
fetch('/api/models', { cache: 'no-store' })
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: StoredModel[]) => {
|
|
||||||
if (!alive) return
|
|
||||||
setModels(Array.isArray(data) ? data : [])
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!alive) return
|
|
||||||
setModels([])
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!alive) return
|
|
||||||
setModelsLoading(false)
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
alive = false
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
// ✅ Online: nur einmalig laden (kein Polling)
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!open || !key) return
|
|
||||||
|
|
||||||
// wenn wir frische Daten aus Cache haben -> keinen Request
|
|
||||||
const mem = mdOnlineMem.get(key)
|
|
||||||
const ss = ssGet<OnlineCacheEntry>(ssKeyOnline(key))
|
|
||||||
const hit =
|
|
||||||
(mem && isFresh(mem.at) ? mem : null) ||
|
|
||||||
(ss && isFresh(ss.at) ? ss : null)
|
|
||||||
|
|
||||||
if (hit) return
|
|
||||||
|
|
||||||
let alive = true
|
|
||||||
const ac = new AbortController()
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const cookieHeader = buildChaturbateCookieHeader(cookies)
|
|
||||||
|
|
||||||
const r = await fetch('/api/chaturbate/online', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : {}),
|
|
||||||
},
|
|
||||||
cache: 'no-store',
|
|
||||||
signal: ac.signal,
|
|
||||||
body: JSON.stringify({ q: [key], show: [], refresh: false }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = (await r.json().catch(() => null)) as OnlineResp
|
|
||||||
if (!alive) return
|
|
||||||
|
|
||||||
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
|
|
||||||
const rooms = Array.isArray(data?.rooms) ? data.rooms : []
|
|
||||||
const nextRoom = rooms[0] ?? null
|
|
||||||
|
|
||||||
setRoomMeta(meta)
|
|
||||||
setRoom(nextRoom)
|
|
||||||
|
|
||||||
const entry: OnlineCacheEntry = { at: Date.now(), room: nextRoom, meta }
|
|
||||||
mdOnlineMem.set(key, entry)
|
|
||||||
ssSet(ssKeyOnline(key), entry)
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === 'AbortError') return
|
|
||||||
if (!alive) return
|
|
||||||
setRoomMeta({ enabled: undefined, fetchedAt: undefined, lastError: 'Fetch fehlgeschlagen' })
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
alive = false
|
|
||||||
ac.abort()
|
|
||||||
}
|
|
||||||
}, [open, key, cookies])
|
|
||||||
|
|
||||||
// ✅ NEW: BioContext (proxy)
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!open || !key) return
|
|
||||||
|
|
||||||
let alive = true
|
|
||||||
setBioLoading(true)
|
|
||||||
setBio(null)
|
|
||||||
setBioMeta(null)
|
|
||||||
|
|
||||||
const cookieHeader = buildChaturbateCookieHeader(cookies)
|
|
||||||
|
|
||||||
const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}${
|
|
||||||
bioRefreshSeq > 0 ? '&refresh=1' : ''
|
|
||||||
}`
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
cache: 'no-store',
|
|
||||||
headers: cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : undefined,
|
|
||||||
})
|
|
||||||
.then(async (r) => {
|
|
||||||
if (!r.ok) {
|
|
||||||
const text = await r.text().catch(() => '')
|
|
||||||
throw new Error(text || `HTTP ${r.status}`)
|
|
||||||
}
|
|
||||||
return r.json()
|
|
||||||
})
|
|
||||||
.then((data: BioResp) => {
|
|
||||||
if (!alive) return
|
|
||||||
setBioMeta({ enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError })
|
|
||||||
setBio((data?.bio as BioContext) ?? null)
|
|
||||||
|
|
||||||
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
|
|
||||||
const nextBio = (data?.bio as BioContext) ?? null
|
|
||||||
|
|
||||||
setBioMeta(meta)
|
|
||||||
setBio(nextBio)
|
|
||||||
|
|
||||||
const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta }
|
|
||||||
mdBioMem.set(key, entry)
|
|
||||||
ssSet(ssKeyBio(key), entry)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (!alive) return
|
|
||||||
setBioMeta({ enabled: undefined, fetchedAt: undefined, lastError: e?.message || 'Fetch fehlgeschlagen' })
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!alive) return
|
|
||||||
setBioLoading(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
alive = false
|
|
||||||
}
|
|
||||||
}, [open, key, bioRefreshSeq, cookies])
|
|
||||||
|
|
||||||
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
|
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -834,26 +779,31 @@ export default function ModelDetails({
|
|||||||
if (!open) return
|
if (!open) return
|
||||||
if (Array.isArray(runningJobs)) return
|
if (Array.isArray(runningJobs)) return
|
||||||
|
|
||||||
let alive = true
|
const ac = new AbortController()
|
||||||
|
const seq = ++runningReqSeqRef.current
|
||||||
|
|
||||||
setRunningLoading(true)
|
setRunningLoading(true)
|
||||||
|
|
||||||
fetch('/api/record/jobs', { cache: 'no-store' })
|
fetch('/api/record/jobs', { cache: 'no-store', signal: ac.signal })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: RecordJob[]) => {
|
.then((data: RecordJob[]) => {
|
||||||
if (!alive) return
|
if (ac.signal.aborted) return
|
||||||
|
if (runningReqSeqRef.current !== seq) return
|
||||||
setRunning(Array.isArray(data) ? data : [])
|
setRunning(Array.isArray(data) ? data : [])
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!alive) return
|
if (ac.signal.aborted) return
|
||||||
|
if (runningReqSeqRef.current !== seq) return
|
||||||
setRunning([])
|
setRunning([])
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!alive) return
|
if (ac.signal.aborted) return
|
||||||
|
if (runningReqSeqRef.current !== seq) return
|
||||||
setRunningLoading(false)
|
setRunningLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false
|
ac.abort()
|
||||||
}
|
}
|
||||||
}, [open, runningJobs])
|
}, [open, runningJobs])
|
||||||
|
|
||||||
@ -862,11 +812,27 @@ export default function ModelDetails({
|
|||||||
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
|
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
|
||||||
}, [models, key])
|
}, [models, key])
|
||||||
|
|
||||||
const doneMatches = done
|
const storedRoomFromSnap = React.useMemo<ChaturbateRoom | null>(() => {
|
||||||
|
const raw = (model as any)?.cbOnlineJson
|
||||||
|
if (!raw || typeof raw !== 'string') return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as ChaturbateRoom
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [model])
|
||||||
|
|
||||||
const doneTotalPages = React.useMemo(() => {
|
const storedRoomMeta = React.useMemo(() => {
|
||||||
return Math.max(1, Math.ceil(doneTotalCount / DONE_PAGE_SIZE))
|
const fetchedAt = (model as any)?.cbOnlineFetchedAt
|
||||||
}, [doneTotalCount])
|
const lastError = (model as any)?.cbOnlineLastError
|
||||||
|
if (!fetchedAt && !lastError) return null
|
||||||
|
return { enabled: true, fetchedAt, lastError } as Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'>
|
||||||
|
}, [model])
|
||||||
|
|
||||||
|
const effectiveRoom = room ?? storedRoomFromSnap
|
||||||
|
const effectiveRoomMeta = roomMeta ?? storedRoomMeta
|
||||||
|
|
||||||
|
const doneMatches = done
|
||||||
|
|
||||||
const runningMatches = React.useMemo(() => {
|
const runningMatches = React.useMemo(() => {
|
||||||
if (!key) return []
|
if (!key) return []
|
||||||
@ -876,27 +842,12 @@ export default function ModelDetails({
|
|||||||
})
|
})
|
||||||
}, [runningList, key])
|
}, [runningList, key])
|
||||||
|
|
||||||
// ✅ Running-Hero: wenn es einen laufenden Job für dieses Model gibt, nimm dessen Preview
|
const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
|
||||||
const runningHeroJob = runningMatches.length ? runningMatches[0] : null
|
const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || ''
|
||||||
|
const heroImgFull = effectiveRoom?.image_url || heroImg
|
||||||
|
const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || ''
|
||||||
|
|
||||||
const allTags = React.useMemo(() => {
|
const showLabel = (effectiveRoom?.current_show || '').trim().toLowerCase()
|
||||||
const a = splitTags(model?.tags)
|
|
||||||
const b = Array.isArray(room?.tags) ? room!.tags : []
|
|
||||||
const map = new Map<string, string>()
|
|
||||||
for (const t of [...a, ...b]) {
|
|
||||||
const k = String(t).trim().toLowerCase()
|
|
||||||
if (!k) continue
|
|
||||||
if (!map.has(k)) map.set(k, String(t).trim())
|
|
||||||
}
|
|
||||||
return Array.from(map.values()).sort((x, y) => x.localeCompare(y, 'de'))
|
|
||||||
}, [model?.tags, room?.tags])
|
|
||||||
|
|
||||||
const titleName = room?.display_name || model?.modelKey || key || 'Model'
|
|
||||||
const heroImg = room?.image_url_360x270 || room?.image_url || ''
|
|
||||||
const heroImgFull = room?.image_url || heroImg
|
|
||||||
const roomUrl = room?.chat_room_url_revshare || room?.chat_room_url || ''
|
|
||||||
|
|
||||||
const showLabel = (room?.current_show || '').trim().toLowerCase()
|
|
||||||
const showPill = showLabel
|
const showPill = showLabel
|
||||||
? showLabel === 'public'
|
? showLabel === 'public'
|
||||||
? 'Public'
|
? 'Public'
|
||||||
@ -922,6 +873,18 @@ export default function ModelDetails({
|
|||||||
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
|
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
|
||||||
const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : []
|
const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : []
|
||||||
|
|
||||||
|
const allTags = React.useMemo(() => {
|
||||||
|
const a = splitTags(model?.tags)
|
||||||
|
const b = Array.isArray(effectiveRoom?.tags) ? (effectiveRoom!.tags as string[]) : []
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const t of [...a, ...b]) {
|
||||||
|
const k = String(t).trim().toLowerCase()
|
||||||
|
if (!k) continue
|
||||||
|
if (!map.has(k)) map.set(k, String(t).trim())
|
||||||
|
}
|
||||||
|
return Array.from(map.values()).sort((x, y) => x.localeCompare(y, 'de'))
|
||||||
|
}, [model?.tags, effectiveRoom?.tags])
|
||||||
|
|
||||||
const Stat = ({
|
const Stat = ({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
@ -947,22 +910,6 @@ export default function ModelDetails({
|
|||||||
return id ? `${id}::${out}` : out
|
return id ? `${id}::${out}` : out
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const addToSet = (setState: React.Dispatch<React.SetStateAction<Set<string>>>, k: string) =>
|
|
||||||
setState((prev) => {
|
|
||||||
if (prev.has(k)) return prev
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.add(k)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const delFromSet = (setState: React.Dispatch<React.SetStateAction<Set<string>>>, k: string) =>
|
|
||||||
setState((prev) => {
|
|
||||||
if (!prev.has(k)) return prev
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.delete(k)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleToggleHot = React.useCallback(
|
const handleToggleHot = React.useCallback(
|
||||||
async (job: RecordJob) => {
|
async (job: RecordJob) => {
|
||||||
const out = job.output || ''
|
const out = job.output || ''
|
||||||
@ -1141,9 +1088,6 @@ export default function ModelDetails({
|
|||||||
[setScrubIndexForKey]
|
[setScrubIndexForKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
type TabKey = 'info' | 'downloads' | 'running'
|
|
||||||
const [tab, setTab] = React.useState<TabKey>('info')
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setTab('info')
|
setTab('info')
|
||||||
@ -1155,20 +1099,6 @@ export default function ModelDetails({
|
|||||||
{ id: 'running', label: 'Running', count: runningMatches.length ? fmtInt(runningMatches.length) : undefined, disabled: runningLoading },
|
{ id: 'running', label: 'Running', count: runningMatches.length ? fmtInt(runningMatches.length) : undefined, disabled: runningLoading },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ✅ Adapter: RecordJobActions erwartet void|boolean.
|
|
||||||
// Dein onToggleHot darf ein Objekt zurückgeben -> wir droppen das.
|
|
||||||
const onToggleHotAction = React.useCallback(
|
|
||||||
async (job: RecordJob): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
await onToggleHot?.(job)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onToggleHot]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@ -1181,6 +1111,22 @@ export default function ModelDetails({
|
|||||||
mobileCollapsedImageSrc={heroImg || undefined}
|
mobileCollapsedImageSrc={heroImg || undefined}
|
||||||
mobileCollapsedImageAlt={titleName}
|
mobileCollapsedImageAlt={titleName}
|
||||||
rightBodyClassName="pt-0 sm:pt-2"
|
rightBodyClassName="pt-0 sm:pt-2"
|
||||||
|
titleRight={
|
||||||
|
tab === 'info' ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className={cn('h-8 px-2 sm:h-9 sm:px-3', 'whitespace-nowrap')}
|
||||||
|
disabled={bioLoading || !modelKey}
|
||||||
|
onClick={() => void refreshBio()}
|
||||||
|
title="BioContext neu abrufen"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ArrowPathIcon className={cn('size-4', bioLoading ? 'animate-spin' : '')} />
|
||||||
|
<span className="hidden sm:inline">Bio aktualisieren</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
left={
|
left={
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
|
||||||
@ -1237,7 +1183,7 @@ export default function ModelDetails({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{room?.is_hd ? (
|
{effectiveRoom?.is_hd ? (
|
||||||
<span
|
<span
|
||||||
className={pill(
|
className={pill(
|
||||||
'bg-indigo-500/10 text-indigo-900 ring-indigo-200 backdrop-blur dark:text-indigo-200 dark:ring-indigo-400/20'
|
'bg-indigo-500/10 text-indigo-900 ring-indigo-200 backdrop-blur dark:text-indigo-200 dark:ring-indigo-400/20'
|
||||||
@ -1247,7 +1193,7 @@ export default function ModelDetails({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{room?.is_new ? (
|
{effectiveRoom?.is_new ? (
|
||||||
<span
|
<span
|
||||||
className={pill(
|
className={pill(
|
||||||
'bg-amber-500/10 text-amber-900 ring-amber-200 backdrop-blur dark:text-amber-200 dark:ring-amber-400/20'
|
'bg-amber-500/10 text-amber-900 ring-amber-200 backdrop-blur dark:text-amber-200 dark:ring-amber-400/20'
|
||||||
@ -1261,10 +1207,10 @@ export default function ModelDetails({
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="absolute bottom-3 left-3 right-3">
|
<div className="absolute bottom-3 left-3 right-3">
|
||||||
<div className="truncate text-sm font-semibold text-white drop-shadow">
|
<div className="truncate text-sm font-semibold text-white drop-shadow">
|
||||||
{room?.display_name || room?.username || model?.modelKey || key || '—'}
|
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-white/85 drop-shadow">
|
<div className="truncate text-xs text-white/85 drop-shadow">
|
||||||
{room?.username ? `@${room.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
|
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1380,11 +1326,11 @@ export default function ModelDetails({
|
|||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div className="p-3 sm:p-4">
|
<div className="p-3 sm:p-4">
|
||||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||||
<Stat icon={<UsersIcon className="size-4" />} label="Viewer" value={fmtInt(room?.num_users)} />
|
<Stat icon={<UsersIcon className="size-4" />} label="Viewer" value={fmtInt(effectiveRoom?.num_users)} />
|
||||||
<Stat
|
<Stat
|
||||||
icon={<SparklesIcon className="size-4" />}
|
icon={<SparklesIcon className="size-4" />}
|
||||||
label="Follower"
|
label="Follower"
|
||||||
value={fmtInt(room?.num_followers ?? bioFollowers)}
|
value={fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1395,7 +1341,7 @@ export default function ModelDetails({
|
|||||||
Location
|
Location
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
||||||
{room?.location || bioLocation || '—'}
|
{effectiveRoom?.location || bioLocation || '—'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1405,7 +1351,7 @@ export default function ModelDetails({
|
|||||||
Sprache
|
Sprache
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
||||||
{room?.spoken_languages || '—'}
|
{effectiveRoom?.spoken_languages || '—'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1415,7 +1361,7 @@ export default function ModelDetails({
|
|||||||
Online
|
Online
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
||||||
{fmtHms(room?.seconds_online)}
|
{fmtHms(effectiveRoom?.seconds_online)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1425,7 +1371,7 @@ export default function ModelDetails({
|
|||||||
Alter
|
Alter
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
||||||
{bioAge != null ? String(bioAge) : room?.age != null ? String(room.age) : '—'}
|
{bioAge != null ? String(bioAge) : effectiveRoom?.age != null ? String(effectiveRoom.age) : '—'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1441,20 +1387,20 @@ export default function ModelDetails({
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{/* Meta warnings */}
|
{/* Meta warnings */}
|
||||||
{roomMeta?.enabled === false ? (
|
{effectiveRoomMeta?.enabled === false ? (
|
||||||
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
|
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
|
||||||
Chaturbate-Online ist aktuell deaktiviert.
|
Chaturbate-Online ist aktuell deaktiviert.
|
||||||
</div>
|
</div>
|
||||||
) : roomMeta?.lastError ? (
|
) : effectiveRoomMeta?.lastError ? (
|
||||||
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
|
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
|
||||||
<div className="font-medium">Online-Info: {errorSummary(roomMeta.lastError)}</div>
|
<div className="font-medium">Online-Info: {errorSummary(effectiveRoomMeta.lastError)}</div>
|
||||||
|
|
||||||
<details className="mt-1">
|
<details className="mt-1">
|
||||||
<summary className="cursor-pointer select-none text-[11px] text-rose-900/80 underline underline-offset-2 dark:text-rose-200/80">
|
<summary className="cursor-pointer select-none text-[11px] text-rose-900/80 underline underline-offset-2 dark:text-rose-200/80">
|
||||||
Details
|
Details
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-words rounded-md bg-black/5 p-2 text-[11px] leading-snug dark:bg-white/10">
|
<pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-words rounded-md bg-black/5 p-2 text-[11px] leading-snug dark:bg-white/10">
|
||||||
{errorDetails(roomMeta.lastError)}
|
{errorDetails(effectiveRoomMeta.lastError)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@ -1499,50 +1445,44 @@ export default function ModelDetails({
|
|||||||
{/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */}
|
{/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */}
|
||||||
{/* ===================== */}
|
{/* ===================== */}
|
||||||
<div className="sm:hidden px-2 pb-2 space-y-1.5">
|
<div className="sm:hidden px-2 pb-2 space-y-1.5">
|
||||||
{/* Header Card (dein bisheriger Mobile-Block) */}
|
{/* HERO Header (mobile) */}
|
||||||
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-2.5 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-lg border border-gray-200/70 bg-white/70 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 overflow-hidden">
|
||||||
<div className="flex items-start gap-3">
|
{/* Hero Background */}
|
||||||
{/* Avatar */}
|
<div className="relative h-40">
|
||||||
|
{heroImg ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="relative shrink-0 overflow-hidden rounded-lg ring-1 ring-black/5 dark:ring-white/10"
|
className="absolute inset-0 block w-full"
|
||||||
onClick={() => (heroImgFull ? openImage(heroImgFull, titleName) : undefined)}
|
onClick={() => (heroImgFull ? openImage(heroImgFull, titleName) : undefined)}
|
||||||
aria-label="Bild vergrößern"
|
aria-label="Bild vergrößern"
|
||||||
>
|
>
|
||||||
{heroImg ? (
|
|
||||||
<img
|
<img
|
||||||
src={heroImg}
|
src={heroImgFull || heroImg}
|
||||||
alt={titleName}
|
alt={titleName}
|
||||||
className={cn('size-10 object-cover', previewBlurCls(blurPreviews))}
|
className={cn('h-full w-full object-cover', previewBlurCls(blurPreviews))}
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="size-10 bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status dot */}
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
'absolute bottom-1.5 right-1.5 size-2.5 rounded-full ring-2 ring-white/80 dark:ring-gray-900/60',
|
|
||||||
(effectivePresenceLabel || '').toLowerCase() === 'online' ? 'bg-emerald-400' : 'bg-gray-400'
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Name + actions (right) + pills */}
|
{/* Gradient overlay */}
|
||||||
<div className="min-w-0 flex-1">
|
<div
|
||||||
<div className="flex items-start justify-between gap-2">
|
aria-hidden
|
||||||
{/* Name */}
|
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-black/0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top row: name + action icons */}
|
||||||
|
<div className="absolute left-3 right-3 top-3 flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="truncate text-base font-semibold text-white drop-shadow">
|
||||||
{room?.display_name || room?.username || model?.modelKey || key || '—'}
|
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
|
<div className="truncate text-xs text-white/85 drop-shadow">
|
||||||
{room?.username ? `@${room.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
|
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ Buttons rechts neben Name */}
|
|
||||||
<div className="shrink-0 flex items-center gap-1.5">
|
<div className="shrink-0 flex items-center gap-1.5">
|
||||||
{/* Watched */}
|
{/* Watched */}
|
||||||
<button
|
<button
|
||||||
@ -1555,29 +1495,15 @@ export default function ModelDetails({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||||||
'transition active:scale-[0.98]',
|
'transition active:scale-[0.98]',
|
||||||
model?.watching
|
model?.watching ? 'bg-sky-500/25 ring-sky-200/30' : 'bg-black/30 ring-white/20'
|
||||||
? 'bg-sky-500/15 ring-sky-200/30'
|
|
||||||
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
|
|
||||||
)}
|
)}
|
||||||
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
|
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
|
||||||
aria-pressed={Boolean(model?.watching)}
|
aria-pressed={Boolean(model?.watching)}
|
||||||
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
|
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
|
||||||
>
|
>
|
||||||
<span className="relative inline-block size-4">
|
<span className="relative inline-block size-4">
|
||||||
<EyeOutlineIcon
|
<EyeOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
|
||||||
className={cn(
|
<EyeSolidIcon className={cn('absolute inset-0 size-4', model?.watching ? 'opacity-100' : 'opacity-0', 'text-sky-200')} />
|
||||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
||||||
model?.watching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
|
||||||
'text-gray-700 dark:text-white/70'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<EyeSolidIcon
|
|
||||||
className={cn(
|
|
||||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
||||||
model?.watching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
|
||||||
'text-sky-500'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -1592,29 +1518,15 @@ export default function ModelDetails({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||||||
'transition active:scale-[0.98]',
|
'transition active:scale-[0.98]',
|
||||||
model?.favorite
|
model?.favorite ? 'bg-amber-500/25 ring-amber-200/30' : 'bg-black/30 ring-white/20'
|
||||||
? 'bg-amber-500/15 ring-amber-200/30'
|
|
||||||
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
|
|
||||||
)}
|
)}
|
||||||
title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
aria-pressed={Boolean(model?.favorite)}
|
aria-pressed={Boolean(model?.favorite)}
|
||||||
aria-label={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
aria-label={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
>
|
>
|
||||||
<span className="relative inline-block size-4">
|
<span className="relative inline-block size-4">
|
||||||
<StarOutlineIcon
|
<StarOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
|
||||||
className={cn(
|
<StarSolidIcon className={cn('absolute inset-0 size-4', model?.favorite ? 'opacity-100' : 'opacity-0', 'text-amber-200')} />
|
||||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
||||||
model?.favorite ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
|
||||||
'text-gray-700 dark:text-white/70'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<StarSolidIcon
|
|
||||||
className={cn(
|
|
||||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
||||||
model?.favorite ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
|
||||||
'text-amber-500'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -1629,91 +1541,52 @@ export default function ModelDetails({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||||||
'transition active:scale-[0.98]',
|
'transition active:scale-[0.98]',
|
||||||
model?.liked
|
model?.liked ? 'bg-rose-500/25 ring-rose-200/30' : 'bg-black/30 ring-white/20'
|
||||||
? 'bg-rose-500/15 ring-rose-200/30'
|
|
||||||
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
|
|
||||||
)}
|
)}
|
||||||
title={model?.liked ? 'Like entfernen' : 'Liken'}
|
title={model?.liked ? 'Like entfernen' : 'Liken'}
|
||||||
aria-pressed={model?.liked === true}
|
aria-pressed={model?.liked === true}
|
||||||
aria-label={model?.liked ? 'Like entfernen' : 'Liken'}
|
aria-label={model?.liked ? 'Like entfernen' : 'Liken'}
|
||||||
>
|
>
|
||||||
<span className="relative inline-block size-4">
|
<span className="relative inline-block size-4">
|
||||||
<HeartOutlineIcon
|
<HeartOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
|
||||||
className={cn(
|
<HeartSolidIcon className={cn('absolute inset-0 size-4', model?.liked ? 'opacity-100' : 'opacity-0', 'text-rose-200')} />
|
||||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
||||||
model?.liked ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
|
||||||
'text-gray-700 dark:text-white/70'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<HeartSolidIcon
|
|
||||||
className={cn(
|
|
||||||
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
||||||
model?.liked ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
|
||||||
'text-rose-500'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pills (jetzt unter der Namenszeile) */}
|
{/* Pills bottom-left */}
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
<div className="absolute left-3 bottom-3 flex flex-wrap items-center gap-1.5">
|
||||||
{showPill ? (
|
{showPill ? (
|
||||||
<span
|
<span className={pill('bg-white/15 text-white ring-white/20')}>
|
||||||
className={pill(
|
|
||||||
'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showPill}
|
{showPill}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{bioStatus ? (
|
{effectivePresenceLabel ? (
|
||||||
<span
|
<span
|
||||||
className={pill(
|
className={pill(
|
||||||
bioStatus.toLowerCase() === 'online'
|
(effectivePresenceLabel || '').toLowerCase() === 'online'
|
||||||
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20'
|
? 'bg-emerald-500/25 text-white ring-emerald-200/30'
|
||||||
: 'bg-gray-500/10 text-gray-900 ring-gray-200 dark:text-gray-200 dark:ring-white/15'
|
: 'bg-white/15 text-white ring-white/20'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{bioStatus}
|
{effectivePresenceLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{room?.is_hd ? (
|
{effectiveRoom?.is_hd ? <span className={pill('bg-white/15 text-white ring-white/20')}>HD</span> : null}
|
||||||
<span
|
{effectiveRoom?.is_new ? <span className={pill('bg-white/15 text-white ring-white/20')}>NEW</span> : null}
|
||||||
className={pill(
|
|
||||||
'bg-indigo-500/10 text-indigo-900 ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/20'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
HD
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{room?.is_new ? (
|
|
||||||
<span
|
|
||||||
className={pill(
|
|
||||||
'bg-amber-500/10 text-amber-900 ring-amber-200 dark:text-amber-200 dark:ring-amber-400/20'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
NEW
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row unten: nur Room-Link (optional) */}
|
{/* Room link bottom-right */}
|
||||||
{roomUrl ? (
|
{roomUrl ? (
|
||||||
<div className="mt-1.5 flex justify-end">
|
<div className="absolute right-3 bottom-3">
|
||||||
<a
|
<a
|
||||||
href={roomUrl}
|
href={roomUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={cn(
|
className="inline-flex h-8 items-center justify-center gap-1 rounded-lg px-3 text-sm font-medium bg-white/15 text-white ring-1 ring-white/20 backdrop-blur hover:bg-white/20"
|
||||||
'inline-flex h-8 items-center justify-center gap-1 rounded-lg px-3 text-sm font-medium',
|
|
||||||
'border border-gray-200/70 bg-white/70 text-gray-900 shadow-sm backdrop-blur hover:bg-white',
|
|
||||||
'dark:border-white/10 dark:bg-white/5 dark:text-white'
|
|
||||||
)}
|
|
||||||
title="Room öffnen"
|
title="Room öffnen"
|
||||||
>
|
>
|
||||||
<ArrowTopRightOnSquareIcon className="size-4" />
|
<ArrowTopRightOnSquareIcon className="size-4" />
|
||||||
@ -1722,21 +1595,21 @@ export default function ModelDetails({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick stats (compact row) */}
|
{/* Quick stats row (unter dem Hero) */}
|
||||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[12px] text-gray-700 dark:text-gray-200">
|
<div className="px-3 py-2 text-[12px] text-gray-700 dark:text-gray-200">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<UsersIcon className="size-3.5 text-gray-400" />
|
<UsersIcon className="size-3.5 text-gray-400" />
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(room?.num_users)}</span>
|
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(effectiveRoom?.num_users)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<SparklesIcon className="size-3.5 text-gray-400" />
|
<SparklesIcon className="size-3.5 text-gray-400" />
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(room?.num_followers ?? bioFollowers)}</span>
|
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<ClockIcon className="size-3.5 text-gray-400" />
|
<ClockIcon className="size-3.5 text-gray-400" />
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">{fmtHms(room?.seconds_online)}</span>
|
<span className="font-semibold text-gray-900 dark:text-white">{fmtHms(effectiveRoom?.seconds_online)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<CalendarDaysIcon className="size-3.5 text-gray-400" />
|
<CalendarDaysIcon className="size-3.5 text-gray-400" />
|
||||||
@ -1747,13 +1620,13 @@ export default function ModelDetails({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meta warnings (mobile) */}
|
{/* Meta warnings (mobile) */}
|
||||||
{roomMeta?.enabled === false ? (
|
{effectiveRoomMeta?.enabled === false ? (
|
||||||
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
|
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
|
||||||
Chaturbate-Online ist aktuell deaktiviert.
|
Chaturbate-Online ist aktuell deaktiviert.
|
||||||
</div>
|
</div>
|
||||||
) : roomMeta?.lastError ? (
|
) : effectiveRoomMeta?.lastError ? (
|
||||||
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
|
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
|
||||||
Online-Info: {roomMeta.lastError}
|
Online-Info: {effectiveRoomMeta.lastError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -1777,6 +1650,7 @@ export default function ModelDetails({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tags (mobile, compact row) */}
|
{/* Tags (mobile, compact row) */}
|
||||||
{allTags.length ? (
|
{allTags.length ? (
|
||||||
@ -1806,15 +1680,15 @@ export default function ModelDetails({
|
|||||||
{/* ===================== */}
|
{/* ===================== */}
|
||||||
{/* (dein bisheriger Header) Row 1: Meta + Actions */}
|
{/* (dein bisheriger Header) Row 1: Meta + Actions */}
|
||||||
{/* ===================== */}
|
{/* ===================== */}
|
||||||
<div className="flex items-start justify-between gap-2 px-2 py-2 sm:px-4">
|
<div className="hidden sm:flex items-start justify-between gap-2 px-2 py-2 sm:px-4">
|
||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[11px] leading-snug text-gray-600 dark:text-gray-300">
|
<div className="text-[11px] leading-snug text-gray-600 dark:text-gray-300">
|
||||||
{key ? (
|
{key ? (
|
||||||
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||||
{roomMeta?.fetchedAt ? (
|
{effectiveRoomMeta?.fetchedAt ? (
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
Online-Stand: {fmtDateTime(roomMeta.fetchedAt)}
|
Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{bioMeta?.fetchedAt ? (
|
{bioMeta?.fetchedAt ? (
|
||||||
@ -1844,21 +1718,6 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{tab === 'info' ? (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className={cn('h-9 px-3 text-sm', 'whitespace-nowrap')}
|
|
||||||
disabled={bioLoading || !modelKey}
|
|
||||||
onClick={() => setBioRefreshSeq((x) => x + 1)}
|
|
||||||
title="BioContext neu abrufen"
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<ArrowPathIcon className={cn('size-4', bioLoading ? 'animate-spin' : '')} />
|
|
||||||
<span className="hidden sm:inline">Bio aktualisieren</span>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{roomUrl ? (
|
{roomUrl ? (
|
||||||
<a
|
<a
|
||||||
href={roomUrl}
|
href={roomUrl}
|
||||||
@ -1914,8 +1773,8 @@ export default function ModelDetails({
|
|||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Room Subject</div>
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">Room Subject</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-gray-800 dark:text-gray-100">
|
<div className="mt-2 text-sm text-gray-800 dark:text-gray-100">
|
||||||
{room?.room_subject ? (
|
{effectiveRoom?.room_subject ? (
|
||||||
<p className="line-clamp-4 whitespace-pre-wrap break-words">{room.room_subject}</p>
|
<p className="line-clamp-4 whitespace-pre-wrap break-words">{effectiveRoom.room_subject}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-600 dark:text-gray-300">Keine Subject-Info vorhanden.</p>
|
<p className="text-gray-600 dark:text-gray-300">Keine Subject-Info vorhanden.</p>
|
||||||
)}
|
)}
|
||||||
@ -2547,11 +2406,6 @@ export default function ModelDetails({
|
|||||||
const dur = runtimeOf(j)
|
const dur = runtimeOf(j)
|
||||||
const size = formatBytes(sizeBytesOf(j))
|
const size = formatBytes(sizeBytesOf(j))
|
||||||
|
|
||||||
// Flags: aktuelles Model
|
|
||||||
const isFav = Boolean(model?.favorite)
|
|
||||||
const isLiked = model?.liked === true
|
|
||||||
const isWatching = Boolean(model?.watching)
|
|
||||||
|
|
||||||
const cardTags = allTags
|
const cardTags = allTags
|
||||||
const modelImageSrc = firstNonEmptyString(heroImgFull, heroImg)
|
const modelImageSrc = firstNonEmptyString(heroImgFull, heroImg)
|
||||||
|
|
||||||
@ -2630,8 +2484,6 @@ export default function ModelDetails({
|
|||||||
const hasSpriteScrubber = hasScrubberUi && spriteCols > 0 && spriteRows > 0
|
const hasSpriteScrubber = hasScrubberUi && spriteCols > 0 && spriteRows > 0
|
||||||
|
|
||||||
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
||||||
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
|
|
||||||
const hasScrubber = hasScrubberUi
|
|
||||||
|
|
||||||
const activeScrubIndex = scrubIndexByKey[k]
|
const activeScrubIndex = scrubIndexByKey[k]
|
||||||
const scrubProgressRatio =
|
const scrubProgressRatio =
|
||||||
|
|||||||
@ -442,8 +442,19 @@ export default function ModelsTab() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
try {
|
||||||
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' })
|
const res = await fetch('/api/models', { cache: 'no-store' as any })
|
||||||
setModels(Array.isArray(list) ? list : [])
|
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`))
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
// ✅ akzeptiere beide Formen: Array ODER { items: [...] }
|
||||||
|
const list: StoredModel[] = Array.isArray(data?.items)
|
||||||
|
? (data.items as StoredModel[])
|
||||||
|
: Array.isArray(data)
|
||||||
|
? (data as StoredModel[])
|
||||||
|
: []
|
||||||
|
|
||||||
|
setModels(list)
|
||||||
void refreshVideoCounts()
|
void refreshVideoCounts()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(e?.message ?? String(e))
|
setErr(e?.message ?? String(e))
|
||||||
|
|||||||
@ -1744,8 +1744,7 @@ export default function Player({
|
|||||||
const videoChrome = (
|
const videoChrome = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative overflow-visible',
|
'relative overflow-visible flex-1 min-h-0'
|
||||||
expanded ? 'flex-1 min-h-0' : miniDesktop ? 'flex-1 min-h-0' : 'aspect-video'
|
|
||||||
)}
|
)}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (!miniDesktop || !canHover) return
|
if (!miniDesktop || !canHover) return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user