nsfwapp/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx

534 lines
18 KiB
TypeScript

// frontend\src\components\ui\FinishedDownloadsGalleryCard.tsx
'use client'
import { useState, useMemo, useCallback, 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 type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
import Checkbox from './Checkbox'
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
}
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,
contentPaddingClass,
footerMinHeightClass,
titleClass,
sublineClass,
assetNonce
}: Props) {
const k = rowKey
const checked = selectedKeys.has(rowKey)
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
const handlePrimaryClick = useCallback(
(e?: SyntheticEvent) => {
e?.preventDefault()
e?.stopPropagation()
if (selectionModeActive) {
onToggleSelected(j)
return
}
onOpenPlayer(j)
},
[selectionModeActive, onToggleSelected, onOpenPlayer, 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
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)
}}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => {
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',
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-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(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
/>
</div>
<PreviewRatingOverlay>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>
<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 border-black/5 bg-white dark:border-white/10 dark:bg-gray-900',
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']}
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