// 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 selectionModeActive: boolean onToggleSelected: (job: RecordJob) => void onSelectOnly: (job: RecordJob) => void isLoading?: boolean blurPreviews?: boolean durations: Record postworkByFile: Record getPostworkSummaryForOutput: ( output: string | undefined, summaries: Record ) => 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 keepingKeys: Set removingKeys: Set registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void onOpenPlayer: (job: RecordJob) => void deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise lower: (s: string) => string modelsByKey: Record activeTagSet: Set onHoverPreviewKeyChange?: (key: string | null) => void onToggleTagFilter: (tag: string) => void onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise onToggleHot: (job: RecordJob) => void | Promise onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise onRecommendedPageSizeChange?: (size: number) => void forcePreviewMuted?: boolean mobileTeaserAudioUnlocked?: boolean onUnlockMobileTeaserAudio?: () => void assetNonce?: 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, keepingKeys, removingKeys, registerTeaserHost, onHoverPreviewKeyChange, onOpenPlayer, deleteVideo, keepVideo, onToggleHot, lower, modelsByKey, activeTagSet, onToggleTagFilter, onToggleFavorite, onToggleLike, onToggleWatch, onSplit, onAddToDownloads, onRecommendedPageSizeChange, forcePreviewMuted, mobileTeaserAudioUnlocked, onUnlockMobileTeaserAudio, assetNonce, renderIntegrityBadge, renderRatingOverlay, }: 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(null) const lastRecommendedPageSizeRef = useRef(null) const recommendPageSize = useCallback(() => { if (!onRecommendedPageSizeChange) return const el = rootRef.current if (!el) return const containerWidth = el.clientWidth if (!Number.isFinite(containerWidth) || containerWidth <= 0) return const gapPx = 12 // entspricht Tailwind gap-3 const columns = Math.max( 1, Math.floor((containerWidth + gapPx) / (minCardWidth + gapPx)) ) // mobil etwas mehr vertikale Füllung, desktop wie bisher ca. 2 Reihen const rowsPerPage = containerWidth < 640 ? 3 : 2 const nextPageSize = clamp(columns * rowsPerPage, rowsPerPage, 24) if (lastRecommendedPageSizeRef.current === nextPageSize) return lastRecommendedPageSizeRef.current = nextPageSize onRecommendedPageSizeChange(nextPageSize) }, [minCardWidth, onRecommendedPageSizeChange]) useEffect(() => { 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) } }, [recommendPageSize]) 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 (
{rows.map((j) => { const k = keyFor(j) const durationForRow = durations[k] return (
) })}
{isLoading && rows.length === 0 ? (
Lade…
) : null}
) } const MemoFinishedDownloadsGalleryView = memo(FinishedDownloadsGalleryView) export default MemoFinishedDownloadsGalleryView