updated
This commit is contained in:
parent
d578d4e6aa
commit
0fac07f620
@ -29,14 +29,14 @@ type Model struct {
|
||||
Liked *bool `json:"liked"` // nil = keine Angabe
|
||||
}
|
||||
|
||||
type modelStore struct {
|
||||
type jsonModelStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
loaded bool
|
||||
items []Model
|
||||
}
|
||||
|
||||
var models = &modelStore{
|
||||
var models = &jsonModelStore{
|
||||
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"})
|
||||
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
|
||||
|
||||
@ -3,7 +3,9 @@ package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@ -25,6 +27,11 @@ type StoredModel struct {
|
||||
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
|
||||
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"`
|
||||
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
|
||||
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 = 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
|
||||
END,
|
||||
|
||||
@ -784,7 +791,10 @@ UPDATE models SET
|
||||
favorite = CASE WHEN $8=true THEN true ELSE favorite END,
|
||||
hot = CASE WHEN $9=true THEN true ELSE hot 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
|
||||
WHERE id = $13;
|
||||
@ -817,12 +827,21 @@ func (s *ModelStore) List() []StoredModel {
|
||||
return []StoredModel{}
|
||||
}
|
||||
|
||||
// ✅ last_stream ist TIMESTAMPTZ -> direkt lesen (NullTime), niemals COALESCE(...,'')
|
||||
rows, err := s.db.Query(`
|
||||
q1 := `
|
||||
SELECT
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags, last_stream,
|
||||
last_seen_online, last_seen_online_at,
|
||||
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,
|
||||
COALESCE(cb_online_last_error,''), -- optional
|
||||
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,
|
||||
@ -830,10 +849,42 @@ SELECT
|
||||
created_at, updated_at
|
||||
FROM models
|
||||
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 {
|
||||
// ✅ 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{}
|
||||
}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]StoredModel, 0, 64)
|
||||
@ -841,7 +892,6 @@ ORDER BY updated_at DESC;
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, input, host, path, modelKey, tags string
|
||||
|
||||
isURL bool
|
||||
|
||||
lastStream sql.NullTime
|
||||
@ -849,6 +899,10 @@ ORDER BY updated_at DESC;
|
||||
lastSeenOnline sql.NullBool
|
||||
lastSeenOnlineAt sql.NullTime
|
||||
|
||||
cbOnlineJSON string
|
||||
cbOnlineFetchedAt sql.NullTime
|
||||
cbOnlineLastError string
|
||||
|
||||
profileImageURL string
|
||||
profileImageUpdatedAt sql.NullTime
|
||||
hasProfileImage int64
|
||||
@ -863,6 +917,7 @@ ORDER BY updated_at DESC;
|
||||
&id, &input, &isURL, &host, &path, &modelKey,
|
||||
&tags, &lastStream,
|
||||
&lastSeenOnline, &lastSeenOnlineAt,
|
||||
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
|
||||
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
|
||||
&watching, &favorite, &hot, &keep, &liked,
|
||||
&createdAt, &updatedAt,
|
||||
@ -882,6 +937,10 @@ ORDER BY updated_at DESC;
|
||||
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
||||
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
|
||||
|
||||
CbOnlineJSON: cbOnlineJSON,
|
||||
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
|
||||
CbOnlineLastError: cbOnlineLastError,
|
||||
|
||||
Watching: watching,
|
||||
Favorite: favorite,
|
||||
Hot: hot,
|
||||
@ -919,6 +978,166 @@ func (s *ModelStore) Meta() ModelsMeta {
|
||||
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)
|
||||
func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
|
||||
if err := s.ensureInit(); err != nil {
|
||||
@ -933,14 +1152,14 @@ func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
|
||||
)
|
||||
if hostFilter == "" {
|
||||
rows, err = s.db.Query(`
|
||||
SELECT id,input,host,model_key,watching
|
||||
SELECT id,input,COALESCE(host,'') as host,model_key,watching
|
||||
FROM models
|
||||
WHERE watching = true
|
||||
ORDER BY updated_at DESC;
|
||||
`)
|
||||
} else {
|
||||
rows, err = s.db.Query(`
|
||||
SELECT id,input,host,model_key,watching
|
||||
SELECT id,input,COALESCE(host,'') as host,model_key,watching
|
||||
FROM models
|
||||
WHERE watching = true AND host = $1
|
||||
ORDER BY updated_at DESC;
|
||||
@ -1200,6 +1419,10 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
|
||||
lastSeenOnline sql.NullBool
|
||||
lastSeenOnlineAt sql.NullTime
|
||||
|
||||
cbOnlineJSON string
|
||||
cbOnlineFetchedAt sql.NullTime
|
||||
cbOnlineLastError string
|
||||
|
||||
profileImageURL string
|
||||
profileImageUpdatedAt sql.NullTime
|
||||
hasProfileImage int64
|
||||
@ -1210,11 +1433,21 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
|
||||
createdAt, updatedAt time.Time
|
||||
)
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
// q1: mit optionaler Spalte cb_online_last_error
|
||||
q1 := `
|
||||
SELECT
|
||||
input,is_url,host,path,model_key,
|
||||
tags, last_stream,
|
||||
last_seen_online, last_seen_online_at,
|
||||
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,
|
||||
COALESCE(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,
|
||||
@ -1222,19 +1455,62 @@ SELECT
|
||||
created_at, updated_at
|
||||
FROM models
|
||||
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,
|
||||
&tags, &lastStream,
|
||||
&lastSeenOnline, &lastSeenOnlineAt,
|
||||
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
|
||||
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
|
||||
&watching, &favorite, &hot, &keep, &liked,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
err := scan(q1)
|
||||
if err != nil {
|
||||
// Wenn die Zeile nicht existiert, nicht noch fallbacken.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
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{
|
||||
@ -1249,6 +1525,10 @@ WHERE id=$1;
|
||||
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
||||
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
|
||||
|
||||
CbOnlineJSON: cbOnlineJSON,
|
||||
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
|
||||
CbOnlineLastError: cbOnlineLastError,
|
||||
|
||||
Watching: watching,
|
||||
Favorite: favorite,
|
||||
Hot: hot,
|
||||
|
||||
@ -501,7 +501,7 @@ function DownloadsCardRow({
|
||||
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
{ /* Status-Badge */}
|
||||
<span
|
||||
className={[
|
||||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
||||
|
||||
@ -23,7 +23,6 @@ import {
|
||||
PhotoIcon,
|
||||
SparklesIcon,
|
||||
UsersIcon,
|
||||
FilmIcon,
|
||||
ClockIcon,
|
||||
EyeIcon as EyeOutlineIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
@ -32,7 +31,6 @@ import {
|
||||
StarIcon as StarSolidIcon,
|
||||
EyeIcon as EyeSolidIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import { useMediaQuery } from '../../lib/useMediaQuery'
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
import PreviewScrubber from './PreviewScrubber'
|
||||
@ -255,16 +253,6 @@ function pill(cls: string) {
|
||||
const previewBlurCls = (blur?: boolean) =>
|
||||
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 {
|
||||
for (const v of values) {
|
||||
if (typeof v === 'string') {
|
||||
@ -335,6 +323,7 @@ function chooseSpriteGrid(count: number): [number, number] {
|
||||
type ChaturbateRoom = {
|
||||
gender?: string
|
||||
location?: string
|
||||
country?: string
|
||||
current_show?: string
|
||||
username?: string
|
||||
room_subject?: string
|
||||
@ -446,6 +435,9 @@ type StoredModel = {
|
||||
keep?: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
cbOnlineJson?: string | null
|
||||
cbOnlineFetchedAt?: string | null
|
||||
cbOnlineLastError?: string | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@ -536,10 +528,9 @@ export default function ModelDetails({
|
||||
onStopJob
|
||||
}: Props) {
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||
//const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||
|
||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
||||
const [, setModelsLoading] = React.useState(false)
|
||||
|
||||
const [room, setRoom] = React.useState<ChaturbateRoom | 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 [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 [runningHover, setRunningHover] = React.useState(false)
|
||||
const [, setRunningHover] = React.useState(false)
|
||||
|
||||
const [stopPending, setStopPending] = React.useState(false)
|
||||
|
||||
@ -569,6 +562,12 @@ export default function ModelDetails({
|
||||
|
||||
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) =====
|
||||
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
||||
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
|
||||
@ -623,6 +622,54 @@ export default function ModelDetails({
|
||||
setStopPending(false)
|
||||
}, [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 () => {
|
||||
try {
|
||||
const r = await fetch('/api/models', { cache: 'no-store' })
|
||||
@ -657,6 +704,35 @@ export default function ModelDetails({
|
||||
}
|
||||
}, [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
|
||||
function jobFromModelKey(key: string): RecordJob {
|
||||
// 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)
|
||||
}, [open, modelKey])
|
||||
|
||||
// Models list (local flags + stored tags)
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
let alive = true
|
||||
setModelsLoading(true)
|
||||
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])
|
||||
void refetchModels()
|
||||
}, [open, refetchModels])
|
||||
|
||||
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
|
||||
React.useEffect(() => {
|
||||
@ -834,26 +779,31 @@ export default function ModelDetails({
|
||||
if (!open) return
|
||||
if (Array.isArray(runningJobs)) return
|
||||
|
||||
let alive = true
|
||||
const ac = new AbortController()
|
||||
const seq = ++runningReqSeqRef.current
|
||||
|
||||
setRunningLoading(true)
|
||||
|
||||
fetch('/api/record/jobs', { cache: 'no-store' })
|
||||
fetch('/api/record/jobs', { cache: 'no-store', signal: ac.signal })
|
||||
.then((r) => r.json())
|
||||
.then((data: RecordJob[]) => {
|
||||
if (!alive) return
|
||||
if (ac.signal.aborted) return
|
||||
if (runningReqSeqRef.current !== seq) return
|
||||
setRunning(Array.isArray(data) ? data : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!alive) return
|
||||
if (ac.signal.aborted) return
|
||||
if (runningReqSeqRef.current !== seq) return
|
||||
setRunning([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!alive) return
|
||||
if (ac.signal.aborted) return
|
||||
if (runningReqSeqRef.current !== seq) return
|
||||
setRunningLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
ac.abort()
|
||||
}
|
||||
}, [open, runningJobs])
|
||||
|
||||
@ -862,11 +812,27 @@ export default function ModelDetails({
|
||||
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
|
||||
}, [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(() => {
|
||||
return Math.max(1, Math.ceil(doneTotalCount / DONE_PAGE_SIZE))
|
||||
}, [doneTotalCount])
|
||||
const storedRoomMeta = React.useMemo(() => {
|
||||
const fetchedAt = (model as any)?.cbOnlineFetchedAt
|
||||
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(() => {
|
||||
if (!key) return []
|
||||
@ -876,27 +842,12 @@ export default function ModelDetails({
|
||||
})
|
||||
}, [runningList, key])
|
||||
|
||||
// ✅ Running-Hero: wenn es einen laufenden Job für dieses Model gibt, nimm dessen Preview
|
||||
const runningHeroJob = runningMatches.length ? runningMatches[0] : null
|
||||
const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
|
||||
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 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 showLabel = (effectiveRoom?.current_show || '').trim().toLowerCase()
|
||||
const showPill = showLabel
|
||||
? showLabel === 'public'
|
||||
? 'Public'
|
||||
@ -922,6 +873,18 @@ export default function ModelDetails({
|
||||
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
|
||||
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 = ({
|
||||
icon,
|
||||
label,
|
||||
@ -947,22 +910,6 @@ export default function ModelDetails({
|
||||
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(
|
||||
async (job: RecordJob) => {
|
||||
const out = job.output || ''
|
||||
@ -1141,9 +1088,6 @@ export default function ModelDetails({
|
||||
[setScrubIndexForKey]
|
||||
)
|
||||
|
||||
type TabKey = 'info' | 'downloads' | 'running'
|
||||
const [tab, setTab] = React.useState<TabKey>('info')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
setTab('info')
|
||||
@ -1155,20 +1099,6 @@ export default function ModelDetails({
|
||||
{ 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 (
|
||||
<Modal
|
||||
open={open}
|
||||
@ -1181,6 +1111,22 @@ export default function ModelDetails({
|
||||
mobileCollapsedImageSrc={heroImg || undefined}
|
||||
mobileCollapsedImageAlt={titleName}
|
||||
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={
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
|
||||
@ -1237,7 +1183,7 @@ export default function ModelDetails({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{room?.is_hd ? (
|
||||
{effectiveRoom?.is_hd ? (
|
||||
<span
|
||||
className={pill(
|
||||
'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>
|
||||
) : null}
|
||||
|
||||
{room?.is_new ? (
|
||||
{effectiveRoom?.is_new ? (
|
||||
<span
|
||||
className={pill(
|
||||
'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 */}
|
||||
<div className="absolute bottom-3 left-3 right-3">
|
||||
<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 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>
|
||||
|
||||
@ -1380,11 +1326,11 @@ export default function ModelDetails({
|
||||
{/* Summary */}
|
||||
<div className="p-3 sm:p-4">
|
||||
<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
|
||||
icon={<SparklesIcon className="size-4" />}
|
||||
label="Follower"
|
||||
value={fmtInt(room?.num_followers ?? bioFollowers)}
|
||||
value={fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1395,7 +1341,7 @@ export default function ModelDetails({
|
||||
Location
|
||||
</dt>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -1405,7 +1351,7 @@ export default function ModelDetails({
|
||||
Sprache
|
||||
</dt>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -1415,7 +1361,7 @@ export default function ModelDetails({
|
||||
Online
|
||||
</dt>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -1425,7 +1371,7 @@ export default function ModelDetails({
|
||||
Alter
|
||||
</dt>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -1441,20 +1387,20 @@ export default function ModelDetails({
|
||||
</dl>
|
||||
|
||||
{/* 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">
|
||||
Chaturbate-Online ist aktuell deaktiviert.
|
||||
</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="font-medium">Online-Info: {errorSummary(roomMeta.lastError)}</div>
|
||||
<div className="font-medium">Online-Info: {errorSummary(effectiveRoomMeta.lastError)}</div>
|
||||
|
||||
<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">
|
||||
Details
|
||||
</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">
|
||||
{errorDetails(roomMeta.lastError)}
|
||||
{errorDetails(effectiveRoomMeta.lastError)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
@ -1499,50 +1445,44 @@ export default function ModelDetails({
|
||||
{/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */}
|
||||
{/* ===================== */}
|
||||
<div className="sm:hidden px-2 pb-2 space-y-1.5">
|
||||
{/* Header Card (dein bisheriger Mobile-Block) */}
|
||||
<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="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
{/* HERO Header (mobile) */}
|
||||
<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">
|
||||
{/* Hero Background */}
|
||||
<div className="relative h-40">
|
||||
{heroImg ? (
|
||||
<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)}
|
||||
aria-label="Bild vergrößern"
|
||||
>
|
||||
{heroImg ? (
|
||||
<img
|
||||
src={heroImg}
|
||||
src={heroImgFull || heroImg}
|
||||
alt={titleName}
|
||||
className={cn('size-10 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'
|
||||
)}
|
||||
className={cn('h-full w-full object-cover', previewBlurCls(blurPreviews))}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" />
|
||||
)}
|
||||
|
||||
{/* Name + actions (right) + pills */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/* Name */}
|
||||
{/* Gradient overlay */}
|
||||
<div
|
||||
aria-hidden
|
||||
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="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{room?.display_name || room?.username || model?.modelKey || key || '—'}
|
||||
<div className="truncate text-base font-semibold text-white drop-shadow">
|
||||
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
|
||||
</div>
|
||||
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
|
||||
{room?.username ? `@${room.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
|
||||
<div className="truncate text-xs text-white/85 drop-shadow">
|
||||
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Buttons rechts neben Name */}
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
{/* Watched */}
|
||||
<button
|
||||
@ -1555,29 +1495,15 @@ export default function ModelDetails({
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||||
'transition active:scale-[0.98]',
|
||||
model?.watching
|
||||
? 'bg-sky-500/15 ring-sky-200/30'
|
||||
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
|
||||
model?.watching ? 'bg-sky-500/25 ring-sky-200/30' : 'bg-black/30 ring-white/20'
|
||||
)}
|
||||
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
|
||||
aria-pressed={Boolean(model?.watching)}
|
||||
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
|
||||
>
|
||||
<span className="relative inline-block size-4">
|
||||
<EyeOutlineIcon
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
/>
|
||||
<EyeOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
|
||||
<EyeSolidIcon className={cn('absolute inset-0 size-4', model?.watching ? 'opacity-100' : 'opacity-0', 'text-sky-200')} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -1592,29 +1518,15 @@ export default function ModelDetails({
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||||
'transition active:scale-[0.98]',
|
||||
model?.favorite
|
||||
? 'bg-amber-500/15 ring-amber-200/30'
|
||||
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
|
||||
model?.favorite ? 'bg-amber-500/25 ring-amber-200/30' : 'bg-black/30 ring-white/20'
|
||||
)}
|
||||
title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
aria-pressed={Boolean(model?.favorite)}
|
||||
aria-label={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||
>
|
||||
<span className="relative inline-block size-4">
|
||||
<StarOutlineIcon
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
/>
|
||||
<StarOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
|
||||
<StarSolidIcon className={cn('absolute inset-0 size-4', model?.favorite ? 'opacity-100' : 'opacity-0', 'text-amber-200')} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -1629,91 +1541,52 @@ export default function ModelDetails({
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||||
'transition active:scale-[0.98]',
|
||||
model?.liked
|
||||
? 'bg-rose-500/15 ring-rose-200/30'
|
||||
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
|
||||
model?.liked ? 'bg-rose-500/25 ring-rose-200/30' : 'bg-black/30 ring-white/20'
|
||||
)}
|
||||
title={model?.liked ? 'Like entfernen' : 'Liken'}
|
||||
aria-pressed={model?.liked === true}
|
||||
aria-label={model?.liked ? 'Like entfernen' : 'Liken'}
|
||||
>
|
||||
<span className="relative inline-block size-4">
|
||||
<HeartOutlineIcon
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
/>
|
||||
<HeartOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
|
||||
<HeartSolidIcon className={cn('absolute inset-0 size-4', model?.liked ? 'opacity-100' : 'opacity-0', 'text-rose-200')} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pills (jetzt unter der Namenszeile) */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
{/* Pills bottom-left */}
|
||||
<div className="absolute left-3 bottom-3 flex flex-wrap items-center gap-1.5">
|
||||
{showPill ? (
|
||||
<span
|
||||
className={pill(
|
||||
'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20'
|
||||
)}
|
||||
>
|
||||
<span className={pill('bg-white/15 text-white ring-white/20')}>
|
||||
{showPill}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{bioStatus ? (
|
||||
{effectivePresenceLabel ? (
|
||||
<span
|
||||
className={pill(
|
||||
bioStatus.toLowerCase() === 'online'
|
||||
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20'
|
||||
: 'bg-gray-500/10 text-gray-900 ring-gray-200 dark:text-gray-200 dark:ring-white/15'
|
||||
(effectivePresenceLabel || '').toLowerCase() === 'online'
|
||||
? 'bg-emerald-500/25 text-white ring-emerald-200/30'
|
||||
: 'bg-white/15 text-white ring-white/20'
|
||||
)}
|
||||
>
|
||||
{bioStatus}
|
||||
{effectivePresenceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{room?.is_hd ? (
|
||||
<span
|
||||
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}
|
||||
{effectiveRoom?.is_hd ? <span className={pill('bg-white/15 text-white ring-white/20')}>HD</span> : null}
|
||||
{effectiveRoom?.is_new ? <span className={pill('bg-white/15 text-white ring-white/20')}>NEW</span> : null}
|
||||
</div>
|
||||
|
||||
{/* Row unten: nur Room-Link (optional) */}
|
||||
{/* Room link bottom-right */}
|
||||
{roomUrl ? (
|
||||
<div className="mt-1.5 flex justify-end">
|
||||
<div className="absolute right-3 bottom-3">
|
||||
<a
|
||||
href={roomUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
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"
|
||||
title="Room öffnen"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="size-4" />
|
||||
@ -1722,21 +1595,21 @@ export default function ModelDetails({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats (compact row) */}
|
||||
<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">
|
||||
{/* Quick stats row (unter dem Hero) */}
|
||||
<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">
|
||||
<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 className="inline-flex items-center gap-1">
|
||||
<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 className="inline-flex items-center gap-1">
|
||||
<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 className="inline-flex items-center gap-1">
|
||||
<CalendarDaysIcon className="size-3.5 text-gray-400" />
|
||||
@ -1747,13 +1620,13 @@ export default function ModelDetails({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
Chaturbate-Online ist aktuell deaktiviert.
|
||||
</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">
|
||||
Online-Info: {roomMeta.lastError}
|
||||
Online-Info: {effectiveRoomMeta.lastError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -1777,6 +1650,7 @@ export default function ModelDetails({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags (mobile, compact row) */}
|
||||
{allTags.length ? (
|
||||
@ -1806,15 +1680,15 @@ export default function ModelDetails({
|
||||
{/* ===================== */}
|
||||
{/* (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 */}
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] leading-snug text-gray-600 dark:text-gray-300">
|
||||
{key ? (
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||
{roomMeta?.fetchedAt ? (
|
||||
{effectiveRoomMeta?.fetchedAt ? (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Online-Stand: {fmtDateTime(roomMeta.fetchedAt)}
|
||||
Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
{bioMeta?.fetchedAt ? (
|
||||
@ -1844,21 +1718,6 @@ export default function ModelDetails({
|
||||
|
||||
{/* Actions */}
|
||||
<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 ? (
|
||||
<a
|
||||
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>
|
||||
<div className="mt-2 text-sm text-gray-800 dark:text-gray-100">
|
||||
{room?.room_subject ? (
|
||||
<p className="line-clamp-4 whitespace-pre-wrap break-words">{room.room_subject}</p>
|
||||
{effectiveRoom?.room_subject ? (
|
||||
<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>
|
||||
)}
|
||||
@ -2547,11 +2406,6 @@ export default function ModelDetails({
|
||||
const dur = runtimeOf(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 modelImageSrc = firstNonEmptyString(heroImgFull, heroImg)
|
||||
|
||||
@ -2630,8 +2484,6 @@ export default function ModelDetails({
|
||||
const hasSpriteScrubber = hasScrubberUi && spriteCols > 0 && spriteRows > 0
|
||||
|
||||
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
||||
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
|
||||
const hasScrubber = hasScrubberUi
|
||||
|
||||
const activeScrubIndex = scrubIndexByKey[k]
|
||||
const scrubProgressRatio =
|
||||
|
||||
@ -442,8 +442,19 @@ export default function ModelsTab() {
|
||||
setLoading(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' })
|
||||
setModels(Array.isArray(list) ? list : [])
|
||||
const res = await fetch('/api/models', { cache: 'no-store' as any })
|
||||
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()
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? String(e))
|
||||
|
||||
@ -1744,8 +1744,7 @@ export default function Player({
|
||||
const videoChrome = (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-visible',
|
||||
expanded ? 'flex-1 min-h-0' : miniDesktop ? 'flex-1 min-h-0' : 'aspect-video'
|
||||
'relative overflow-visible flex-1 min-h-0'
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!miniDesktop || !canHover) return
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user