665 lines
23 KiB
TypeScript
665 lines
23 KiB
TypeScript
// frontend\src\components\ui\FinishedDownloadsGalleryCard.tsx
|
|
|
|
'use client'
|
|
|
|
import { useState, useMemo, useCallback, useRef, memo, type ReactNode, type SyntheticEvent } from 'react'
|
|
import type {
|
|
RecordJob,
|
|
GalleryModelFlags,
|
|
FinishedPostworkSummary,
|
|
} from '../../types'
|
|
import FinishedVideoPreview from './FinishedVideoPreview'
|
|
import RecordJobActions from './RecordJobActions'
|
|
import TagOverflowRow from './TagOverflowRow'
|
|
import { isHotName, stripHotPrefix } from './hotName'
|
|
import PreviewScrubber from './PreviewScrubber'
|
|
import Checkbox from './Checkbox'
|
|
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
|
import {
|
|
shouldAnimateTeaser,
|
|
shouldEnableTeaserAudio,
|
|
} from './teaserPlayback'
|
|
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
|
|
import {
|
|
buildSpriteFrameStyle,
|
|
parseJobMeta,
|
|
readPreviewSpriteInfo,
|
|
scrubProgressRatioFromIndex,
|
|
} from './previewSprite'
|
|
import ModelGenderIcon from './ModelGenderIcon'
|
|
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
|
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
|
import SpritePreloader from './SpritePreloader'
|
|
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
|
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
|
|
|
function normalizeDurationSeconds(value: unknown): number | undefined {
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
|
return value > 24 * 60 * 60 ? value / 1000 : value
|
|
}
|
|
|
|
type Props = {
|
|
job: RecordJob
|
|
selectedKeys: Set<string>
|
|
selectionModeActive: boolean
|
|
rowKey: string
|
|
isPreviewActive: boolean
|
|
forcePreviewMuted?: boolean
|
|
blurPreviews?: boolean
|
|
|
|
postworkByFile: Record<string, FinishedPostworkSummary>
|
|
getPostworkSummaryForOutput: (
|
|
output: string | undefined,
|
|
summaries: Record<string, FinishedPostworkSummary>
|
|
) => FinishedPostworkSummary | undefined
|
|
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
|
|
|
durationSeconds?: number
|
|
teaserState: FinishedDownloadsTeaserState
|
|
|
|
baseName: (p: string) => string
|
|
modelNameFromOutput: (output?: string) => string
|
|
runtimeOf: (job: RecordJob) => string
|
|
sizeBytesOf: (job: RecordJob) => number | null
|
|
formatBytes: (bytes?: number | null) => string
|
|
lower: (s: string) => string
|
|
|
|
modelsByKey: Record<string, GalleryModelFlags>
|
|
activeTagSet: Set<string>
|
|
|
|
deletingKeys: Set<string>
|
|
keepingKeys: Set<string>
|
|
removingKeys: Set<string>
|
|
|
|
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
|
|
|
onToggleSelected: (job: RecordJob) => void
|
|
onSelectOnly: (job: RecordJob) => void
|
|
onHoverPreviewKeyChange?: (key: string | null) => void
|
|
onToggleTagFilter: (tag: string) => void
|
|
onOpenPlayer: (job: RecordJob) => void
|
|
handleDuration: (job: RecordJob, seconds: number) => void
|
|
handleResolution: (job: RecordJob, w: number, h: number) => void
|
|
resolutionLabelOf: (job: RecordJob) => string
|
|
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
|
onToggleHot: (job: RecordJob) => void | Promise<void>
|
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
|
jobForDetails: (job: RecordJob) => RecordJob
|
|
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
|
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
|
|
|
contentPaddingClass: string
|
|
footerMinHeightClass: string
|
|
titleClass: string
|
|
sublineClass: string
|
|
assetNonce?: number
|
|
|
|
mobileTeaserAudioUnlocked?: boolean
|
|
onUnlockMobileTeaserAudio?: () => void
|
|
}
|
|
|
|
function FinishedDownloadsGalleryCardInner({
|
|
job: j,
|
|
selectedKeys,
|
|
selectionModeActive,
|
|
rowKey,
|
|
isPreviewActive,
|
|
forcePreviewMuted,
|
|
blurPreviews,
|
|
postworkByFile,
|
|
getPostworkSummaryForOutput,
|
|
renderPostworkBadge,
|
|
durationSeconds,
|
|
teaserState,
|
|
baseName,
|
|
modelNameFromOutput,
|
|
runtimeOf,
|
|
sizeBytesOf,
|
|
formatBytes,
|
|
lower,
|
|
modelsByKey,
|
|
activeTagSet,
|
|
deletingKeys,
|
|
keepingKeys,
|
|
removingKeys,
|
|
registerTeaserHost,
|
|
onToggleSelected,
|
|
onHoverPreviewKeyChange,
|
|
onToggleTagFilter,
|
|
onOpenPlayer,
|
|
handleDuration,
|
|
handleResolution,
|
|
resolutionLabelOf,
|
|
handleScrubberClickIndex,
|
|
deleteVideo,
|
|
keepVideo,
|
|
onToggleHot,
|
|
onToggleFavorite,
|
|
onToggleLike,
|
|
onToggleWatch,
|
|
onSplit,
|
|
onAddToDownloads,
|
|
jobForDetails,
|
|
renderIntegrityBadge,
|
|
renderRatingOverlay,
|
|
mobileTeaserAudioUnlocked,
|
|
onUnlockMobileTeaserAudio,
|
|
contentPaddingClass,
|
|
footerMinHeightClass,
|
|
titleClass,
|
|
sublineClass,
|
|
assetNonce
|
|
}: Props) {
|
|
const k = rowKey
|
|
const checked = selectedKeys.has(rowKey)
|
|
const longPressTimerRef = useRef<number | null>(null)
|
|
const longPressTriggeredRef = useRef(false)
|
|
|
|
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
|
|
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
|
|
|
|
const teaserPlayFnsRef = useRef<Record<string, (() => void) | null>>({})
|
|
|
|
const [audioButtonDismissed, setAudioButtonDismissed] = useState(false)
|
|
const [teaserAudioEnabled, setTeaserAudioEnabled] = useState(false)
|
|
|
|
const registerTeaserPlayForKey = useCallback(
|
|
(key: string) => (fn: (() => void) | null) => {
|
|
if (fn) teaserPlayFnsRef.current[key] = fn
|
|
else delete teaserPlayFnsRef.current[key]
|
|
},
|
|
[]
|
|
)
|
|
|
|
const handlePrimaryClick = useCallback(
|
|
(e?: SyntheticEvent) => {
|
|
e?.preventDefault()
|
|
e?.stopPropagation()
|
|
|
|
if (longPressTriggeredRef.current) {
|
|
longPressTriggeredRef.current = false
|
|
return
|
|
}
|
|
|
|
if (selectionModeActive) {
|
|
onToggleSelected(j)
|
|
return
|
|
}
|
|
|
|
onOpenPlayer(j)
|
|
},
|
|
[selectionModeActive, onToggleSelected, onOpenPlayer, j]
|
|
)
|
|
|
|
const clearLongPressTimer = useCallback(() => {
|
|
if (longPressTimerRef.current !== null) {
|
|
window.clearTimeout(longPressTimerRef.current)
|
|
longPressTimerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const startLongPressSelect = useCallback(() => {
|
|
clearLongPressTimer()
|
|
|
|
if (selectionModeActive) return
|
|
|
|
longPressTimerRef.current = window.setTimeout(() => {
|
|
longPressTimerRef.current = null
|
|
longPressTriggeredRef.current = true
|
|
onToggleSelected(j)
|
|
}, 450)
|
|
}, [clearLongPressTimer, selectionModeActive, onToggleSelected, j])
|
|
|
|
const setScrubIndexForKey = 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 = useCallback((key: string) => {
|
|
setScrubIndexForKey(key, undefined)
|
|
}, [setScrubIndexForKey])
|
|
|
|
const allowSound = shouldEnableTeaserAudio({
|
|
state: teaserState,
|
|
itemKey: k,
|
|
})
|
|
const previewMuted = forcePreviewMuted
|
|
? true
|
|
: !(allowSound || mobileTeaserAudioUnlocked || teaserAudioEnabled)
|
|
|
|
const showMobileAudioButton =
|
|
Boolean(onUnlockMobileTeaserAudio) &&
|
|
!mobileTeaserAudioUnlocked &&
|
|
!teaserAudioEnabled &&
|
|
!audioButtonDismissed
|
|
|
|
const model = modelNameFromOutput(j.output)
|
|
|
|
const { flags } = resolveFinishedModelFlags(
|
|
j,
|
|
modelsByKey,
|
|
modelNameFromOutput,
|
|
lower
|
|
)
|
|
|
|
const tags = useMemo(() => {
|
|
const s = String(flags?.tags ?? '').trim()
|
|
if (!s) return [] as string[]
|
|
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 kk = p.toLowerCase()
|
|
if (seen.has(kk)) continue
|
|
seen.add(kk)
|
|
out.push(p)
|
|
}
|
|
return out
|
|
}, [flags?.tags])
|
|
|
|
const isFav = Boolean(flags?.favorite)
|
|
const isLiked = flags?.liked === true
|
|
const isWatching = Boolean(flags?.watching)
|
|
|
|
const country = resolveFinishedCountry(flags, j)
|
|
|
|
const fileRaw = baseName(j.output || '')
|
|
const isHot = isHotName(fileRaw)
|
|
const file = stripHotPrefix(fileRaw)
|
|
|
|
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
|
|
const showPostworkBadge = !!postworkBadge
|
|
|
|
const dur = runtimeOf(j)
|
|
const size = formatBytes(sizeBytesOf(j))
|
|
|
|
const meta = useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta])
|
|
|
|
const resObj = useMemo(() => {
|
|
const w = meta?.media?.video?.width ?? meta?.file?.video?.width
|
|
const h = meta?.media?.video?.height ?? meta?.file?.video?.height
|
|
if (
|
|
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
|
|
typeof h === 'number' && Number.isFinite(h) && h > 0
|
|
) {
|
|
return { w, h }
|
|
}
|
|
return null
|
|
}, [meta])
|
|
|
|
const resLabelRaw = resolutionLabelOf(j)
|
|
const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : ''
|
|
|
|
const isRemoving = removingKeys.has(k)
|
|
const isDeleting = deletingKeys.has(k)
|
|
const isKeeping = keepingKeys.has(k)
|
|
const busy = isDeleting || isKeeping || isRemoving
|
|
|
|
const sprite = useMemo(
|
|
() =>
|
|
readPreviewSpriteInfo((j as any)?.meta, {
|
|
fallbackPath: flags?.previewScrubberPath,
|
|
fallbackCount: flags?.previewScrubberCount,
|
|
versionFallback: assetNonce ?? 0,
|
|
}),
|
|
[(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount, assetNonce]
|
|
)
|
|
|
|
const spriteUrl = sprite.url
|
|
const hasScrubberUi = sprite.hasScrubberUi
|
|
const hasSpriteScrubber = sprite.hasSpriteScrubber
|
|
const scrubberCount = sprite.count
|
|
const scrubberStepSeconds = sprite.stepSeconds
|
|
const hasScrubber = hasScrubberUi
|
|
const shouldPreloadSprite = Boolean(hasSpriteScrubber && spriteUrl)
|
|
|
|
const activeScrubIndex = scrubIndexByKey[k]
|
|
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
|
|
|
|
const spriteFrameStyle = useMemo(
|
|
() =>
|
|
buildSpriteFrameStyle({
|
|
spriteUrl,
|
|
spriteCols: sprite.cols,
|
|
spriteRows: sprite.rows,
|
|
spriteCount: sprite.count,
|
|
activeIndex: activeScrubIndex,
|
|
}),
|
|
[spriteUrl, sprite.cols, sprite.rows, sprite.count, activeScrubIndex]
|
|
)
|
|
|
|
const showScrubberSpriteInThumb = Boolean(spriteFrameStyle)
|
|
|
|
const previewDurationSeconds =
|
|
durationSeconds ??
|
|
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
|
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
|
|
|
const spriteScrubbingActive =
|
|
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
data-finished-hover-key={k}
|
|
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
|
|
onClick={(e) => {
|
|
handlePrimaryClick(e)
|
|
}}
|
|
onPointerDown={(e) => {
|
|
if (e.pointerType === 'mouse') return
|
|
startLongPressSelect()
|
|
}}
|
|
onPointerUp={clearLongPressTimer}
|
|
onPointerCancel={clearLongPressTimer}
|
|
onPointerLeave={clearLongPressTimer}
|
|
onContextMenu={(e) => {
|
|
if (longPressTriggeredRef.current) {
|
|
e.preventDefault()
|
|
}
|
|
}}
|
|
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
|
onMouseLeave={() => {
|
|
clearLongPressTimer()
|
|
onHoverPreviewKeyChange?.(null)
|
|
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
|
clearScrubIndex(k)
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
handlePrimaryClick(e)
|
|
}}
|
|
>
|
|
<div
|
|
className={[
|
|
'relative rounded-lg overflow-visible 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-180 ease-out',
|
|
'will-change-[transform,opacity]',
|
|
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
|
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
|
|
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(' ')}
|
|
>
|
|
<div
|
|
className={[
|
|
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
|
|
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
|
].join(' ')}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<Checkbox
|
|
checked={checked}
|
|
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
|
|
onChange={() => onToggleSelected(j)}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
|
ref={registerTeaserHost(k)}
|
|
onMouseEnter={() => setHoveredThumbKey(k)}
|
|
onMouseLeave={() => {
|
|
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
|
clearScrubIndex(k)
|
|
}}
|
|
>
|
|
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
|
<div className="absolute inset-0">
|
|
<FinishedVideoPreview
|
|
job={j}
|
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
|
durationSeconds={previewDurationSeconds}
|
|
onDuration={handleDuration}
|
|
onResolution={handleResolution}
|
|
variant="fill"
|
|
showPopover={false}
|
|
blur={blurPreviews}
|
|
animated={shouldAnimateTeaser({
|
|
state: teaserState,
|
|
itemKey: k,
|
|
disabled: spriteScrubbingActive,
|
|
})}
|
|
animatedMode="teaser"
|
|
animatedTrigger="always"
|
|
muted={previewMuted}
|
|
popoverMuted={previewMuted}
|
|
assetNonce={assetNonce ?? 0}
|
|
scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio}
|
|
preferScrubProgress={!hasSpriteScrubber && typeof activeScrubIndex === 'number'}
|
|
forceActive={isPreviewActive}
|
|
teaserPreloadEnabled
|
|
registerTeaserPlay={registerTeaserPlayForKey(k)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Desktop/Tablet: wie bisher */}
|
|
<div className="hidden sm:block">
|
|
<PreviewRatingOverlay>
|
|
{renderRatingOverlay ? renderRatingOverlay(j) : null}
|
|
</PreviewRatingOverlay>
|
|
</div>
|
|
|
|
{/* Mobile: Stern unten links */}
|
|
{renderRatingOverlay ? (
|
|
<div
|
|
className="pointer-events-auto absolute bottom-2 left-2 z-[45] sm:hidden"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}}
|
|
onPointerDown={(e) => {
|
|
e.stopPropagation()
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation()
|
|
}}
|
|
>
|
|
{renderRatingOverlay(j)}
|
|
</div>
|
|
) : null}
|
|
|
|
{showMobileAudioButton ? (
|
|
<button
|
|
type="button"
|
|
className="
|
|
absolute top-2 right-2 z-[35] inline-flex items-center gap-1.5 rounded-full
|
|
px-2.5 py-1.5 text-[11px] font-medium backdrop-blur transition sm:hidden
|
|
bg-white/95 text-gray-900 ring-1 ring-gray-200 shadow-sm hover:bg-white
|
|
dark:bg-black/70 dark:text-white dark:ring-white/10 dark:hover:bg-black/80
|
|
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
|
"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
setAudioButtonDismissed(true)
|
|
setTeaserAudioEnabled(true)
|
|
onUnlockMobileTeaserAudio?.()
|
|
|
|
requestAnimationFrame(() => {
|
|
teaserPlayFnsRef.current[k]?.()
|
|
})
|
|
}}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onTouchStart={(e) => e.stopPropagation()}
|
|
aria-label="Teaser mit Ton abspielen"
|
|
title="Ton aktivieren"
|
|
>
|
|
<SpeakerWaveIcon className="size-3.5 shrink-0" />
|
|
<span>Ton aktivieren</span>
|
|
</button>
|
|
) : null}
|
|
|
|
<PreviewMetaOverlay
|
|
dur={dur}
|
|
resObj={resObj}
|
|
resLabel={resLabel}
|
|
size={size}
|
|
/>
|
|
|
|
<SpritePreloader
|
|
src={spriteUrl}
|
|
enabled={shouldPreloadSprite}
|
|
/>
|
|
|
|
{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}
|
|
|
|
{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}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={[
|
|
'relative flex flex-col rounded-b-lg border-t transition-colors',
|
|
checked
|
|
? [
|
|
'border-indigo-200 bg-indigo-50/90',
|
|
'dark:border-indigo-400/30 dark:bg-indigo-500/15',
|
|
].join(' ')
|
|
: [
|
|
'border-black/5 bg-white',
|
|
'dark:border-white/10 dark:bg-gray-900',
|
|
].join(' '),
|
|
footerMinHeightClass,
|
|
contentPaddingClass,
|
|
].join(' ')}
|
|
onClick={(e) => {
|
|
handlePrimaryClick(e)
|
|
}}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="mt-0.5 flex min-h-6 items-center justify-between gap-2 min-w-0">
|
|
<div className="min-w-0 flex items-center gap-1.5">
|
|
<div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}>
|
|
{model}
|
|
</div>
|
|
|
|
<ModelGenderIcon
|
|
gender={flags?.gender}
|
|
className="shrink-0"
|
|
/>
|
|
|
|
<CountryFlag
|
|
country={country}
|
|
size="sm"
|
|
className="shrink-0"
|
|
/>
|
|
</div>
|
|
|
|
{(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
|
|
<div className="relative z-20 shrink-0 flex h-6 items-center gap-2">
|
|
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
|
{showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-0.5 flex min-h-[18px] 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-gray-500 dark:text-gray-400', sublineClass].join(' ')}
|
|
title={stripHotPrefix(file) || '—'}
|
|
>
|
|
{stripHotPrefix(file) || '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<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', 'training']}
|
|
className="w-full gap-1.5"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
)
|
|
}
|
|
|
|
const FinishedDownloadsGalleryCard = memo(FinishedDownloadsGalleryCardInner)
|
|
export default FinishedDownloadsGalleryCard |