2742 lines
114 KiB
TypeScript
2742 lines
114 KiB
TypeScript
// frontend\src\components\ui\ModelDetails.tsx
|
||
|
||
'use client'
|
||
|
||
import * as React from 'react'
|
||
|
||
import type { RecordJob } from '../../types'
|
||
import Modal from './Modal'
|
||
import Button from './Button'
|
||
import TagBadge from './TagBadge'
|
||
import RecordJobActions from './RecordJobActions'
|
||
import Tabs, { type TabItem } from './Tabs'
|
||
import {
|
||
ArrowTopRightOnSquareIcon,
|
||
CalendarDaysIcon,
|
||
ArrowPathIcon,
|
||
HeartIcon as HeartOutlineIcon,
|
||
StarIcon as StarOutlineIcon,
|
||
IdentificationIcon,
|
||
LanguageIcon,
|
||
LinkIcon,
|
||
MapPinIcon,
|
||
PhotoIcon,
|
||
SparklesIcon,
|
||
UsersIcon,
|
||
ClockIcon,
|
||
EyeIcon as EyeOutlineIcon,
|
||
} from '@heroicons/react/24/outline'
|
||
import {
|
||
HeartIcon as HeartSolidIcon,
|
||
StarIcon as StarSolidIcon,
|
||
EyeIcon as EyeSolidIcon,
|
||
} from '@heroicons/react/24/solid'
|
||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||
import TagOverflowRow from './TagOverflowRow'
|
||
import PreviewScrubber from './PreviewScrubber'
|
||
import { formatResolution } from './formatters'
|
||
import Pagination from './Pagination'
|
||
import LiveVideo from './LiveVideo'
|
||
|
||
function cn(...parts: Array<string | false | null | undefined>) {
|
||
return parts.filter(Boolean).join(' ')
|
||
}
|
||
|
||
function isRunningJob(job: RecordJob): boolean {
|
||
const s = String((job as any)?.status ?? '').toLowerCase()
|
||
const ended = Boolean((job as any)?.endedAt ?? (job as any)?.completedAt)
|
||
return !ended && (s === 'running' || s === 'postwork')
|
||
}
|
||
|
||
const MD_CACHE_TTL_MS = 10 * 60 * 1000 // 10 min
|
||
|
||
type OnlineCacheEntry = {
|
||
at: number
|
||
room: ChaturbateRoom | null
|
||
meta: Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null
|
||
}
|
||
|
||
type BioCacheEntry = {
|
||
at: number
|
||
bio: BioContext | null
|
||
meta: Pick<BioResp, 'enabled' | 'fetchedAt' | 'lastError'> | null
|
||
}
|
||
|
||
// In-Memory (über Modal-Open/Close hinweg, solange Tab offen)
|
||
const mdOnlineMem = new Map<string, OnlineCacheEntry>()
|
||
const mdBioMem = new Map<string, BioCacheEntry>()
|
||
|
||
function isFresh(at: number) {
|
||
return Number.isFinite(at) && Date.now() - at <= MD_CACHE_TTL_MS
|
||
}
|
||
|
||
function ssGet<T>(key: string): T | null {
|
||
try {
|
||
if (typeof window === 'undefined') return null
|
||
const raw = window.sessionStorage.getItem(key)
|
||
if (!raw) return null
|
||
return JSON.parse(raw) as T
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function ssSet(key: string, value: any) {
|
||
try {
|
||
if (typeof window === 'undefined') return
|
||
window.sessionStorage.setItem(key, JSON.stringify(value))
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
function ssKeyOnline(modelKey: string) {
|
||
return `md:cb:online:${modelKey}`
|
||
}
|
||
function ssKeyBio(modelKey: string) {
|
||
return `md:cb:bio:${modelKey}`
|
||
}
|
||
|
||
const nf = new Intl.NumberFormat('de-DE')
|
||
|
||
function fmtInt(n: number | undefined | null) {
|
||
if (n == null || !Number.isFinite(n)) return '—'
|
||
return nf.format(n)
|
||
}
|
||
|
||
function fmtBytes(n: number | undefined | null) {
|
||
if (n == null || !Number.isFinite(n)) return '—'
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
let v = n
|
||
let i = 0
|
||
while (v >= 1024 && i < units.length - 1) {
|
||
v /= 1024
|
||
i++
|
||
}
|
||
const digits = i === 0 ? 0 : i === 1 ? 0 : 1
|
||
return `${v.toFixed(digits)} ${units[i]}`
|
||
}
|
||
|
||
function fmtHms(totalSeconds: number | undefined | null) {
|
||
if (totalSeconds == null || !Number.isFinite(totalSeconds) || totalSeconds < 0) return '—'
|
||
const s = Math.floor(totalSeconds)
|
||
const hh = Math.floor(s / 3600)
|
||
const mm = Math.floor((s % 3600) / 60)
|
||
const ss = s % 60
|
||
if (hh > 0) return `${hh}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`
|
||
return `${mm}:${String(ss).padStart(2, '0')}`
|
||
}
|
||
|
||
function fmtDateTime(v: string | Date | null | undefined) {
|
||
if (!v) return '—'
|
||
const d = typeof v === 'string' ? new Date(v) : v
|
||
if (Number.isNaN(d.getTime())) return String(v)
|
||
return d.toLocaleString('de-DE', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
function splitTags(v?: string | null) {
|
||
if (!v) return []
|
||
return v
|
||
.split(',')
|
||
.map((t) => t.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function baseName(path: string) {
|
||
return (path || '').split(/[\\/]/).pop() || ''
|
||
}
|
||
|
||
function shortDate(v: string | Date | null | undefined) {
|
||
if (!v) return '—'
|
||
const d = typeof v === 'string' ? new Date(v) : v
|
||
if (Number.isNaN(d.getTime())) return String(v)
|
||
// kompakter als fmtDateTime (ohne Sekunden)
|
||
return d.toLocaleDateString('de-DE', { year: '2-digit', month: '2-digit', day: '2-digit' })
|
||
}
|
||
|
||
function stripHotPrefix(name: string) {
|
||
return name.startsWith('HOT ') ? name.slice(4) : name
|
||
}
|
||
|
||
function isHotName(name: string) {
|
||
return String(name || '').startsWith('HOT ')
|
||
}
|
||
|
||
function replaceBaseName(path: string, newBase: string) {
|
||
const p = String(path || '')
|
||
if (!p) return p
|
||
// ersetzt nur das letzte Segment (funktioniert für / und \)
|
||
return p.replace(/([\\/])[^\\/]*$/, `$1${newBase}`)
|
||
}
|
||
|
||
function toggleHotFileName(file: string) {
|
||
return isHotName(file) ? stripHotPrefix(file) : `HOT ${file}`
|
||
}
|
||
|
||
function modelNameFromOutput(output?: string) {
|
||
const fileRaw = baseName(output || '')
|
||
const file = stripHotPrefix(fileRaw)
|
||
if (!file) return '—'
|
||
|
||
const stem = file.replace(/\.[^.]+$/, '')
|
||
|
||
// match: <model>_DD_MM_YYYY__HH-MM-SS
|
||
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
|
||
if (m?.[1]) return m[1]
|
||
|
||
const i = stem.lastIndexOf('_')
|
||
return i > 0 ? stem.slice(0, i) : stem
|
||
}
|
||
|
||
function stripHtmlToText(input?: string | null) {
|
||
if (!input) return ''
|
||
// simpel & sicher: tags weg (kein dangerouslySetInnerHTML)
|
||
return String(input)
|
||
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
|
||
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, '')
|
||
.replace(/<\/?[^>]+>/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
}
|
||
|
||
function errorSummary(input?: string | null) {
|
||
const s = String(input ?? '').trim()
|
||
if (!s) return ''
|
||
|
||
// bevorzugt: HTTP-Code rausziehen (kommt bei dir oft vor)
|
||
const m = s.match(/HTTP\s+(\d{3})/i)
|
||
if (m?.[1]) return `HTTP ${m[1]}`
|
||
|
||
// wenn HTML/doctype drin: nur "HTML response" anzeigen
|
||
const lower = s.toLowerCase()
|
||
if (lower.includes('<!doctype') || lower.includes('<html') || lower.includes('text/html')) {
|
||
return 'HTTP Error (HTML response)'
|
||
}
|
||
|
||
// fallback: erste Zeile / kurzer Ausschnitt
|
||
const oneLine = s.replace(/\s+/g, ' ')
|
||
return oneLine.length > 80 ? oneLine.slice(0, 77) + '…' : oneLine
|
||
}
|
||
|
||
function errorDetails(input?: string | null) {
|
||
const s = String(input ?? '').trim()
|
||
if (!s) return ''
|
||
// HTML zu Text strippen, damit es lesbar/kopierbar wird
|
||
const t = stripHtmlToText(s)
|
||
// hart begrenzen, damit es nicht eskaliert
|
||
return t.length > 2000 ? t.slice(0, 2000) + '…' : t
|
||
}
|
||
|
||
|
||
function absCbUrl(u?: string | null) {
|
||
if (!u) return ''
|
||
const s = String(u).trim()
|
||
if (!s) return ''
|
||
if (s.startsWith('http://') || s.startsWith('https://')) return s
|
||
if (s.startsWith('/')) return `https://chaturbate.com${s}`
|
||
return `https://chaturbate.com/${s}`
|
||
}
|
||
|
||
function pill(cls: string) {
|
||
return cn(
|
||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset',
|
||
cls
|
||
)
|
||
}
|
||
|
||
const previewBlurCls = (blur?: boolean) =>
|
||
blur ? 'blur-md scale-[1.03] brightness-90' : ''
|
||
|
||
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
||
for (const v of values) {
|
||
if (typeof v === 'string') {
|
||
const s = v.trim()
|
||
if (s) return s
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function parseJobMeta(metaRaw: unknown): any | null {
|
||
if (!metaRaw) return null
|
||
if (typeof metaRaw === 'string') {
|
||
try {
|
||
return JSON.parse(metaRaw)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
if (typeof metaRaw === 'object') return metaRaw
|
||
return null
|
||
}
|
||
|
||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||
// ms -> s Heuristik wie in FinishedVideoPreview
|
||
return value > 24 * 60 * 60 ? value / 1000 : value
|
||
}
|
||
|
||
function clamp(n: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(max, n))
|
||
}
|
||
|
||
const DEFAULT_SPRITE_STEP_SECONDS = 5
|
||
|
||
function chooseSpriteGrid(count: number): [number, number] {
|
||
if (count <= 1) return [1, 1]
|
||
|
||
const targetRatio = 16 / 9
|
||
let bestCols = 1
|
||
let bestRows = count
|
||
let bestWaste = Number.POSITIVE_INFINITY
|
||
let bestRatioScore = Number.POSITIVE_INFINITY
|
||
|
||
for (let c = 1; c <= count; c++) {
|
||
const r = Math.max(1, Math.ceil(count / c))
|
||
const waste = c * r - count
|
||
const ratio = c / r
|
||
const ratioScore = Math.abs(ratio - targetRatio)
|
||
|
||
if (
|
||
waste < bestWaste ||
|
||
(waste === bestWaste && ratioScore < bestRatioScore) ||
|
||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
|
||
) {
|
||
bestWaste = waste
|
||
bestRatioScore = ratioScore
|
||
bestCols = c
|
||
bestRows = r
|
||
}
|
||
}
|
||
|
||
return [bestCols, bestRows]
|
||
}
|
||
|
||
// ------ API types (Chaturbate online) ------
|
||
|
||
type ChaturbateRoom = {
|
||
gender?: string
|
||
location?: string
|
||
country?: string
|
||
current_show?: string
|
||
username?: string
|
||
room_subject?: string
|
||
tags?: string[]
|
||
is_new?: boolean
|
||
num_users?: number
|
||
num_followers?: number
|
||
spoken_languages?: string
|
||
display_name?: string
|
||
birthday?: string
|
||
is_hd?: boolean
|
||
age?: number
|
||
seconds_online?: number
|
||
image_url?: string
|
||
image_url_360x270?: string
|
||
chat_room_url?: string
|
||
chat_room_url_revshare?: string
|
||
iframe_embed?: string
|
||
iframe_embed_revshare?: string
|
||
slug?: string
|
||
}
|
||
|
||
type OnlineResp = {
|
||
enabled?: boolean
|
||
fetchedAt?: string
|
||
count?: number
|
||
lastError?: string
|
||
rooms?: ChaturbateRoom[]
|
||
}
|
||
|
||
// ------ API types (Chaturbate biocontext proxy) ------
|
||
|
||
type BioPhotoSet = {
|
||
id: number
|
||
name: string
|
||
cover_url?: string
|
||
tokens?: number
|
||
is_video?: boolean
|
||
user_can_access?: boolean
|
||
user_has_purchased?: boolean
|
||
fan_club_only?: boolean
|
||
label_text?: string
|
||
label_color?: string
|
||
video_has_sound?: boolean
|
||
video_ready?: boolean
|
||
}
|
||
|
||
type BioSocial = {
|
||
id: number
|
||
title_name: string
|
||
image_url?: string
|
||
link?: string
|
||
popup_link?: boolean
|
||
tokens?: number
|
||
purchased?: boolean
|
||
label_text?: string
|
||
label_color?: string
|
||
}
|
||
|
||
type BioContext = {
|
||
follower_count?: number
|
||
location?: string
|
||
real_name?: string
|
||
body_decorations?: string
|
||
last_broadcast?: string
|
||
smoke_drink?: string
|
||
body_type?: string
|
||
display_birthday?: string
|
||
about_me?: string
|
||
wish_list?: string
|
||
time_since_last_broadcast?: string
|
||
fan_club_cost?: number
|
||
performer_has_fanclub?: boolean
|
||
fan_club_is_member?: boolean
|
||
fan_club_join_url?: string
|
||
needs_supporter_to_pm?: boolean
|
||
interested_in?: string[]
|
||
display_age?: number
|
||
sex?: string
|
||
subgender?: string
|
||
room_status?: string // offline/online
|
||
photo_sets?: BioPhotoSet[]
|
||
social_medias?: BioSocial[]
|
||
is_broadcaster_or_staff?: boolean
|
||
}
|
||
|
||
type BioResp = {
|
||
enabled?: boolean
|
||
fetchedAt?: string
|
||
lastError?: string
|
||
model?: string
|
||
bio?: BioContext | null
|
||
}
|
||
|
||
// ------ props ------
|
||
|
||
// ------ API types (local model store) ------
|
||
// /api/models liefert StoredModel aus dem models_store
|
||
type StoredModel = {
|
||
id: string
|
||
modelKey: string
|
||
tags?: string | null
|
||
lastSeenOnline?: boolean | null
|
||
lastSeenOnlineAt?: string
|
||
favorite?: boolean
|
||
watching?: boolean
|
||
liked?: boolean | null
|
||
hot?: boolean
|
||
keep?: boolean
|
||
createdAt?: string
|
||
updatedAt?: string
|
||
cbOnlineJson?: string | null
|
||
cbOnlineFetchedAt?: string | null
|
||
cbOnlineLastError?: string | null
|
||
}
|
||
|
||
type Props = {
|
||
open: boolean
|
||
modelKey: string | null
|
||
onClose: () => void
|
||
onOpenPlayer?: (job: RecordJob, startAtSec?: number) => void
|
||
cookies?: Record<string, string>
|
||
runningJobs?: RecordJob[]
|
||
blurPreviews?: boolean
|
||
teaserPlayback?: 'still' | 'hover' | 'all'
|
||
teaserAudio?: boolean
|
||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||
onToggleHot?: (
|
||
job: RecordJob
|
||
) =>
|
||
| void
|
||
| { ok?: boolean; oldFile?: string; newFile?: string }
|
||
| Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
|
||
onDelete?: (job: RecordJob) => void | Promise<void>
|
||
onKeep?: (job: RecordJob) => void | Promise<void>
|
||
onStopJob?: (id: string) => void | Promise<void>
|
||
}
|
||
|
||
function normalizeModelKey(raw: string | null | undefined): string {
|
||
let s = String(raw ?? '').trim()
|
||
if (!s) return ''
|
||
|
||
// falls jemand eine URL reinreicht
|
||
s = s.replace(/^https?:\/\//i, '')
|
||
|
||
// falls host/path drin ist -> letzten Segment nehmen
|
||
if (s.includes('/')) {
|
||
const parts = s.split('/').filter(Boolean)
|
||
s = parts[parts.length - 1] || s
|
||
}
|
||
|
||
// falls host:model drin ist -> nach ":" nehmen
|
||
if (s.includes(':')) {
|
||
s = s.split(':').pop() || s
|
||
}
|
||
|
||
return s.trim().toLowerCase()
|
||
}
|
||
|
||
function buildChaturbateCookieHeader(cookies?: Record<string, string>): string {
|
||
const c = cookies ?? {}
|
||
|
||
const cf =
|
||
c['cf_clearance'] ||
|
||
c['cf-clearance'] ||
|
||
c['cfclearance'] ||
|
||
''
|
||
|
||
const sess =
|
||
// ✅ wichtig: deine App nutzt "sessionId"
|
||
c['sessionId'] ||
|
||
c['sessionid'] ||
|
||
c['session_id'] ||
|
||
c['session-id'] ||
|
||
''
|
||
|
||
const parts: string[] = []
|
||
if (cf) parts.push(`cf_clearance=${cf}`)
|
||
if (sess) parts.push(`sessionid=${sess}`) // upstream erwartet sessionid=...
|
||
return parts.join('; ')
|
||
}
|
||
|
||
|
||
export default function ModelDetails({
|
||
open,
|
||
modelKey,
|
||
onClose,
|
||
onOpenPlayer,
|
||
cookies,
|
||
runningJobs,
|
||
blurPreviews,
|
||
teaserPlayback = 'hover',
|
||
teaserAudio = false,
|
||
onToggleWatch,
|
||
onToggleFavorite,
|
||
onToggleLike,
|
||
onToggleHot,
|
||
onDelete,
|
||
onKeep,
|
||
onStopJob
|
||
}: Props) {
|
||
|
||
//const isDesktop = useMediaQuery('(min-width: 640px)')
|
||
|
||
const [models, setModels] = React.useState<StoredModel[]>([])
|
||
|
||
const [room, setRoom] = React.useState<ChaturbateRoom | null>(null)
|
||
const [roomMeta, setRoomMeta] = React.useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
|
||
|
||
const [bio, setBio] = React.useState<BioContext | null>(null)
|
||
const [bioMeta, setBioMeta] = React.useState<Pick<BioResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
|
||
const [bioLoading, setBioLoading] = React.useState(false)
|
||
|
||
const [done, setDone] = React.useState<RecordJob[]>([])
|
||
const [doneLoading, setDoneLoading] = React.useState(false)
|
||
|
||
const [running, setRunning] = React.useState<RecordJob[]>([])
|
||
const [runningLoading, setRunningLoading] = React.useState(false)
|
||
|
||
const runningReqSeqRef = React.useRef(0)
|
||
|
||
const [, setBioRefreshSeq] = React.useState(0)
|
||
|
||
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
|
||
|
||
const [, setRunningHover] = React.useState(false)
|
||
|
||
const [stopPending, setStopPending] = React.useState(false)
|
||
|
||
const [doneTotalCount, setDoneTotalCount] = React.useState(0)
|
||
|
||
const [donePage, setDonePage] = React.useState(1)
|
||
const DONE_PAGE_SIZE = 4
|
||
|
||
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)
|
||
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
||
|
||
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
|
||
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
|
||
|
||
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
|
||
|
||
const lower = React.useCallback((s: string) => String(s ?? '').toLowerCase(), [])
|
||
|
||
const deletingKeys = React.useMemo(() => new Set<string>(), [])
|
||
const keepingKeys = React.useMemo(() => new Set<string>(), [])
|
||
const removingKeys = React.useMemo(() => new Set<string>(), [])
|
||
const deletedKeys = React.useMemo(() => new Set<string>(), [])
|
||
|
||
// ✅ 1) Beim Öffnen sofort aus Cache rendern
|
||
React.useEffect(() => {
|
||
if (!open || !key) return
|
||
|
||
// Online
|
||
const memOnline = mdOnlineMem.get(key)
|
||
const ssOnline = ssGet<OnlineCacheEntry>(ssKeyOnline(key))
|
||
|
||
const onlineHit =
|
||
(memOnline && isFresh(memOnline.at) ? memOnline : null) ||
|
||
(ssOnline && isFresh(ssOnline.at) ? ssOnline : null)
|
||
|
||
if (onlineHit) {
|
||
setRoom(onlineHit.room ?? null)
|
||
setRoomMeta(onlineHit.meta ?? null)
|
||
}
|
||
|
||
// Bio
|
||
const memBio = mdBioMem.get(key)
|
||
const ssBio = ssGet<BioCacheEntry>(ssKeyBio(key))
|
||
|
||
const bioHit =
|
||
(memBio && isFresh(memBio.at) ? memBio : null) ||
|
||
(ssBio && isFresh(ssBio.at) ? ssBio : null)
|
||
|
||
if (bioHit) {
|
||
setBio(bioHit.bio ?? null)
|
||
setBioMeta(bioHit.meta ?? null)
|
||
// bioLoading NICHT anfassen – Fetch kann trotzdem laufen
|
||
}
|
||
}, [open, key])
|
||
|
||
React.useEffect(() => {
|
||
if (!open) return
|
||
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' })
|
||
const data = (await r.json().catch(() => null)) as any
|
||
setModels(Array.isArray(data) ? data : [])
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [])
|
||
|
||
const refetchDone = React.useCallback(async () => {
|
||
if (!key) return
|
||
setDoneLoading(true)
|
||
try {
|
||
const url =
|
||
`/api/record/done?model=${encodeURIComponent(key)}` +
|
||
`&page=${donePage}&pageSize=${DONE_PAGE_SIZE}` +
|
||
`&sort=completed_desc&includeKeep=1&withCount=1`
|
||
|
||
const r = await fetch(url, { cache: 'no-store' })
|
||
const data = await r.json().catch(() => null)
|
||
|
||
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
|
||
const count = Number(data?.count ?? items.length)
|
||
|
||
setDone(items)
|
||
setDoneTotalCount(Number.isFinite(count) ? count : items.length)
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setDoneLoading(false)
|
||
}
|
||
}, [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
|
||
return {
|
||
id: `model:${key}`,
|
||
output: `${key}_01_01_2000__00-00-00.mp4`,
|
||
status: 'finished',
|
||
} as any
|
||
}
|
||
|
||
const openImage = React.useCallback((src?: string | null, alt?: string) => {
|
||
const s = String(src ?? '').trim()
|
||
if (!s) return
|
||
setImgViewer({ src: s, alt })
|
||
}, [])
|
||
|
||
// wenn Modal zu geht, wieder "normal" (kein force refresh)
|
||
React.useEffect(() => {
|
||
if (!open) setBioRefreshSeq(0)
|
||
}, [open])
|
||
|
||
const runningList = React.useMemo(() => {
|
||
return Array.isArray(runningJobs) ? runningJobs : running
|
||
}, [runningJobs, running])
|
||
|
||
React.useEffect(() => {
|
||
if (!open) return
|
||
setDonePage(1)
|
||
}, [open, modelKey])
|
||
|
||
React.useEffect(() => {
|
||
if (!open) return
|
||
void refetchModels()
|
||
}, [open, refetchModels])
|
||
|
||
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
|
||
React.useEffect(() => {
|
||
if (!open || !key) return
|
||
void refetchDone()
|
||
}, [open, key, refetchDone])
|
||
|
||
// Running jobs
|
||
React.useEffect(() => {
|
||
if (!open) return
|
||
if (Array.isArray(runningJobs)) return
|
||
|
||
const ac = new AbortController()
|
||
const seq = ++runningReqSeqRef.current
|
||
|
||
setRunningLoading(true)
|
||
|
||
fetch('/api/record/jobs', { cache: 'no-store', signal: ac.signal })
|
||
.then((r) => r.json())
|
||
.then((data: RecordJob[]) => {
|
||
if (ac.signal.aborted) return
|
||
if (runningReqSeqRef.current !== seq) return
|
||
setRunning(Array.isArray(data) ? data : [])
|
||
})
|
||
.catch(() => {
|
||
if (ac.signal.aborted) return
|
||
if (runningReqSeqRef.current !== seq) return
|
||
setRunning([])
|
||
})
|
||
.finally(() => {
|
||
if (ac.signal.aborted) return
|
||
if (runningReqSeqRef.current !== seq) return
|
||
setRunningLoading(false)
|
||
})
|
||
|
||
return () => {
|
||
ac.abort()
|
||
}
|
||
}, [open, runningJobs])
|
||
|
||
const model = React.useMemo(() => {
|
||
if (!key) return null
|
||
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
|
||
}, [models, key])
|
||
|
||
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 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 []
|
||
return runningList.filter((j) => {
|
||
const m = modelNameFromOutput(j.output)
|
||
return m !== '—' && m.trim().toLowerCase() === key
|
||
})
|
||
}, [runningList, key])
|
||
|
||
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 showLabel = (effectiveRoom?.current_show || '').trim().toLowerCase()
|
||
const showPill = showLabel
|
||
? showLabel === 'public'
|
||
? 'Public'
|
||
: showLabel === 'private'
|
||
? 'Private'
|
||
: showLabel
|
||
: ''
|
||
|
||
const bioLocation = (bio?.location || '').trim()
|
||
const bioFollowers = bio?.follower_count
|
||
const bioAge = bio?.display_age
|
||
const bioStatus = (bio?.room_status || '').trim()
|
||
const bioLast = bio?.last_broadcast ? fmtDateTime(bio.last_broadcast) : '—'
|
||
const about = stripHtmlToText(bio?.about_me)
|
||
const wish = stripHtmlToText(bio?.wish_list)
|
||
|
||
const storedPresenceLabel =
|
||
model?.lastSeenOnline == null ? '' : model.lastSeenOnline ? 'online' : 'offline'
|
||
|
||
const effectivePresenceLabel = (bioStatus || showLabel || storedPresenceLabel || '').trim()
|
||
|
||
const socials = Array.isArray(bio?.social_medias) ? bio!.social_medias! : []
|
||
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,
|
||
value,
|
||
}: {
|
||
icon: React.ReactNode
|
||
label: string
|
||
value: React.ReactNode
|
||
}) => (
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/60 px-2.5 py-2 sm:px-3 dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-center gap-1.5 text-[11px] sm:text-xs text-gray-600 dark:text-gray-300">
|
||
{icon}
|
||
{label}
|
||
</div>
|
||
<div className="mt-0.5 text-sm font-semibold text-gray-900 dark:text-white">{value}</div>
|
||
</div>
|
||
)
|
||
|
||
const keyFor = React.useCallback((j: RecordJob) => {
|
||
// stabil, auch wenn id fehlt
|
||
const id = String((j as any)?.id ?? '')
|
||
const out = String(j.output ?? '')
|
||
return id ? `${id}::${out}` : out
|
||
}, [])
|
||
|
||
const handleToggleHot = React.useCallback(
|
||
async (job: RecordJob) => {
|
||
const out = job.output || ''
|
||
const oldFile = baseName(out)
|
||
if (!oldFile) {
|
||
await onToggleHot?.(job)
|
||
return
|
||
}
|
||
|
||
const newFile = toggleHotFileName(oldFile)
|
||
|
||
// ✅ 1) UI sofort updaten (optimistisch)
|
||
setDone((prev) =>
|
||
prev.map((j) => {
|
||
const same =
|
||
(j.id && job.id && j.id === job.id) ||
|
||
(j.output && job.output && j.output === job.output)
|
||
|
||
if (!same) return j
|
||
return { ...j, output: replaceBaseName(j.output || '', newFile) }
|
||
})
|
||
)
|
||
|
||
// ✅ 2) Backend/App Handler
|
||
await onToggleHot?.(job)
|
||
|
||
// ✅ 3) Server-Truth nachziehen (falls Backend anders renamed)
|
||
refetchDone()
|
||
},
|
||
[onToggleHot, refetchDone]
|
||
)
|
||
|
||
const handleScrubberClickIndex = React.useCallback(
|
||
(job: RecordJob, segmentIndex: number, _segmentCount: number) => {
|
||
const metaRaw = (job as any)?.meta
|
||
const meta = typeof metaRaw === 'string' ? (() => { try { return JSON.parse(metaRaw) } catch { return null } })() : metaRaw
|
||
|
||
const step =
|
||
typeof meta?.previewSprite?.stepSeconds === 'number' && Number.isFinite(meta.previewSprite.stepSeconds) && meta.previewSprite.stepSeconds > 0
|
||
? meta.previewSprite.stepSeconds
|
||
: 5
|
||
|
||
const startAtSec = Math.max(0, Math.floor(segmentIndex) * step)
|
||
onOpenPlayer?.(job, startAtSec)
|
||
},
|
||
[onOpenPlayer]
|
||
)
|
||
|
||
const handleToggleFavoriteModel = React.useCallback(async () => {
|
||
if (!key) return
|
||
|
||
// ✅ UI sofort (optimistisch)
|
||
setModels((prev) =>
|
||
prev.map((m) =>
|
||
(m.modelKey || '').toLowerCase() === key
|
||
? { ...m, favorite: !Boolean(m.favorite) }
|
||
: m
|
||
)
|
||
)
|
||
|
||
// ✅ Backend/App Handler
|
||
await onToggleFavorite?.(jobFromModelKey(key))
|
||
|
||
// ✅ Server-Truth nachziehen
|
||
refetchModels()
|
||
}, [key, onToggleFavorite, refetchModels])
|
||
|
||
const handleToggleLikeModel = React.useCallback(async () => {
|
||
if (!key) return
|
||
|
||
// liked ist bei dir boolean | null -> wir togglen "true <-> false"
|
||
setModels((prev) =>
|
||
prev.map((m) =>
|
||
(m.modelKey || '').toLowerCase() === key
|
||
? { ...m, liked: m.liked === true ? false : true }
|
||
: m
|
||
)
|
||
)
|
||
|
||
await onToggleLike?.(jobFromModelKey(key))
|
||
refetchModels()
|
||
}, [key, onToggleLike, refetchModels])
|
||
|
||
const handleToggleWatchModel = React.useCallback(async () => {
|
||
if (!key) return
|
||
|
||
// ✅ UI sofort (optimistisch)
|
||
setModels((prev) =>
|
||
prev.map((m) =>
|
||
(m.modelKey || '').toLowerCase() === key
|
||
? { ...m, watching: !Boolean(m.watching) }
|
||
: m
|
||
)
|
||
)
|
||
|
||
// ✅ Backend/App Handler
|
||
await onToggleWatch?.(jobFromModelKey(key))
|
||
|
||
// ✅ Server-Truth nachziehen
|
||
refetchModels()
|
||
}, [key, onToggleWatch, refetchModels])
|
||
|
||
React.useEffect(() => {
|
||
if (!open) {
|
||
setHoverTeaserKey(null)
|
||
setTeaserKey(null)
|
||
setHoveredModelPreviewKey(null)
|
||
setHoveredThumbKey(null)
|
||
setScrubIndexByKey({})
|
||
setDurations({})
|
||
}
|
||
}, [open, key])
|
||
|
||
const handleDuration = React.useCallback(
|
||
(job: RecordJob, seconds: number) => {
|
||
const k = keyFor(job)
|
||
setDurations((prev) => (prev[k] === seconds ? prev : { ...prev, [k]: seconds }))
|
||
},
|
||
[keyFor]
|
||
)
|
||
|
||
const handleHoverPreviewKeyChange = React.useCallback(
|
||
(k: string | null) => {
|
||
setHoverTeaserKey(k)
|
||
if (teaserPlayback === 'hover') setTeaserKey(k)
|
||
},
|
||
[teaserPlayback]
|
||
)
|
||
|
||
const runtimeOf = React.useCallback(
|
||
(job: RecordJob) => {
|
||
const k = keyFor(job)
|
||
const raw =
|
||
(job as any)?.durationSeconds ??
|
||
(job as any)?.meta?.durationSeconds ??
|
||
durations[k]
|
||
|
||
const n =
|
||
typeof raw === 'number' && Number.isFinite(raw)
|
||
? raw > 24 * 60 * 60
|
||
? raw / 1000
|
||
: raw
|
||
: null
|
||
|
||
return fmtHms(n)
|
||
},
|
||
[durations, keyFor]
|
||
)
|
||
|
||
const sizeBytesOf = React.useCallback((job: RecordJob) => {
|
||
const v =
|
||
(job as any)?.sizeBytes ??
|
||
(job as any)?.size ??
|
||
(job as any)?.meta?.sizeBytes ??
|
||
(job as any)?.meta?.size
|
||
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
||
}, [])
|
||
|
||
const formatBytes = React.useCallback((bytes?: number | null) => fmtBytes(bytes ?? null), [])
|
||
|
||
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
|
||
setScrubIndexByKey((prev) => {
|
||
if (index === undefined) {
|
||
if (!(key in prev)) return prev
|
||
const next = { ...prev }
|
||
delete next[key]
|
||
return next
|
||
}
|
||
if (prev[key] === index) return prev
|
||
return { ...prev, [key]: index }
|
||
})
|
||
}, [])
|
||
|
||
const clearScrubIndex = React.useCallback(
|
||
(key: string) => setScrubIndexForKey(key, undefined),
|
||
[setScrubIndexForKey]
|
||
)
|
||
|
||
React.useEffect(() => {
|
||
if (!open) return
|
||
setTab('info')
|
||
}, [open, key])
|
||
|
||
const tabs: TabItem[] = [
|
||
{ id: 'info', label: 'Info' },
|
||
{ id: 'downloads', label: 'Downloads', count: doneTotalCount ? fmtInt(doneTotalCount) : undefined, disabled: doneLoading },
|
||
{ id: 'running', label: 'Running', count: runningMatches.length ? fmtInt(runningMatches.length) : undefined, disabled: runningLoading },
|
||
]
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={onClose}
|
||
title={titleName}
|
||
width="max-w-6xl"
|
||
layout="split"
|
||
scroll="right"
|
||
leftWidthClass="lg:w-[320px]"
|
||
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">
|
||
|
||
{/* ===================== */}
|
||
{/* DESKTOP (sm:block) */}
|
||
{/* ===================== */}
|
||
<div className="hidden sm:block space-y-2">
|
||
<div className="overflow-hidden rounded-lg border border-gray-200/70 bg-white/70 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||
{/* Image */}
|
||
<div className="relative">
|
||
{heroImg ? (
|
||
<button
|
||
type="button"
|
||
className="block w-full cursor-zoom-in"
|
||
onClick={() => openImage(heroImgFull, titleName)}
|
||
aria-label="Bild vergrößern"
|
||
>
|
||
<img
|
||
src={heroImg}
|
||
alt={titleName}
|
||
className={cn('h-24 sm:h-52 w-full object-cover transition', previewBlurCls(blurPreviews))}
|
||
/>
|
||
</button>
|
||
) : (
|
||
<div className="h-24 sm:h-52 w-full bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" />
|
||
)}
|
||
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/40 via-black/0 to-black/0"
|
||
/>
|
||
|
||
{/* Pills */}
|
||
<div className="absolute left-3 top-3 flex flex-wrap items-center gap-2">
|
||
{showPill ? (
|
||
<span
|
||
className={pill(
|
||
'bg-emerald-500/10 text-emerald-900 ring-emerald-200 backdrop-blur dark:text-emerald-200 dark:ring-emerald-400/20'
|
||
)}
|
||
>
|
||
{showPill}
|
||
</span>
|
||
) : null}
|
||
|
||
{effectivePresenceLabel ? (
|
||
<span
|
||
className={pill(
|
||
effectivePresenceLabel.toLowerCase() === 'online'
|
||
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 backdrop-blur dark:text-emerald-200 dark:ring-emerald-400/20'
|
||
: 'bg-gray-500/10 text-gray-900 ring-gray-200 backdrop-blur dark:text-gray-200 dark:ring-white/15'
|
||
)}
|
||
>
|
||
{effectivePresenceLabel}
|
||
</span>
|
||
) : null}
|
||
|
||
{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'
|
||
)}
|
||
>
|
||
HD
|
||
</span>
|
||
) : null}
|
||
|
||
{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'
|
||
)}
|
||
>
|
||
NEW
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<div className="absolute bottom-3 left-3 right-3">
|
||
<div className="truncate text-sm font-semibold text-white drop-shadow">
|
||
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
|
||
</div>
|
||
<div className="truncate text-xs text-white/85 drop-shadow">
|
||
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Local flags icons */}
|
||
<div className="absolute bottom-3 right-3 flex items-center gap-2">
|
||
{/* Watched */}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleToggleWatchModel()
|
||
}}
|
||
className={cn(
|
||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
|
||
model?.watching ? 'bg-sky-500/25 ring-sky-200/30' : 'bg-black/20 ring-white/15'
|
||
)}
|
||
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-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-200'
|
||
)}
|
||
/>
|
||
</span>
|
||
</button>
|
||
|
||
{/* Favorite */}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleToggleFavoriteModel()
|
||
}}
|
||
className={cn(
|
||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
|
||
model?.favorite ? 'bg-amber-500/25 ring-amber-200/30' : 'bg-black/20 ring-white/15'
|
||
)}
|
||
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-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-200'
|
||
)}
|
||
/>
|
||
</span>
|
||
</button>
|
||
|
||
{/* Like */}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleToggleLikeModel()
|
||
}}
|
||
className={cn(
|
||
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
|
||
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
|
||
model?.liked ? 'bg-rose-500/25 ring-rose-200/30' : 'bg-black/20 ring-white/15'
|
||
)}
|
||
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-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-200'
|
||
)}
|
||
/>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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(effectiveRoom?.num_users)} />
|
||
<Stat
|
||
icon={<SparklesIcon className="size-4" />}
|
||
label="Follower"
|
||
value={fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}
|
||
/>
|
||
</div>
|
||
|
||
<dl className="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||
<div className="rounded-lg border border-gray-200/60 bg-white/50 px-2.5 py-2 dark:border-white/10 dark:bg-white/5">
|
||
<dt className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||
<MapPinIcon className="size-4" />
|
||
Location
|
||
</dt>
|
||
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
||
{effectiveRoom?.location || bioLocation || '—'}
|
||
</dd>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-gray-200/60 bg-white/50 px-2.5 py-2 dark:border-white/10 dark:bg-white/5">
|
||
<dt className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||
<LanguageIcon className="size-4" />
|
||
Sprache
|
||
</dt>
|
||
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
||
{effectiveRoom?.spoken_languages || '—'}
|
||
</dd>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-gray-200/60 bg-white/50 px-2.5 py-2 dark:border-white/10 dark:bg-white/5">
|
||
<dt className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||
<CalendarDaysIcon className="size-4" />
|
||
Online
|
||
</dt>
|
||
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
||
{fmtHms(effectiveRoom?.seconds_online)}
|
||
</dd>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-gray-200/60 bg-white/50 px-2.5 py-2 dark:border-white/10 dark:bg-white/5">
|
||
<dt className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||
<HeartOutlineIcon className="size-4" />
|
||
Alter
|
||
</dt>
|
||
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
||
{bioAge != null ? String(bioAge) : effectiveRoom?.age != null ? String(effectiveRoom.age) : '—'}
|
||
</dd>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-gray-200/60 bg-white/50 px-2.5 py-2 dark:border-white/10 dark:bg-white/5">
|
||
<dt className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||
<CalendarDaysIcon className="size-4" />
|
||
Last broadcast
|
||
</dt>
|
||
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
|
||
{bioLast}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
{/* Meta warnings */}
|
||
{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>
|
||
) : 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(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(effectiveRoomMeta.lastError)}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
) : null}
|
||
|
||
{bioMeta?.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">
|
||
BioContext ist aktuell deaktiviert.
|
||
</div>
|
||
) : bioMeta?.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">
|
||
BioContext: {bioMeta.lastError}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tags (desktop) */}
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Tags</div>
|
||
{allTags.length ? (
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{allTags.map((t) => (
|
||
<TagBadge key={t} tag={t} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">Keine Tags vorhanden.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
rightHeader={
|
||
<div
|
||
className={cn(
|
||
'border-b border-gray-200/70 dark:border-white/10',
|
||
'bg-white/95 backdrop-blur dark:bg-gray-800/95'
|
||
)}
|
||
>
|
||
{/* ===================== */}
|
||
{/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */}
|
||
{/* ===================== */}
|
||
<div className="sm:hidden px-2 pb-2 space-y-1.5">
|
||
{/* 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="absolute inset-0 block w-full"
|
||
onClick={() => (heroImgFull ? openImage(heroImgFull, titleName) : undefined)}
|
||
aria-label="Bild vergrößern"
|
||
>
|
||
<img
|
||
src={heroImgFull || heroImg}
|
||
alt={titleName}
|
||
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" />
|
||
)}
|
||
|
||
{/* 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-base font-semibold text-white drop-shadow">
|
||
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
|
||
</div>
|
||
<div className="truncate text-xs text-white/85 drop-shadow">
|
||
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 flex items-center gap-1.5">
|
||
{/* Watched */}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleToggleWatchModel()
|
||
}}
|
||
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/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', 'text-white/70')} />
|
||
<EyeSolidIcon className={cn('absolute inset-0 size-4', model?.watching ? 'opacity-100' : 'opacity-0', 'text-sky-200')} />
|
||
</span>
|
||
</button>
|
||
|
||
{/* Favorite */}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleToggleFavoriteModel()
|
||
}}
|
||
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/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', 'text-white/70')} />
|
||
<StarSolidIcon className={cn('absolute inset-0 size-4', model?.favorite ? 'opacity-100' : 'opacity-0', 'text-amber-200')} />
|
||
</span>
|
||
</button>
|
||
|
||
{/* Like */}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleToggleLikeModel()
|
||
}}
|
||
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/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', '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 bottom-left */}
|
||
<div className="absolute left-3 bottom-3 flex flex-wrap items-center gap-1.5">
|
||
{showPill ? (
|
||
<span className={pill('bg-white/15 text-white ring-white/20')}>
|
||
{showPill}
|
||
</span>
|
||
) : null}
|
||
|
||
{effectivePresenceLabel ? (
|
||
<span
|
||
className={pill(
|
||
(effectivePresenceLabel || '').toLowerCase() === 'online'
|
||
? 'bg-emerald-500/25 text-white ring-emerald-200/30'
|
||
: 'bg-white/15 text-white ring-white/20'
|
||
)}
|
||
>
|
||
{effectivePresenceLabel}
|
||
</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>
|
||
|
||
{/* Room link bottom-right */}
|
||
{roomUrl ? (
|
||
<div className="absolute right-3 bottom-3">
|
||
<a
|
||
href={roomUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
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" />
|
||
<span>Room</span>
|
||
</a>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* 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(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(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(effectiveRoom?.seconds_online)}</span>
|
||
</span>
|
||
<span className="inline-flex items-center gap-1">
|
||
<CalendarDaysIcon className="size-3.5 text-gray-400" />
|
||
<span className="font-semibold text-gray-900 dark:text-white">
|
||
{bio?.last_broadcast ? shortDate(bio.last_broadcast) : '—'}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
|
||
{/* Meta warnings (mobile) */}
|
||
{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>
|
||
) : 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: {effectiveRoomMeta.lastError}
|
||
</div>
|
||
) : null}
|
||
|
||
{bioMeta?.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">
|
||
BioContext ist aktuell deaktiviert.
|
||
</div>
|
||
) : bioMeta?.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="flex items-start justify-between gap-2">
|
||
<div className="font-medium">BioContext: {errorSummary(bioMeta.lastError)}</div>
|
||
</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(bioMeta.lastError)}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tags (mobile, compact row) */}
|
||
{allTags.length ? (
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-2 py-1.5 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-center gap-2">
|
||
<div className="shrink-0 text-xs font-semibold text-gray-900 dark:text-white">
|
||
Tags <span className="text-gray-500 dark:text-gray-400">({allTags.length})</span>
|
||
</div>
|
||
|
||
<div className="-mx-1 flex-1 overflow-x-auto px-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||
<div className="flex min-w-max items-center gap-1.5">
|
||
{allTags.slice(0, 20).map((t) => (
|
||
<TagBadge key={t} tag={t} />
|
||
))}
|
||
{allTags.length > 20 ? (
|
||
<span className={pill('bg-gray-500/10 text-gray-900 ring-gray-200 dark:text-gray-200 dark:ring-white/15')}>
|
||
+{allTags.length - 20}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* ===================== */}
|
||
{/* (dein bisheriger Header) Row 1: Meta + Actions */}
|
||
{/* ===================== */}
|
||
<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">
|
||
{effectiveRoomMeta?.fetchedAt ? (
|
||
<span className="text-gray-500 dark:text-gray-400">
|
||
Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)}
|
||
</span>
|
||
) : null}
|
||
{bioMeta?.fetchedAt ? (
|
||
<span className="text-gray-500 dark:text-gray-400">
|
||
· Bio-Stand: {fmtDateTime(bioMeta.fetchedAt)}
|
||
</span>
|
||
) : null}
|
||
{model?.lastSeenOnlineAt ? (
|
||
<span className="text-gray-500 dark:text-gray-400">
|
||
· Zuletzt gesehen:{' '}
|
||
<span className="font-medium">
|
||
{model.lastSeenOnline == null
|
||
? '—'
|
||
: model.lastSeenOnline
|
||
? 'online'
|
||
: 'offline'}
|
||
</span>{' '}
|
||
({fmtDateTime(model.lastSeenOnlineAt)})
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
'—'
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{roomUrl ? (
|
||
<a
|
||
href={roomUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className={cn(
|
||
'inline-flex h-8 items-center justify-center gap-1 rounded-lg',
|
||
'border border-gray-200/70 bg-white/70 px-3 text-sm font-medium text-gray-900 shadow-sm backdrop-blur hover:bg-white',
|
||
'dark:border-white/10 dark:bg-white/5 dark:text-white',
|
||
'whitespace-nowrap'
|
||
)}
|
||
title="Room öffnen"
|
||
>
|
||
<ArrowTopRightOnSquareIcon className="size-4" />
|
||
<span className="hidden sm:inline">Room öffnen</span>
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: Tabs */}
|
||
<div className="px-2 pb-2">
|
||
<div className="-mx-1 overflow-x-auto px-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||
<div className="min-w-max">
|
||
<Tabs
|
||
tabs={tabs}
|
||
value={tab}
|
||
onChange={(id) => setTab(id as TabKey)}
|
||
variant="pillsBrand"
|
||
ariaLabel="Bereich auswählen"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
footer={
|
||
<div className="w-full">
|
||
<Button variant="secondary" className="w-full sm:w-auto" onClick={onClose}>
|
||
Schließen
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
{/* RIGHT content (Tabs) */}
|
||
<div className="min-h-0">
|
||
{/* INFO */}
|
||
{tab === 'info' ? (
|
||
<div className="space-y-2">
|
||
{/* Subject */}
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-3 sm:p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<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">
|
||
{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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bio */}
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Bio</div>
|
||
{bioLoading ? <span className="text-xs text-gray-500 dark:text-gray-400">Lade…</span> : null}
|
||
</div>
|
||
|
||
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/60 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-center gap-2 text-xs font-medium text-gray-700 dark:text-gray-200">
|
||
<IdentificationIcon className="size-4" />
|
||
Über mich
|
||
</div>
|
||
<div className="mt-2 text-sm text-gray-800 dark:text-gray-100">
|
||
{about ? (
|
||
<p className="line-clamp-6 whitespace-pre-wrap">{about}</p>
|
||
) : (
|
||
<span className="text-gray-600 dark:text-gray-300">—</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/60 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-center gap-2 text-xs font-medium text-gray-700 dark:text-gray-200">
|
||
<SparklesIcon className="size-4" />
|
||
Wishlist
|
||
</div>
|
||
<div className="mt-2 text-sm text-gray-800 dark:text-gray-100">
|
||
{wish ? (
|
||
<p className="line-clamp-6 whitespace-pre-wrap">{wish}</p>
|
||
) : (
|
||
<span className="text-gray-600 dark:text-gray-300">—</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/60 px-3 py-2 text-xs dark:border-white/10 dark:bg-white/5">
|
||
<span className="text-gray-600 dark:text-gray-300">Real name:</span>{' '}
|
||
<span className="font-medium text-gray-900 dark:text-white">
|
||
{bio?.real_name ? stripHtmlToText(bio.real_name) : '—'}
|
||
</span>
|
||
</div>
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/60 px-3 py-2 text-xs dark:border-white/10 dark:bg-white/5">
|
||
<span className="text-gray-600 dark:text-gray-300">Body type:</span>{' '}
|
||
<span className="font-medium text-gray-900 dark:text-white">{bio?.body_type || '—'}</span>
|
||
</div>
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/60 px-3 py-2 text-xs dark:border-white/10 dark:bg-white/5">
|
||
<span className="text-gray-600 dark:text-gray-300">Smoke/Drink:</span>{' '}
|
||
<span className="font-medium text-gray-900 dark:text-white">{bio?.smoke_drink || '—'}</span>
|
||
</div>
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/60 px-3 py-2 text-xs dark:border-white/10 dark:bg-white/5">
|
||
<span className="text-gray-600 dark:text-gray-300">Sex:</span>{' '}
|
||
<span className="font-medium text-gray-900 dark:text-white">{bio?.sex || '—'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{interested.length ? (
|
||
<div className="mt-2">
|
||
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">Interested in</div>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{interested.map((x) => (
|
||
<span
|
||
key={x}
|
||
className={pill(
|
||
'bg-sky-500/10 text-sky-900 ring-sky-200 dark:text-sky-200 dark:ring-sky-400/20'
|
||
)}
|
||
>
|
||
{x}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* Socials */}
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Socials</div>
|
||
{socials.length ? (
|
||
<span className="text-xs text-gray-600 dark:text-gray-300">{socials.length} Links</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{socials.length ? (
|
||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||
{socials.map((s) => {
|
||
const href = absCbUrl(s.link)
|
||
return (
|
||
<a
|
||
key={s.id}
|
||
href={href}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className={cn(
|
||
'group flex min-w-0 w-full items-center gap-3 overflow-hidden rounded-lg border border-gray-200/70 bg-white/60 px-3 py-2 text-gray-900',
|
||
'transition hover:border-indigo-200 hover:bg-gray-50/80 hover:text-gray-900 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:border-indigo-400/30 dark:hover:bg-white/10 dark:hover:text-white'
|
||
)}
|
||
title={s.title_name}
|
||
>
|
||
<div className="flex size-9 items-center justify-center rounded-lg bg-black/5 dark:bg-white/5">
|
||
{s.image_url ? <img src={s.image_url} alt="" className="size-5" /> : <LinkIcon className="size-5" />}
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{s.title_name}</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
{s.label_text ? s.label_text : s.tokens != null ? `${s.tokens} token(s)` : 'Link'}
|
||
</div>
|
||
</div>
|
||
|
||
<ArrowTopRightOnSquareIcon className="ml-auto size-4 text-gray-400 transition group-hover:text-indigo-500 dark:text-gray-500 dark:group-hover:text-indigo-400" />
|
||
</a>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">Keine Social-Medias vorhanden.</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Photo sets */}
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Photo sets</div>
|
||
{photos.length ? (
|
||
<span className="text-xs text-gray-600 dark:text-gray-300">{photos.length} Sets</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{photos.length ? (
|
||
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||
{photos.slice(0, 6).map((p) => (
|
||
<div
|
||
key={p.id}
|
||
className="overflow-hidden rounded-lg border border-gray-200/70 bg-white/60 dark:border-white/10 dark:bg-white/5"
|
||
>
|
||
<div className="relative">
|
||
{p.cover_url ? (
|
||
<button
|
||
type="button"
|
||
className="block w-full cursor-zoom-in"
|
||
onClick={() => openImage(p.cover_url, p.name)}
|
||
aria-label="Bild vergrößern"
|
||
>
|
||
<img
|
||
src={p.cover_url}
|
||
alt={p.name}
|
||
className={cn('h-28 w-full object-cover transition', previewBlurCls(blurPreviews))}
|
||
/>
|
||
</button>
|
||
) : (
|
||
<div className="flex h-28 w-full items-center justify-center bg-black/5 dark:bg-white/5">
|
||
<PhotoIcon className="size-6 text-gray-500" />
|
||
</div>
|
||
)}
|
||
<div className="absolute left-2 top-2 flex flex-wrap gap-2">
|
||
{p.is_video ? (
|
||
<span
|
||
className={pill(
|
||
'bg-indigo-500/10 text-indigo-900 ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/20'
|
||
)}
|
||
>
|
||
Video
|
||
</span>
|
||
) : (
|
||
<span
|
||
className={pill(
|
||
'bg-gray-500/10 text-gray-900 ring-gray-200 dark:text-gray-200 dark:ring-white/15'
|
||
)}
|
||
>
|
||
Photos
|
||
</span>
|
||
)}
|
||
{p.fan_club_only ? (
|
||
<span
|
||
className={pill(
|
||
'bg-amber-500/10 text-amber-900 ring-amber-200 dark:text-amber-200 dark:ring-amber-400/20'
|
||
)}
|
||
>
|
||
FanClub
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-3">
|
||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{p.name}</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Tokens: <span className="font-medium text-gray-900 dark:text-white">{fmtInt(p.tokens)}</span>
|
||
{p.user_can_access === true ? <span className="ml-2">· Zugriff: ✅</span> : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">Keine Photo-Sets vorhanden.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* DOWNLOADS */}
|
||
{tab === 'downloads' ? (
|
||
<div className="min-h-0">
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Abgeschlossene Downloads</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Inkl. Dateien aus <span className="font-medium">/done/keep/</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-full sm:w-auto">
|
||
<Pagination
|
||
page={donePage}
|
||
pageSize={DONE_PAGE_SIZE}
|
||
totalItems={doneTotalCount}
|
||
onPageChange={(p) => setDonePage(p)}
|
||
siblingCount={1}
|
||
boundaryCount={1}
|
||
showSummary={true}
|
||
prevLabel="Zurück"
|
||
nextLabel="Weiter"
|
||
ariaLabel="Downloads Seiten"
|
||
className="border-0 bg-transparent px-0 py-0 dark:bg-transparent"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2">
|
||
{doneLoading ? (
|
||
<div className="text-sm text-gray-600 dark:text-gray-300">Lade Downloads…</div>
|
||
) : doneMatches.length === 0 ? (
|
||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||
Keine abgeschlossenen Downloads für dieses Model gefunden.
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||
{doneMatches.map((j) => {
|
||
const k = keyFor(j)
|
||
|
||
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
|
||
const previewMuted = !allowSound
|
||
|
||
const fileRaw = baseName(j.output || '')
|
||
const isHot = isHotName(fileRaw)
|
||
const file = stripHotPrefix(fileRaw)
|
||
|
||
const dur = runtimeOf(j)
|
||
const size = formatBytes(sizeBytesOf(j))
|
||
|
||
// Auflösung bevorzugt aus meta/videoWidth/videoHeight
|
||
const meta = parseJobMeta((j as any)?.meta)
|
||
const resObj =
|
||
typeof meta?.videoWidth === 'number' &&
|
||
typeof meta?.videoHeight === 'number' &&
|
||
meta.videoWidth > 0 &&
|
||
meta.videoHeight > 0
|
||
? { w: meta.videoWidth, h: meta.videoHeight }
|
||
: typeof (j as any)?.videoWidth === 'number' &&
|
||
typeof (j as any)?.videoHeight === 'number' &&
|
||
(j as any).videoWidth > 0 &&
|
||
(j as any).videoHeight > 0
|
||
? { w: (j as any).videoWidth, h: (j as any).videoHeight }
|
||
: null
|
||
|
||
const resLabel = formatResolution(resObj)
|
||
|
||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||
const deleted = deletedKeys.has(k)
|
||
|
||
// Model “Flags” (hier: aktuelles Model)
|
||
const isFav = Boolean(model?.favorite)
|
||
const isLiked = model?.liked === true
|
||
const isWatching = Boolean(model?.watching)
|
||
|
||
// Tags: nimm stored + room-tags (wie links), deduped
|
||
const cardTags = allTags
|
||
|
||
// Model Preview Bild: nimm Hero
|
||
const modelImageSrc = firstNonEmptyString(heroImgFull, heroImg)
|
||
|
||
// Preview-ID (für sprite fallback)
|
||
const fileForPreviewId = stripHotPrefix(baseName(j.output || ''))
|
||
const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()
|
||
|
||
// -------- Sprite/Scrubber Setup (wie Gallery) --------
|
||
const spritePathRaw = firstNonEmptyString(
|
||
meta?.previewSprite?.path,
|
||
(meta as any)?.previewSpritePath,
|
||
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
|
||
)
|
||
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
|
||
|
||
const spriteStepSecondsRaw = meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
|
||
const spriteStepSeconds =
|
||
typeof spriteStepSecondsRaw === 'number' && Number.isFinite(spriteStepSecondsRaw) && spriteStepSecondsRaw > 0
|
||
? spriteStepSecondsRaw
|
||
: DEFAULT_SPRITE_STEP_SECONDS
|
||
|
||
const durationForSprite =
|
||
normalizeDurationSeconds(meta?.durationSeconds) ??
|
||
normalizeDurationSeconds((j as any)?.durationSeconds) ??
|
||
normalizeDurationSeconds(durations[k])
|
||
|
||
const inferredSpriteCountFromDuration =
|
||
typeof durationForSprite === 'number' && durationForSprite > 0
|
||
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
|
||
: undefined
|
||
|
||
const spriteCountRaw =
|
||
meta?.previewSprite?.count ??
|
||
(meta as any)?.previewSpriteCount ??
|
||
inferredSpriteCountFromDuration
|
||
|
||
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
|
||
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
|
||
|
||
const spriteCount =
|
||
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw) ? Math.max(0, Math.floor(spriteCountRaw)) : 0
|
||
|
||
const [inferredCols, inferredRows] = spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
|
||
|
||
const spriteCols =
|
||
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw) ? Math.max(0, Math.floor(spriteColsRaw)) : inferredCols
|
||
|
||
const spriteRows =
|
||
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw) ? Math.max(0, Math.floor(spriteRowsRaw)) : inferredRows
|
||
|
||
const spriteVersion =
|
||
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix) ? meta.updatedAtUnix : undefined) ??
|
||
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix) ? (meta as any).fileModUnix : undefined) ??
|
||
0
|
||
|
||
const spriteUrl = spritePath && spriteVersion ? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}` : spritePath || undefined
|
||
|
||
const hasScrubberUi = Boolean(spriteUrl) && spriteCount > 1
|
||
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 =
|
||
typeof activeScrubIndex === 'number' && scrubberCount > 1 ? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1) : undefined
|
||
|
||
const spriteFrameStyle: React.CSSProperties | undefined =
|
||
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
||
? (() => {
|
||
const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1))
|
||
const col = idx % spriteCols
|
||
const row = Math.floor(idx / spriteCols)
|
||
|
||
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
|
||
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
|
||
|
||
return {
|
||
backgroundImage: `url("${spriteUrl}")`,
|
||
backgroundRepeat: 'no-repeat',
|
||
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
|
||
backgroundPosition: `${posX}% ${posY}%`,
|
||
}
|
||
})()
|
||
: undefined
|
||
|
||
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
|
||
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
|
||
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
|
||
|
||
if (deleted) return null
|
||
|
||
return (
|
||
<div
|
||
key={k}
|
||
role="button"
|
||
tabIndex={0}
|
||
className={[
|
||
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||
'bg-white dark:bg-gray-900/40',
|
||
'transition-all duration-200',
|
||
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||
busy && 'pointer-events-none opacity-70',
|
||
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
onClick={() => onOpenPlayer?.(j)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer?.(j)
|
||
}}
|
||
>
|
||
{/* Thumb */}
|
||
<div
|
||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||
onMouseEnter={() => {
|
||
setHoveredThumbKey(k)
|
||
handleHoverPreviewKeyChange(k)
|
||
}}
|
||
onMouseLeave={() => {
|
||
setHoveredThumbKey(null)
|
||
handleHoverPreviewKeyChange(null)
|
||
clearScrubIndex(k)
|
||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||
}}
|
||
>
|
||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||
<div className="absolute inset-0">
|
||
<FinishedVideoPreview
|
||
job={j}
|
||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
||
onDuration={handleDuration}
|
||
variant="fill"
|
||
showPopover={false}
|
||
blur={blurPreviews}
|
||
animated={
|
||
hideTeaserUnderOverlay
|
||
? false
|
||
: teaserPlayback === 'all'
|
||
? true
|
||
: teaserPlayback === 'hover'
|
||
? teaserKey === k
|
||
: false
|
||
}
|
||
animatedMode="teaser"
|
||
animatedTrigger="always"
|
||
muted={previewMuted}
|
||
popoverMuted={previewMuted}
|
||
scrubProgressRatio={scrubProgressRatio}
|
||
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
||
/>
|
||
</div>
|
||
|
||
{/* Sprite preload */}
|
||
{hasSpriteScrubber && spriteUrl ? (
|
||
<img src={spriteUrl} alt="" className="hidden" loading="lazy" decoding="async" aria-hidden="true" />
|
||
) : null}
|
||
|
||
{/* Sprite overlay frame */}
|
||
{showScrubberSpriteInThumb && spriteFrameStyle ? (
|
||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||
<div className="h-full w-full" style={spriteFrameStyle} />
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Model image preview overlay */}
|
||
{showModelPreviewInThumb && modelImageSrc ? (
|
||
<div className="absolute inset-0 z-[6]">
|
||
<img src={modelImageSrc} alt="Model preview" className="h-full w-full object-cover" draggable={false} />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
|
||
<div className="text-[10px] font-semibold tracking-wide text-white/95">MODEL PREVIEW</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Hover Scrubber */}
|
||
{hasScrubber && hoveredThumbKey === k ? (
|
||
<div
|
||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<PreviewScrubber
|
||
className="pointer-events-auto px-1"
|
||
imageCount={scrubberCount}
|
||
activeIndex={activeScrubIndex}
|
||
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
|
||
onIndexClick={(index) => {
|
||
setScrubIndexForKey(k, index)
|
||
handleScrubberClickIndex(j, index, scrubberCount)
|
||
}}
|
||
stepSeconds={scrubberStepSeconds}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Meta overlay bottom right */}
|
||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||
<div
|
||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
|
||
>
|
||
<span>{dur}</span>
|
||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||
{resLabel ? <span>{resLabel}</span> : null}
|
||
<span aria-hidden="true">•</span>
|
||
<span>{size}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="relative min-h-[118px] px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
|
||
{/* filename */}
|
||
<div className="min-w-0">
|
||
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
||
{isHot ? (
|
||
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
|
||
HOT
|
||
</span>
|
||
) : null}
|
||
<span className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white" title={file || '—'}>
|
||
{file || '—'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* actions */}
|
||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||
<RecordJobActions
|
||
job={j}
|
||
variant="table"
|
||
busy={busy}
|
||
collapseToMenu
|
||
compact={true}
|
||
isHot={isHot}
|
||
isFavorite={isFav}
|
||
isLiked={isLiked}
|
||
isWatching={isWatching}
|
||
onToggleWatch={onToggleWatch}
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleLike={onToggleLike}
|
||
onToggleHot={async (job) => {
|
||
await handleToggleHot(job)
|
||
return true
|
||
}}
|
||
onKeep={async (job) => {
|
||
try {
|
||
await onKeep?.(job)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}}
|
||
onDelete={async (job) => {
|
||
try {
|
||
await onDelete?.(job)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}}
|
||
order={[ 'hot', 'keep', 'delete']}
|
||
className="w-full gap-1.5"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* tags */}
|
||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||
<TagOverflowRow
|
||
rowKey={k}
|
||
tags={cardTags}
|
||
activeTagSet={new Set()} // in ModelDetails kein Tag-Filter -> leer
|
||
lower={lower}
|
||
onToggleTagFilter={() => {}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* RUNNING */}
|
||
{tab === 'running' ? (
|
||
<div className="min-h-0">
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Laufender Download</div>
|
||
{runningLoading ? <span className="text-xs text-gray-500 dark:text-gray-400">Lade…</span> : null}
|
||
</div>
|
||
|
||
<div className="mt-3">
|
||
{runningLoading ? (
|
||
<div className="text-sm text-gray-600 dark:text-gray-300">Lade…</div>
|
||
) : runningMatches.length === 0 ? (
|
||
<div className="text-sm text-gray-600 dark:text-gray-300">Kein laufender Job.</div>
|
||
) : (() => {
|
||
const j = runningMatches[0]
|
||
const k = keyFor(j)
|
||
|
||
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
|
||
const previewMuted = !allowSound
|
||
|
||
const fileRaw = baseName(j.output || '')
|
||
const isHot = isHotName(fileRaw)
|
||
const file = stripHotPrefix(fileRaw) || '—'
|
||
|
||
const meta = parseJobMeta((j as any)?.meta)
|
||
|
||
// Auflösung bevorzugt aus meta/videoWidth/videoHeight
|
||
const resObj =
|
||
typeof meta?.videoWidth === 'number' &&
|
||
typeof meta?.videoHeight === 'number' &&
|
||
meta.videoWidth > 0 &&
|
||
meta.videoHeight > 0
|
||
? { w: meta.videoWidth, h: meta.videoHeight }
|
||
: typeof (j as any)?.videoWidth === 'number' &&
|
||
typeof (j as any)?.videoHeight === 'number' &&
|
||
(j as any).videoWidth > 0 &&
|
||
(j as any).videoHeight > 0
|
||
? { w: (j as any).videoWidth, h: (j as any).videoHeight }
|
||
: null
|
||
|
||
const resLabel = formatResolution(resObj)
|
||
|
||
// Dauer + Size im gleichen Stil wie Gallery
|
||
const dur = runtimeOf(j)
|
||
const size = formatBytes(sizeBytesOf(j))
|
||
|
||
const cardTags = allTags
|
||
const modelImageSrc = firstNonEmptyString(heroImgFull, heroImg)
|
||
|
||
// Preview-ID (für sprite fallback)
|
||
const fileForPreviewId = stripHotPrefix(baseName(j.output || ''))
|
||
const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()
|
||
|
||
// -------- Sprite/Scrubber Setup (wie Gallery) --------
|
||
const spritePathRaw = firstNonEmptyString(
|
||
meta?.previewSprite?.path,
|
||
(meta as any)?.previewSpritePath,
|
||
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
|
||
)
|
||
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
|
||
|
||
const spriteStepSecondsRaw =
|
||
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
|
||
const spriteStepSeconds =
|
||
typeof spriteStepSecondsRaw === 'number' &&
|
||
Number.isFinite(spriteStepSecondsRaw) &&
|
||
spriteStepSecondsRaw > 0
|
||
? spriteStepSecondsRaw
|
||
: DEFAULT_SPRITE_STEP_SECONDS
|
||
|
||
const durationForSprite =
|
||
normalizeDurationSeconds(meta?.durationSeconds) ??
|
||
normalizeDurationSeconds((j as any)?.durationSeconds) ??
|
||
normalizeDurationSeconds(durations[k])
|
||
|
||
const inferredSpriteCountFromDuration =
|
||
typeof durationForSprite === 'number' && durationForSprite > 0
|
||
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
|
||
: undefined
|
||
|
||
const spriteCountRaw =
|
||
meta?.previewSprite?.count ??
|
||
(meta as any)?.previewSpriteCount ??
|
||
inferredSpriteCountFromDuration
|
||
|
||
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
|
||
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
|
||
|
||
const spriteCount =
|
||
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
|
||
? Math.max(0, Math.floor(spriteCountRaw))
|
||
: 0
|
||
|
||
const [inferredCols, inferredRows] =
|
||
spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
|
||
|
||
const spriteCols =
|
||
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
|
||
? Math.max(0, Math.floor(spriteColsRaw))
|
||
: inferredCols
|
||
|
||
const spriteRows =
|
||
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
|
||
? Math.max(0, Math.floor(spriteRowsRaw))
|
||
: inferredRows
|
||
|
||
const spriteVersion =
|
||
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
|
||
? meta.updatedAtUnix
|
||
: undefined) ??
|
||
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
|
||
? (meta as any).fileModUnix
|
||
: undefined) ??
|
||
0
|
||
|
||
const spriteUrl =
|
||
spritePath && spriteVersion
|
||
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
|
||
: spritePath || undefined
|
||
|
||
const hasScrubberUi = Boolean(spriteUrl) && spriteCount > 1
|
||
const hasSpriteScrubber = hasScrubberUi && spriteCols > 0 && spriteRows > 0
|
||
|
||
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
||
|
||
const activeScrubIndex = scrubIndexByKey[k]
|
||
const scrubProgressRatio =
|
||
typeof activeScrubIndex === 'number' && scrubberCount > 1
|
||
? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1)
|
||
: undefined
|
||
|
||
const spriteFrameStyle: React.CSSProperties | undefined =
|
||
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
||
? (() => {
|
||
const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1))
|
||
const col = idx % spriteCols
|
||
const row = Math.floor(idx / spriteCols)
|
||
|
||
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
|
||
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
|
||
|
||
return {
|
||
backgroundImage: `url("${spriteUrl}")`,
|
||
backgroundRepeat: 'no-repeat',
|
||
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
|
||
backgroundPosition: `${posX}% ${posY}%`,
|
||
}
|
||
})()
|
||
: undefined
|
||
|
||
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
|
||
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
|
||
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
|
||
|
||
const showLive = isRunningJob(j)
|
||
|
||
return (
|
||
<div className="w-full">
|
||
<div
|
||
className={cn(
|
||
'group relative overflow-hidden rounded-lg outline-1 outline-black/5',
|
||
'bg-white shadow-sm backdrop-blur',
|
||
'dark:-outline-offset-1 dark:outline-white/10 dark:bg-gray-900/40'
|
||
)}
|
||
>
|
||
{/* Thumb (GROSS) */}
|
||
<div
|
||
role={onOpenPlayer ? 'button' : undefined}
|
||
tabIndex={onOpenPlayer ? 0 : undefined}
|
||
className={cn(
|
||
'group/thumb relative w-full bg-black/5 dark:bg-white/5',
|
||
'aspect-video sm:aspect-[21/9]',
|
||
onOpenPlayer ? 'cursor-pointer' : ''
|
||
)}
|
||
onClick={() => onOpenPlayer?.(j)}
|
||
onKeyDown={(e) => {
|
||
if (!onOpenPlayer) return
|
||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||
}}
|
||
onMouseEnter={() => {
|
||
setRunningHover(true)
|
||
setHoveredThumbKey(k)
|
||
handleHoverPreviewKeyChange(k)
|
||
}}
|
||
onMouseLeave={() => {
|
||
setRunningHover(false)
|
||
setHoveredThumbKey(null)
|
||
handleHoverPreviewKeyChange(null)
|
||
clearScrubIndex(k)
|
||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||
}}
|
||
>
|
||
{/* Clip area */}
|
||
<div className="absolute inset-0 overflow-hidden">
|
||
{/* Base Preview (nur wenn Live NICHT aktiv) */}
|
||
{!showLive ? (
|
||
<div className="absolute inset-0">
|
||
<FinishedVideoPreview
|
||
job={j}
|
||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
||
onDuration={handleDuration}
|
||
variant="fill"
|
||
showPopover={false}
|
||
blur={blurPreviews}
|
||
animated={
|
||
hideTeaserUnderOverlay
|
||
? false
|
||
: teaserPlayback === 'all'
|
||
? true
|
||
: teaserPlayback === 'hover'
|
||
? teaserKey === k
|
||
: false
|
||
}
|
||
animatedMode="teaser"
|
||
animatedTrigger="always"
|
||
muted={previewMuted}
|
||
popoverMuted={previewMuted}
|
||
scrubProgressRatio={scrubProgressRatio}
|
||
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Live HLS inline (statt Teaser) */}
|
||
{showLive ? (
|
||
<div className="absolute inset-0 z-10">
|
||
<LiveVideo
|
||
src={`/api/preview/live?id=${encodeURIComponent(String((j as any)?.id ?? ''))}&hover=1`}
|
||
className="absolute inset-0 w-full h-full"
|
||
muted
|
||
/>
|
||
|
||
{/* ✅ LIVE badge oben links */}
|
||
<div className="absolute left-2 top-2 z-20 pointer-events-none inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
||
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
||
Live
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Meta overlay bottom right */}
|
||
<div className="pointer-events-none absolute right-2 bottom-2 z-20 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||
<div
|
||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
|
||
>
|
||
<span>{size}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer (wie Gallery) */}
|
||
<div className="relative px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
|
||
{/* filename */}
|
||
<div className="min-w-0">
|
||
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
||
{isHot ? (
|
||
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
|
||
HOT
|
||
</span>
|
||
) : null}
|
||
<span className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white" title={file}>
|
||
{file}
|
||
</span>
|
||
</div>
|
||
|
||
{/* kleine Meta-Zeile (optional, aber nice) */}
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Start:{' '}
|
||
<span className="font-medium text-gray-900 dark:text-white">
|
||
{fmtDateTime((j as any)?.startedAt as any)}
|
||
</span>
|
||
{' · '}
|
||
Status:{' '}
|
||
<span className="font-medium text-gray-900 dark:text-white">
|
||
{String((j as any)?.status ?? 'running')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ✅ actions: Stop Button wie im Player */}
|
||
<div
|
||
className="mt-2"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||
<Button
|
||
variant="primary"
|
||
color="red"
|
||
size="sm"
|
||
rounded="md"
|
||
className="w-full shadow-none"
|
||
disabled={!onStopJob || !isRunningJob(j) || stopPending}
|
||
title={stopPending ? 'Stoppe…' : 'Stop'}
|
||
aria-label={stopPending ? 'Stoppe…' : 'Stop'}
|
||
onClick={async (e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (!onStopJob) return
|
||
if (!isRunningJob(j)) return
|
||
if (stopPending) return
|
||
|
||
try {
|
||
setStopPending(true)
|
||
await onStopJob(String((j as any)?.id ?? ''))
|
||
} finally {
|
||
setStopPending(false)
|
||
}
|
||
}}
|
||
>
|
||
{stopPending ? 'Stoppe…' : 'Stoppen'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* tags */}
|
||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||
<TagOverflowRow
|
||
rowKey={k}
|
||
tags={cardTags}
|
||
activeTagSet={new Set()}
|
||
lower={lower}
|
||
onToggleTagFilter={() => {}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* Image viewer modal (unverändert) */}
|
||
<Modal
|
||
open={Boolean(imgViewer)}
|
||
onClose={() => setImgViewer(null)}
|
||
title={imgViewer?.alt || 'Bild'}
|
||
width="max-w-4xl"
|
||
layout="single"
|
||
scroll="body"
|
||
footer={
|
||
<div className="flex items-center justify-end gap-2">
|
||
{imgViewer?.src ? (
|
||
<a
|
||
href={imgViewer.src}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200/70 bg-white/70 px-3 py-1.5 text-xs font-medium text-gray-900 shadow-sm backdrop-blur hover:bg-white dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||
>
|
||
<ArrowTopRightOnSquareIcon className="size-4" />
|
||
In neuem Tab
|
||
</a>
|
||
) : null}
|
||
<Button variant="secondary" onClick={() => setImgViewer(null)}>
|
||
Schließen
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="grid place-items-center">
|
||
{imgViewer?.src ? (
|
||
<img
|
||
src={imgViewer.src}
|
||
alt={imgViewer.alt || ''}
|
||
className={cn('max-h-[80vh] w-auto max-w-full rounded-lg object-contain', previewBlurCls(blurPreviews))}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</Modal>
|
||
</Modal>
|
||
)
|
||
}
|