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,15 +783,18 @@ 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
|
||||
ELSE last_stream
|
||||
WHEN last_stream IS NULL AND $6::timestamptz IS NOT NULL THEN $6::timestamptz
|
||||
ELSE last_stream
|
||||
END,
|
||||
|
||||
watching = CASE WHEN $7=true THEN true ELSE watching END,
|
||||
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,9 +849,41 @@ 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 {
|
||||
return []StoredModel{}
|
||||
// ✅ 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()
|
||||
|
||||
@ -841,14 +892,17 @@ ORDER BY updated_at DESC;
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, input, host, path, modelKey, tags string
|
||||
|
||||
isURL bool
|
||||
isURL bool
|
||||
|
||||
lastStream sql.NullTime
|
||||
|
||||
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(
|
||||
&input, &isURL, &host, &path, &modelKey,
|
||||
&tags, &lastStream,
|
||||
&lastSeenOnline, &lastSeenOnlineAt,
|
||||
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
|
||||
&watching, &favorite, &hot, &keep, &liked,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
`
|
||||
|
||||
// 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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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