nsfwapp/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx
2026-06-25 14:54:58 +02:00

453 lines
14 KiB
TypeScript

// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
'use client'
import { useEffect, useRef, useMemo, useCallback, memo, type ReactNode } from 'react'
import type {
RecordJob,
GalleryModelFlags,
FinishedPostworkSummary,
} from '../../types'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
shouldObserveTeasers,
} from './teaserPlayback'
import FinishedDownloadsGalleryCard from './FinishedDownloadsGalleryCard'
type Props = {
rows: RecordJob[]
selectedKeys: Set<string>
selectionModeActive: boolean
onToggleSelected: (job: RecordJob) => void
onSelectOnly: (job: RecordJob) => void
isLoading?: boolean
blurPreviews?: boolean
durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkSummary>
getPostworkSummaryForOutput: (
output: string | undefined,
summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined
teaserState: FinishedDownloadsTeaserState
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
cardScale?: number
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
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>
deletedSuccessKeys: Set<string>
keptSuccessKeys: Set<string>
keepingKeys: Set<string>
removingKeys: 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, GalleryModelFlags>
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>
onRecommendedPageSizeChange?: (size: number) => void
suspendAutoPageSize?: boolean
forcePreviewMuted?: boolean
mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void
assetNonce?: number
assetNonceForJob?: (job: RecordJob) => number
renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode
}
function snapTo(value: number, step: number, base = 0) {
if (!Number.isFinite(value)) return base
if (!Number.isFinite(step) || step <= 0) return value
const snapped = Math.round((value - base) / step) * step + base
return Number(snapped.toFixed(4))
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function FinishedDownloadsGalleryView({
rows,
isLoading,
blurPreviews,
durations,
postworkByFile,
getPostworkSummaryForOutput,
teaserState,
renderPostworkBadge,
cardScale,
selectedKeys,
selectionModeActive,
onToggleSelected,
onSelectOnly,
handleDuration,
handleResolution,
resolutionLabelOf,
handleScrubberClickIndex,
jobForDetails,
keyFor,
baseName,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
formatBytes,
deletingKeys,
deletedSuccessKeys,
keptSuccessKeys,
keepingKeys,
removingKeys,
registerTeaserHost,
onHoverPreviewKeyChange,
onOpenPlayer,
deleteVideo,
keepVideo,
onToggleHot,
lower,
modelsByKey,
activeTagSet,
onToggleTagFilter,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
onRecommendedPageSizeChange,
forcePreviewMuted,
mobileTeaserAudioUnlocked,
onUnlockMobileTeaserAudio,
assetNonce,
assetNonceForJob,
renderIntegrityBadge,
renderRatingOverlay,
suspendAutoPageSize,
}: Props) {
const observeTeasers = shouldObserveTeasers(teaserState.mode)
const registerTeaserHostIfNeeded = useCallback(
(key: string) => (el: HTMLDivElement | null) => {
if (!observeTeasers) {
registerTeaserHost(key)(null)
return
}
registerTeaserHost(key)(el)
},
[registerTeaserHost, observeTeasers]
)
const effectiveScaleRaw =
typeof cardScale === 'number'
? cardScale
: 1
const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35)
const layoutScale = useMemo(
() => snapTo(effectiveScale, 0.05, 0.8),
[effectiveScale]
)
const sizePreset = useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => {
if (layoutScale >= 1.14) return 'xl'
if (layoutScale >= 0.99) return 'lg'
if (layoutScale >= 0.88) return 'md'
return 'sm'
}, [layoutScale])
const minCardWidth = useMemo(() => {
return Math.round(270 * layoutScale)
}, [layoutScale])
const rootRef = useRef<HTMLDivElement | null>(null)
const lastRecommendedPageSizeRef = useRef<number | null>(null)
const shrinkTimerRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const recommendPageSize = useCallback(() => {
if (suspendAutoPageSize || !onRecommendedPageSizeChange) return
const el = rootRef.current
if (!el) return
const measureAndApply = () => {
const containerWidth = el.clientWidth
if (!Number.isFinite(containerWidth) || containerWidth <= 0) return
const gapPx = 12
const columns = Math.max(
1,
Math.floor((containerWidth + gapPx) / (minCardWidth + gapPx))
)
const grid = el.firstElementChild
const itemEls =
grid instanceof HTMLElement
? Array.from(grid.children).filter((child): child is HTMLElement => child instanceof HTMLElement)
: []
const measuredCardHeight = itemEls.reduce((maxHeight, child) => {
const height = child.getBoundingClientRect().height
return Number.isFinite(height) && height > maxHeight ? height : maxHeight
}, 0)
const estimatedCardWidth =
columns > 0
? (containerWidth - gapPx * (columns - 1)) / columns
: minCardWidth
const fallbackFooterHeight =
sizePreset === 'xl'
? 132
: sizePreset === 'lg'
? 124
: sizePreset === 'md'
? 118
: 110
const cardHeight = Math.max(
1,
measuredCardHeight || Math.round(estimatedCardWidth * 9 / 16 + fallbackFooterHeight + 2)
)
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0
const top = Math.max(0, el.getBoundingClientRect().top)
const bottomReservePx = 112
const availableHeight = Math.max(0, viewportHeight - top - bottomReservePx)
const rowsPerPage = Math.max(
1,
Math.floor((availableHeight + gapPx) / (cardHeight + gapPx))
)
const nextPageSize = clamp(columns * rowsPerPage, columns, 120)
const currentPageSize = lastRecommendedPageSizeRef.current
if (currentPageSize === nextPageSize) return
// Größer werden: sofort übernehmen
if (currentPageSize == null || nextPageSize > currentPageSize) {
if (shrinkTimerRef.current !== null) {
window.clearTimeout(shrinkTimerRef.current)
shrinkTimerRef.current = null
}
lastRecommendedPageSizeRef.current = nextPageSize
onRecommendedPageSizeChange(nextPageSize)
return
}
// Kleiner werden: erst kurz stabilisieren lassen,
// damit kurze Reflows / Scrollbars / Sidebar-Änderungen
// nicht sofort 12 -> 8 zurückdrücken.
if (shrinkTimerRef.current !== null) {
window.clearTimeout(shrinkTimerRef.current)
}
shrinkTimerRef.current = window.setTimeout(() => {
shrinkTimerRef.current = null
const stableWidth = el.clientWidth
if (!Number.isFinite(stableWidth) || stableWidth <= 0) return
const stableColumns = Math.max(
1,
Math.floor((stableWidth + gapPx) / (minCardWidth + gapPx))
)
const stablePageSize = clamp(stableColumns * rowsPerPage, stableColumns, 120)
if (stablePageSize >= (lastRecommendedPageSizeRef.current ?? 0)) {
return
}
lastRecommendedPageSizeRef.current = stablePageSize
onRecommendedPageSizeChange(stablePageSize)
}, 220)
}
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
}
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null
measureAndApply()
})
}, [minCardWidth, onRecommendedPageSizeChange, suspendAutoPageSize])
useEffect(() => {
if (suspendAutoPageSize) return
recommendPageSize()
const el = rootRef.current
if (!el) return
const ro = new ResizeObserver(() => {
recommendPageSize()
})
ro.observe(el)
window.addEventListener('resize', recommendPageSize)
return () => {
ro.disconnect()
window.removeEventListener('resize', recommendPageSize)
if (shrinkTimerRef.current !== null) {
window.clearTimeout(shrinkTimerRef.current)
shrinkTimerRef.current = null
}
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [recommendPageSize, suspendAutoPageSize])
const contentPaddingClass =
sizePreset === 'xl'
? 'px-4 py-3.5'
: sizePreset === 'lg'
? 'px-3.5 py-3'
: sizePreset === 'md'
? 'px-3 py-2.5'
: 'px-2.5 py-2'
const footerMinHeightClass =
sizePreset === 'xl'
? 'min-h-[132px]'
: sizePreset === 'lg'
? 'min-h-[124px]'
: sizePreset === 'md'
? 'min-h-[118px]'
: 'min-h-[110px]'
const titleClass =
sizePreset === 'xl'
? 'text-[15px]'
: sizePreset === 'lg'
? 'text-sm'
: sizePreset === 'md'
? 'text-[13px]'
: 'text-xs'
const sublineClass =
sizePreset === 'xl'
? 'text-[13px]'
: sizePreset === 'lg'
? 'text-xs'
: 'text-[11px]'
return (
<div ref={rootRef} className="relative w-full">
<div
className="grid w-full justify-start gap-3"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`,
}}
>
{rows.map((j) => {
const k = keyFor(j)
const durationForRow = durations[k]
return (
<div
key={k}
className="min-w-0 w-full"
>
<FinishedDownloadsGalleryCard
job={j}
selectedKeys={selectedKeys}
selectionModeActive={selectionModeActive}
rowKey={k}
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
forcePreviewMuted={forcePreviewMuted}
blurPreviews={blurPreviews}
postworkByFile={postworkByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
renderPostworkBadge={renderPostworkBadge}
durationSeconds={durationForRow}
teaserState={teaserState}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lower}
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
deletingKeys={deletingKeys}
deletedSuccessKeys={deletedSuccessKeys}
keptSuccessKeys={keptSuccessKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
registerTeaserHost={registerTeaserHostIfNeeded}
onToggleSelected={onToggleSelected}
onSelectOnly={onSelectOnly}
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
onToggleTagFilter={onToggleTagFilter}
onOpenPlayer={onOpenPlayer}
handleDuration={handleDuration}
handleResolution={handleResolution}
resolutionLabelOf={resolutionLabelOf}
handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={onToggleHot}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
jobForDetails={jobForDetails}
contentPaddingClass={contentPaddingClass}
footerMinHeightClass={footerMinHeightClass}
titleClass={titleClass}
sublineClass={sublineClass}
assetNonce={assetNonceForJob?.(j) ?? assetNonce ?? 0}
renderIntegrityBadge={renderIntegrityBadge}
renderRatingOverlay={renderRatingOverlay}
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
onUnlockMobileTeaserAudio={onUnlockMobileTeaserAudio}
/>
</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>
)
}
const MemoFinishedDownloadsGalleryView = memo(FinishedDownloadsGalleryView)
export default MemoFinishedDownloadsGalleryView