nsfwapp/frontend/src/components/ui/DownloadsCardRow.tsx
2026-06-19 07:05:34 +02:00

1037 lines
30 KiB
TypeScript

// frontend\src\components\ui\DownloadsCardRow.tsx
'use client'
import { useMemo, useState, useRef, useEffect } from 'react'
import Button from './Button'
import ModelPreview from './ModelPreview'
import ModelGenderIcon from './ModelGenderIcon'
import ProgressBar from './ProgressBar'
import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types'
import LazyMountWhenVisible from './LazyMountWhenVisible'
import CountryFlag from './CountryFlag'
export type PendingWatchedRoom = WaitingModelRow & {
currentShow: string
}
type WaitingModelRow = {
id: string
modelKey: string
url: string
imageUrl?: string
currentShow?: string
}
export type DownloadRow =
| { kind: 'job'; job: RecordJob }
| { kind: 'pending'; pending: PendingWatchedRoom }
type ModelFlags = {
favorite?: boolean
liked?: boolean | null
watching?: boolean
roomStatus?: string
imageUrl?: string
gender?: string | null
country?: string | null
}
type Props = {
r: DownloadRow
nowMs: number
blurPreviews?: boolean
modelsByKey: Record<string, ModelFlags>
roomStatusByModelKey: Record<string, string>
stopRequestedIds: Record<string, true>
growingByJobId: Record<string, boolean>
postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined
markStopRequested: (ids: string | string[]) => void
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void | Promise<void>
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
}
const pendingModelName = (p: PendingWatchedRoom) => {
const anyP = p as any
return (
anyP.modelName ??
anyP.model ??
anyP.modelKey ??
anyP.username ??
anyP.name ??
'—'
)
}
const pendingUrl = (p: PendingWatchedRoom) => {
const anyP = p as any
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
}
const pendingImageUrl = (p: PendingWatchedRoom) => {
const anyP = p as any
return String(anyP.imageUrl ?? anyP.image_url ?? '').trim()
}
const normalizeRoomStatus = (v: unknown): string => {
const s = String(v ?? '').trim().toLowerCase()
switch (s) {
case 'public':
return 'Public'
case 'private':
return 'Private'
case 'hidden':
return 'Hidden'
case 'away':
return 'Away'
case 'offline':
return 'Offline'
default:
return 'Unknown'
}
}
const baseName = (p: string) =>
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
const modelNameFromOutput = (output?: string) => {
const file = baseName(output || '')
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
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
}
const modelKeyFromJob = (job: RecordJob): string => {
const j = job as any
const rawUrl = String(j.sourceUrl ?? '').trim()
if (rawUrl) {
const m = rawUrl.match(/chaturbate\.com\/([^/?#]+)/i)
if (m?.[1]) {
try {
return decodeURIComponent(m[1]).trim().toLowerCase()
} catch {
return String(m[1]).trim().toLowerCase()
}
}
}
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
}
const previewInitialSrcOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
return onlineImageUrlOfJob(job, modelsByKey)
}
const onlineImageUrlOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
const anyJ = job as any
const direct =
String(
anyJ?.modelImageUrl ??
anyJ?.avatar ??
anyJ?.previewImageUrl ??
anyJ?.imageUrl ??
anyJ?.model?.imageUrl ??
''
).trim()
if (direct) return direct
const modelKey = modelKeyFromJob(job)
if (!modelKey) return ''
return String(modelsByKey?.[modelKey]?.imageUrl ?? '').trim()
}
const roomStatusOfJob = (
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>
): string => {
const j = job as any
const rawFromJob =
j.currentShow ??
j.roomStatus ??
j.modelStatus ??
j.show ??
j.statusText ??
j.model?.currentShow ??
j.model?.roomStatus ??
j.model?.status ??
j.room?.currentShow ??
j.room?.status
if (rawFromJob != null && String(rawFromJob).trim() !== '') {
return normalizeRoomStatus(rawFromJob)
}
const modelKey = modelKeyFromJob(job)
if (modelKey && roomStatusByModelKey?.[modelKey]) {
return normalizeRoomStatus(roomStatusByModelKey[modelKey])
}
if (modelKey && modelsByKey?.[modelKey]?.roomStatus) {
return normalizeRoomStatus(modelsByKey[modelKey].roomStatus)
}
return 'Unknown'
}
function effectiveRoomStatusOfJob(
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>,
growingByJobId?: Record<string, boolean>
): string {
const raw = roomStatusOfJob(job, roomStatusByModelKey, modelsByKey)
if (raw !== 'Unknown') return raw
const anyJ = job as any
const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase()
const statusLower = String(job.status ?? '').trim().toLowerCase()
const isActiveRecording =
!job.endedAt &&
statusLower === 'running' &&
(phaseLower === '' || phaseLower === 'recording')
if (isActiveRecording && growingByJobId?.[job.id]) {
return 'Public'
}
return raw
}
function previewRoomStatusOfJob(
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>,
growingByJobId?: Record<string, boolean>
): string {
const postworkState = getEffectivePostworkState(job)
if (postworkState === 'running' || postworkState === 'queued') {
return 'Public'
}
return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId)
}
const roomStatusTone = (status: string): string => {
switch (status) {
case 'Public':
return 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
case 'Private':
return 'bg-fuchsia-500/15 text-fuchsia-900 ring-fuchsia-500/30 dark:bg-fuchsia-400/10 dark:text-fuchsia-200 dark:ring-fuchsia-400/25'
case 'Hidden':
return 'bg-slate-500/15 text-slate-900 ring-slate-500/30 dark:bg-slate-400/10 dark:text-slate-200 dark:ring-slate-400/25'
case 'Away':
return 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
case 'Offline':
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
case 'Unknown':
default:
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
}
}
const toMs = (v: unknown): number => {
if (typeof v === 'number' && Number.isFinite(v)) {
return v < 1_000_000_000_000 ? v * 1000 : v
}
if (typeof v === 'string') {
const ms = Date.parse(v)
return Number.isFinite(ms) ? ms : 0
}
if (v instanceof Date) return v.getTime()
return 0
}
const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const total = Math.floor(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const runtimeOf = (j: RecordJob, nowMs: number) => {
const anyJ = j as any
const start =
toMs(anyJ.startedAtMs) ||
toMs(j.startedAt)
if (!Number.isFinite(start) || start <= 0) return '—'
const end =
j.endedAt
? (toMs(anyJ.endedAtMs) || toMs(j.endedAt))
: nowMs
if (!Number.isFinite(end) || end <= 0) return '—'
return formatDuration(end - start)
}
const sizeBytesOf = (job: RecordJob): number | null => {
const anyJob = job as any
const v = anyJob.sizeBytes ?? anyJob.fileSizeBytes ?? anyJob.bytes ?? anyJob.size ?? null
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
}
const formatBytes = (bytes: number | null): string => {
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 10 ? 1 : 2
const s = v.toFixed(digits).replace(/\.0+$/, '')
return `${s} ${units[i]}`
}
const phaseLabel = (p?: string) => {
switch ((p ?? '').toLowerCase()) {
case 'stopping':
return 'Stoppe Aufnahme…'
case 'probe':
return 'Analysiere Datei (Dauer/Streams)…'
case 'remuxing':
return 'Konvertiere Container zu MP4…'
case 'moving':
return 'Verschiebe nach Done…'
case 'assets':
return 'Erstelle Vorschau/Thumbnails…'
case 'analyze':
return 'AI analysiert Segmente…'
case 'postwork':
return 'Nacharbeiten laufen…'
default:
return ''
}
}
const STOP_RETRY_AFTER_MS = 15_000
const stopRequestedAtMsOf = (job: RecordJob): number => {
return toMs((job as any)?.stopRequestedAtMs)
}
const isFreshStoppingPhase = (job: RecordJob, nowMs: number): boolean => {
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
if (phase !== 'stopping') return false
const startedAt = stopRequestedAtMsOf(job)
return startedAt > 0 && Math.max(0, nowMs - startedAt) < STOP_RETRY_AFTER_MS
}
const canRetryStoppingJob = (job: RecordJob, nowMs: number): boolean => {
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
const status = String((job as any)?.status ?? '').trim().toLowerCase()
return phase === 'stopping' && status === 'running' && !isFreshStoppingPhase(job, nowMs)
}
const isStopWaitingForJob = (
job: RecordJob,
localStopRequested: boolean,
nowMs: number
): boolean => {
return localStopRequested || isFreshStoppingPhase(job, nowMs)
}
const isStoppingForUi = (
job: RecordJob,
localStopRequested: boolean,
nowMs: number
): boolean => {
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
const status = String((job as any)?.status ?? '').trim().toLowerCase()
const isBusyPhase = phase !== '' && phase !== 'recording' && phase !== 'stopping'
return isBusyPhase || status !== 'running' || isStopWaitingForJob(job, localStopRequested, nowMs)
}
const isTerminalStatus = (status?: unknown) => {
const s = String(status ?? '').trim().toLowerCase()
return (
s === 'stopped' ||
s === 'finished' ||
s === 'failed' ||
s === 'done' ||
s === 'completed' ||
s === 'canceled' ||
s === 'cancelled'
)
}
const isPostworkJob = (job: RecordJob): boolean => {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim()
const pw = anyJ.postWork
const pwKey = String(anyJ.postWorkKey ?? '').trim()
if (pwKey) return true
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
if (job.endedAt && phase) return true
if (phase === 'postwork') return true
return false
}
const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none' => {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
const pw = anyJ.postWork
const pwState = String(pw?.state ?? '').trim().toLowerCase()
const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== ''
if (!isPostworkJob(job)) return 'none'
if (isTerminalStatus(anyJ?.status)) return 'none'
if (pwState === 'queued') return 'queued'
if (pwState === 'running') return 'running'
if (
phase === 'postwork' ||
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets'
) {
return 'running'
}
if (
typeof pw?.position === 'number' &&
Number.isFinite(pw.position) &&
pw.position > 0
) {
return 'queued'
}
if (
typeof pw?.running === 'number' &&
Number.isFinite(pw.running) &&
pw.running > 0 &&
(!Number.isFinite(pw?.position) || pw.position <= 0)
) {
return 'running'
}
if (hasPwKey) {
return 'queued'
}
if (anyJ.endedAt || job.endedAt) return 'queued'
return 'none'
}
function postWorkLabel(
job: RecordJob,
override?: { pos?: number; total?: number }
): string {
const anyJ = job as any
const pw = anyJ.postWork
const effectiveState = getEffectivePostworkState(job)
if (effectiveState === 'running') {
const running = typeof pw?.running === 'number' && pw.running > 0 ? pw.running : 1
const maxP = typeof pw?.maxParallel === 'number' ? pw.maxParallel : 0
return maxP > 0
? `Nacharbeiten laufen… (${running}/${maxP} parallel)`
: 'Nacharbeiten laufen…'
}
if (effectiveState === 'queued') {
const posServer = typeof pw?.position === 'number' ? pw.position : 0
const waitingServer = typeof pw?.waiting === 'number' ? pw.waiting : 0
const totalServer = Math.max(waitingServer, posServer)
const pos =
typeof override?.pos === 'number' && Number.isFinite(override.pos) && override.pos > 0
? override.pos
: posServer
const total =
typeof override?.total === 'number' && Number.isFinite(override.total) && override.total > 0
? override.total
: totalServer
return pos > 0 && total > 0
? `Warte auf Nacharbeiten… ${pos} / ${total}`
: 'Warte auf Nacharbeiten…'
}
return 'Warte auf Nacharbeiten…'
}
export function PendingPreviewImage({
src,
alt,
blur,
className,
}: {
src: string
alt: string
blur?: boolean
className?: string
}) {
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const [nonce, setNonce] = useState(0)
const [imgError, setImgError] = useState(false)
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setInView(Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0))
},
{ root: null, threshold: 0.01 }
)
obs.observe(el)
return () => obs.disconnect()
}, [])
useEffect(() => {
if (!inView) return
const id = window.setInterval(() => {
setNonce((n) => n + 1)
}, 20000)
return () => window.clearInterval(id)
}, [inView, src])
useEffect(() => {
setImgError(false)
}, [src])
const finalSrc = useMemo(() => {
if (!inView) return ''
const s = String(src ?? '').trim()
if (!s) return ''
return s.includes('?') ? `${s}&v=${nonce}` : `${s}?v=${nonce}`
}, [src, nonce, inView])
return (
<div
ref={rootRef}
className={className}
>
{inView && !imgError && finalSrc ? (
<img
src={finalSrc}
alt={alt}
className={[
'block h-full w-full object-cover',
blur ? 'blur-md' : '',
].join(' ')}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={() => setImgError(true)}
/>
) : (
<div
className={[
'grid h-full w-full place-items-center bg-gray-100 text-sm text-gray-500 dark:bg-white/10 dark:text-gray-300',
blur ? 'blur-md' : '',
].join(' ')}
>
</div>
)}
</div>
)
}
export default function DownloadsCardRow({
r,
nowMs,
blurPreviews,
modelsByKey,
roomStatusByModelKey,
stopRequestedIds,
growingByJobId,
postworkInfoOf,
markStopRequested,
onOpenPlayer,
onStopJob,
onRemoveQueuedPostworkJob,
onToggleFavorite,
onToggleLike,
onToggleWatch,
}: Props) {
if (r.kind === 'pending') {
const p = r.pending
const name = pendingModelName(p)
const url = pendingUrl(p)
const show = normalizeRoomStatus(p.currentShow)
const pendingKey = String(p.modelKey ?? name).trim().toLowerCase()
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
const img =
pendingImageUrl(p) ||
String(pendingFlags?.imageUrl ?? '').trim()
const pendingActionJob = {
id: `pending:${pendingKey || name}`,
output: `${name}_pending.mp4`,
sourceUrl: url,
status: 'pending',
endedAt: null,
} as unknown as RecordJob
const isFav = Boolean(pendingFlags?.favorite)
const isLiked = pendingFlags?.liked === true
const isWatching = Boolean(pendingFlags?.watching)
return (
<div
className="
group relative isolate overflow-hidden rounded-2xl
border border-gray-200/90 bg-white shadow-sm
ring-1 ring-black/5
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
dark:border-white/10 dark:bg-gray-950
"
>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-gray-50/90 via-white to-gray-50/70 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="pointer-events-none absolute -inset-10 opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100 bg-white/25 dark:bg-white/5" />
<div className="relative p-3">
<div className="flex items-start gap-3">
<div
className="
relative shrink-0 overflow-hidden rounded-lg
w-[112px] h-[64px]
bg-gray-100 ring-1 ring-black/5
dark:bg-white/10 dark:ring-white/10
"
>
{img ? (
<PendingPreviewImage
src={img}
alt={name}
blur={blurPreviews}
className="h-full w-full"
/>
) : (
<div className="grid h-full w-full place-items-center text-sm text-gray-500 dark:text-gray-300">
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div
className="min-w-0 truncate text-base font-semibold text-gray-900 dark:text-white"
title={name}
>
{name}
</div>
<ModelGenderIcon
gender={pendingFlags?.gender}
className="shrink-0"
/>
<CountryFlag
country={pendingFlags?.country}
size="sm"
className="shrink-0"
/>
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
Warte auf Online-Status
</div>
</div>
<div className="shrink-0 flex items-start">
<span
className={[
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomStatusTone(show),
].join(' ')}
title={show}
>
{show}
</span>
</div>
</div>
{url ? (
<a
href={url}
target="_blank"
rel="noreferrer"
className="mt-1 block truncate text-xs text-indigo-600 hover:underline dark:text-indigo-400"
onClick={(e) => e.stopPropagation()}
title={url}
>
{url}
</a>
) : null}
</div>
</div>
<div className="mt-3 flex items-center gap-3 border-t border-white/30 pt-3 dark:border-white/10">
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={pendingActionJob}
variant="table"
busy={false}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
</div>
<div
className="ml-auto flex items-center justify-end gap-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200 whitespace-nowrap">
Wartend
</span>
<div className="hidden sm:block">
<Button
size="sm"
variant="primary"
color="red"
className="shrink-0"
disabled
>
Wartend
</Button>
</div>
</div>
</div>
</div>
</div>
)
}
const j = r.job
const name = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const phase = String((j as any).phase ?? '').trim()
const phaseLower = phase.toLowerCase()
const isRecording = phaseLower === 'recording'
const isStopRequested = Boolean(stopRequestedIds[j.id])
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
const showRemoveQueuedButton = isQueuedPostwork
const disableStopButton = showRemoveQueuedButton ? false : isStopping
const actionsBusy = showRemoveQueuedButton ? false : isStopping
const roomStatus = effectiveRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)
let phaseText = ''
if (phaseLower === 'recording') {
phaseText = 'Recording läuft…'
} else if (isPostworkJob(j)) {
const pwState = getEffectivePostworkState(j)
if (phaseLower === 'probe' || phaseLower === 'remuxing' || phaseLower === 'moving' || phaseLower === 'assets') {
phaseText = phaseLabel(phase) || phase
} else if (pwState === 'queued') {
phaseText = postWorkLabel(j, postworkInfoOf(j))
} else if (pwState === 'running') {
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
}
} else {
if (phaseLower === 'stopping' && canRetryStop) {
phaseText = 'Stop dauert zu lange - erneut stoppen'
} else {
phaseText = phase ? (phaseLabel(phase) || phase) : ''
}
}
const progressLabel = phaseText || roomStatus
const progress = Number((j as any).progress ?? 0)
const showBar =
!isRecording &&
Number.isFinite(progress) &&
progress > 0 &&
progress < 100
const showIndeterminate =
!isRecording &&
!showBar &&
Boolean(phase) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
const modelKey = modelKeyFromJob(j)
const flags = modelKey ? modelsByKey[modelKey] : undefined
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey)
return (
<div
className="
group relative isolate overflow-hidden rounded-2xl
border border-gray-200/90 bg-white shadow-sm
ring-1 ring-black/5
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
dark:border-white/10 dark:bg-gray-950
"
onClick={() => onOpenPlayer(j)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-gray-50/90 via-white to-gray-50/70 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="pointer-events-none absolute -inset-10 opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100 bg-white/25 dark:bg-white/5" />
<div className="relative p-3">
<div className="flex items-start gap-3">
<div
className="
relative
shrink-0 overflow-hidden rounded-lg
w-[112px] h-[64px]
bg-gray-100 ring-1 ring-black/5
dark:bg-white/10 dark:ring-white/10
"
>
<LazyMountWhenVisible
className="w-full h-full"
placeholder={<div className="h-full w-full bg-gray-100 dark:bg-white/10" />}
>
<ModelPreview
jobId={j.id}
initialSrc={directPreviewSrc || undefined}
enableHoverLive={false}
blur={blurPreviews}
roomStatus={previewRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)}
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
className="w-full h-full"
/>
</LazyMountWhenVisible>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div
className="min-w-0 truncate text-base font-semibold text-gray-900 dark:text-white"
title={name}
>
{name}
</div>
<ModelGenderIcon
gender={flags?.gender}
className="shrink-0"
/>
<CountryFlag
country={flags?.country}
size="sm"
className="shrink-0"
/>
</div>
<div
className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300"
title={j.output}
>
{file || '—'}
</div>
</div>
<div className="shrink-0 flex items-start">
<span
className={[
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomStatusTone(roomStatus),
].join(' ')}
title={roomStatus}
>
{roomStatus}
</span>
</div>
</div>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-1 block truncate text-xs text-indigo-600 hover:underline dark:text-indigo-400"
onClick={(e) => e.stopPropagation()}
title={j.sourceUrl}
>
{j.sourceUrl}
</a>
) : null}
</div>
</div>
{(showBar || showIndeterminate) ? (
<div className="mt-3">
{showBar ? (
<ProgressBar
label={progressLabel}
value={Math.max(0, Math.min(100, progress))}
showPercent
size="sm"
className="w-full"
/>
) : (
<ProgressBar
label={progressLabel}
indeterminate
size="sm"
className="w-full"
/>
)}
</div>
) : null}
<div className="mt-3 flex items-center gap-3 border-t border-white/30 pt-3 dark:border-white/10">
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={j}
variant="table"
busy={actionsBusy}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
</div>
<div
className="ml-auto flex min-w-0 self-center items-center justify-end gap-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-end gap-2 text-right">
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200 whitespace-nowrap">
{runtimeOf(j, nowMs)}
</span>
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200 whitespace-nowrap">
{formatBytes(sizeBytesOf(j))}
</span>
</div>
<div className="hidden sm:block">
<Button
size="sm"
variant="primary"
color={isQueuedPostwork ? 'red' : 'indigo'}
disabled={disableStopButton}
className="shrink-0"
onClick={async (e) => {
e.stopPropagation()
if (showRemoveQueuedButton) {
await onRemoveQueuedPostworkJob?.(j.id)
return
}
if (isStopping) return
markStopRequested(j.id)
await onStopJob(j.id)
}}
>
{showRemoveQueuedButton
? 'Entfernen'
: canRetryStop
? 'Stop erneut'
: (isStopping ? 'Stoppe…' : 'Stop')}
</Button>
</div>
</div>
</div>
</div>
</div>
)
}