nsfwapp/frontend/src/components/ui/ModelDetails.tsx
2026-03-06 16:59:51 +01:00

2742 lines
114 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
)
}