nsfwapp/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx
2026-03-19 21:46:24 +01:00

781 lines
31 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\FinishedDownloadsGalleryView.tsx
'use client'
import * as React from 'react'
import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import {
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
import PreviewScrubber from './PreviewScrubber'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
shouldObserveTeasers,
} from './teaserPlayback'
type ModelFlags = {
favorite?: boolean
liked?: boolean | null
watching?: boolean | null
tags?: string
// ✅ Optional für Model-Hover-Preview
image?: string
imageUrl?: string | null
// ✅ Optional für stashapp-artigen Preview-Scrubber
previewScrubberPath?: string
previewScrubberCount?: number
}
type FinishedPostworkBadge = {
file: string
assetId?: string
queue: 'postwork' | 'enrich'
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
phase?: string
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts: number
}
type Props = {
rows: RecordJob[]
isLoading?: boolean
blurPreviews?: boolean
durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkBadge>
teaserState: FinishedDownloadsTeaserState
handleDuration: (job: RecordJob, seconds: number) => void
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
jobForDetails: (job: RecordJob) => RecordJob
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null
formatBytes: (bytes?: number | null) => string
deletingKeys: Set<string>
keepingKeys: Set<string>
removingKeys: Set<string>
deletedKeys: Set<string>
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
onOpenPlayer: (job: RecordJob) => void
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
lower: (s: string) => string
modelsByKey: Record<string, ModelFlags>
activeTagSet: Set<string>
onHoverPreviewKeyChange?: (key: string | null) => void
onToggleTagFilter: (tag: string) => void
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onToggleHot: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
forcePreviewMuted?: boolean
previewActivationNonce: number
}
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]
}
export default function FinishedDownloadsGalleryView({
rows,
isLoading,
blurPreviews,
durations,
postworkByFile,
teaserState,
handleDuration,
handleScrubberClickIndex,
jobForDetails,
keyFor,
baseName,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
formatBytes,
deletingKeys,
keepingKeys,
removingKeys,
registerTeaserHost,
onHoverPreviewKeyChange,
onOpenPlayer,
deleteVideo,
keepVideo,
onToggleHot,
lower,
modelsByKey,
activeTagSet,
onToggleTagFilter,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
forcePreviewMuted,
previewActivationNonce,
}: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const observeTeasers = shouldObserveTeasers(teaserState.mode)
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback(
(key: string) => (el: HTMLDivElement | null) => {
if (!observeTeasers) {
registerTeaserHost(key)(null)
return
}
registerTeaserHost(key)(el)
},
[registerTeaserHost, observeTeasers]
)
const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
return out
}
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const w =
(typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth)
? (j as any).meta.videoWidth
: 0) ||
(typeof (j as any)?.videoWidth === 'number' && Number.isFinite((j as any).videoWidth)
? (j as any).videoWidth
: 0)
const h =
(typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight)
? (j as any).meta.videoHeight
: 0) ||
(typeof (j as any)?.videoHeight === 'number' && Number.isFinite((j as any).videoHeight)
? (j as any).videoHeight
: 0)
if (w > 0 && h > 0) return { w, h }
return null
}, [])
// ✅ Modelbild-Preview (wird beim Hover auf Modelnamen im Thumb eingeblendet)
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
// ✅ stashapp-artiger Hover-Scrubber-Zustand (pro Karte)
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(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])
return (
<div className="relative">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{rows.map((j) => {
const k = keyFor(j)
const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k
// Sound nur bei Hover auf genau diesem Teaser
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const postworkBadge =
postworkByFile[fileRaw] ??
postworkByFile[stripHotPrefix(fileRaw)]
const showPostworkBadge =
postworkBadge &&
(postworkBadge.state === 'queued' ||
postworkBadge.state === 'running' ||
postworkBadge.state === 'done' ||
postworkBadge.state === 'error')
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
const isRemoving = removingKeys.has(k)
const isDeleting = deletingKeys.has(k)
const isKeeping = keepingKeys.has(k)
const busy = isDeleting || isKeeping || isRemoving
// ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
const modelImageSrc = firstNonEmptyString(
flags?.image,
flags?.imageUrl,
(j as any)?.meta?.modelImage,
(j as any)?.meta?.modelImageUrl
)
// Preview-ID wie in FinishedVideoPreview ableiten (HOT entfernen + ext entfernen)
const fileForPreviewId = stripHotPrefix(baseName(j.output || ''))
const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()
// meta robust lesen (Objekt oder JSON-String)
const meta = parseJobMeta((j as any)?.meta)
// ------------------------------------------------------------
// ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position)
// Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds }
// ------------------------------------------------------------
const spritePathRaw = firstNonEmptyString(
meta?.previewSprite?.path,
(meta as any)?.previewSpritePath,
flags?.previewScrubberPath,
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
// ✅ Fallback-Dauer (meta -> job -> durations cache)
const durationForSprite =
normalizeDurationSeconds(meta?.durationSeconds) ??
normalizeDurationSeconds((j as any)?.durationSeconds) ??
normalizeDurationSeconds(durations[k])
// ✅ Count aus Meta/Flags ODER aus Dauer ableiten
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 ??
flags?.previewScrubberCount ??
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
// ✅ Wenn cols/rows fehlen, aus Count ableiten (wie Backend)
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
// Optionaler Cache-Buster (wenn du sowas in meta hast)
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
// Finales Scrubber-Setup
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
// Sprite-Overlay-Frame (kein Request pro Move)
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
return (
<div key={k} className="relative">
<div
role="button"
tabIndex={0}
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<div
className={[
'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-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
'will-change-[transform,opacity]',
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
busy && !isRemoving && 'pointer-events-none opacity-70',
isDeleting &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping &&
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isRemoving
? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]'
: 'opacity-100 translate-y-0 scale-100',
]
.filter(Boolean)
.join(' ')}
>
{/* Thumb */}
<div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => {
setHoveredThumbKey(k)
onHoverPreviewKeyChange?.(k)
}}
onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev))
onHoverPreviewKeyChange?.(null)
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
>
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: hideTeaserUnderOverlay,
})}
animatedMode="teaser"
animatedTrigger="always"
muted={previewMuted}
popoverMuted={previewMuted}
scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={typeof activeScrubIndex === 'number'}
forceActive={isPreviewActive}
activationNonce={isPreviewActive ? previewActivationNonce : 0}
teaserPreloadEnabled
/>
</div>
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
{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}
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
{showModelPreviewInThumb && modelImageSrc ? (
<div className="absolute inset-0 z-[6]">
<img
src={modelImageSrc}
alt={model ? `${model} preview` : '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}
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
{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 im Video: unten rechts */}
<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 / Meta */}
<div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
{/* ✅ stashapp-like: Dateiname zuerst */}
<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={stripHotPrefix(file) || '—'}
>
{stripHotPrefix(file) || '—'}
</span>
</div>
</div>
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<span
className={[
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400',
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
]
.filter(Boolean)
.join(' ')}
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
onMouseEnter={(e) => {
e.stopPropagation()
if (modelImageSrc) setHoveredModelPreviewKey(k)
}}
onMouseLeave={(e) => {
e.stopPropagation()
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{model || '—'}
</span>
{showPostworkBadge ? (
<span
className={[
'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkBadge.state === 'running'
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
: postworkBadge.state === 'queued'
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
: postworkBadge.state === 'done'
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
].join(' ')}
title={
postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')
}
>
<span className="truncate">
{postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen…'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten…'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')}
</span>
</span>
) : null}
</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{/* Actions (wie CardView: im Footer statt im Video) */}
<div
className="mt-2 shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full">
<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={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={true}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
</div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
{isLoading && rows.length === 0 ? (
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">Lade</div>
</div>
</div>
) : null}
</div>
)
}