// frontend\src\components\ui\FinishedDownloadsTableView.tsx 'use client' import { useState, useMemo, useCallback, type Dispatch, type ReactNode, type SetStateAction } from 'react' import Table, { type Column, type SortState } from './Table' import type { RecordJob, FinishedPostworkSummary, DoneSortMode, StoredModelFlags, } from '../../types' import FinishedVideoPreview from './FinishedVideoPreview' import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' import { isHotName, stripHotPrefix } from './hotName' import { formatResolution } from './formatters' import type { FinishedDownloadsTeaserState } from './teaserPlayback' import { shouldAnimateTeaser, shouldEnableTeaserAudio, } from './teaserPlayback' import Checkbox from './Checkbox' import { parseJobMeta } from './previewSprite' import ModelGenderIcon from './ModelGenderIcon' import { resolveFinishedModelFlags } from './finishedModelFlags' import CountryFlag, { resolveFinishedCountry } from './CountryFlag' type Props = { rows: RecordJob[] isLoading?: boolean selectedKeys: Set selectionModeActive: boolean onToggleSelected: (job: RecordJob) => void onToggleSelectAllPage: (checked: boolean) => void // helpers keyFor: (j: RecordJob) => string baseName: (p: string) => string lower: (s: string) => string modelNameFromOutput: (output?: string) => string runtimeOf: (job: RecordJob) => string sizeBytesOf: (job: RecordJob) => number | null formatBytes: (bytes?: number | null) => string resolutions: Record durations: Record postworkByFile: Record getPostworkSummaryForOutput: ( output: string | undefined, summaries: Record ) => FinishedPostworkSummary | undefined jobForDetails: (job: RecordJob) => RecordJob renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode // teaser/preview canHover: boolean teaserState: FinishedDownloadsTeaserState setHoverTeaserKey: Dispatch> registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void handleDuration: (job: RecordJob, seconds: number) => void handleResolution: (job: RecordJob, w: number, h: number) => void blurPreviews?: boolean assetNonce?: number // state/flags for actions row + rowClassName deletingKeys: Set keepingKeys: Set removingKeys: Set modelsByKey: Record activeTagSet: Set onToggleTagFilter: (tag: string) => void handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void // actions onOpenPlayer: (job: RecordJob) => void onSortModeChange: (m: DoneSortMode) => void page: number onPageChange: (page: number) => void onToggleHot?: (job: RecordJob) => void | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise forcePreviewMuted?: boolean renderIntegrityBadge?: (job: RecordJob) => ReactNode } export default function FinishedDownloadsTableView({ rows, isLoading, selectedKeys, selectionModeActive, onToggleSelected, onToggleSelectAllPage, keyFor, baseName, lower, modelNameFromOutput, runtimeOf, sizeBytesOf, formatBytes, resolutions, durations, postworkByFile, getPostworkSummaryForOutput, jobForDetails, renderPostworkBadge, canHover, teaserState, setHoverTeaserKey, registerTeaserHost, handleDuration, handleResolution, blurPreviews, assetNonce, deletingKeys, keepingKeys, removingKeys, modelsByKey, activeTagSet, onToggleTagFilter, onOpenPlayer, onSortModeChange, page, onPageChange, onToggleHot, onToggleFavorite, onToggleLike, onToggleWatch, onSplit, onAddToDownloads, deleteVideo, keepVideo, forcePreviewMuted, renderIntegrityBadge, }: Props) { const [sort, setSort] = useState(null) const runtimeSecondsForSort = useCallback((job: RecordJob) => { // 1) Prefer real video duration (ffprobe / backend) const sec = (job as any)?.meta?.file?.durationSeconds if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec // 2) Fallback: endedAt-startedAt (only if plausible) const start = Date.parse(String((job as any)?.startedAt || '')) const end = Date.parse(String((job as any)?.endedAt || '')) if (Number.isFinite(start) && Number.isFinite(end) && end > start) { const diffSec = (end - start) / 1000 if (diffSec >= 1 && diffSec <= 24 * 60 * 60) return diffSec } return Number.POSITIVE_INFINITY }, []) const parseTags = useCallback((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() const out: string[] = [] for (const p of parts) { const k = p.toLowerCase() if (seen.has(k)) continue seen.add(k) out.push(p) } out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) return out }, []) const parseMeta = useCallback((j: RecordJob): any | null => { return parseJobMeta((j as any)?.meta) }, []) const previewSpriteInfoOf = useCallback( (j: RecordJob): { count: number; stepSeconds: number } | null => { const meta = parseMeta(j) let ps: any = meta?.previewSprite ?? meta?.preview?.sprite ?? null if (typeof ps === 'string') { try { ps = JSON.parse(ps) } catch { ps = null } } const count = Number(ps?.count) const stepSeconds = Number(ps?.stepSeconds) if (!Number.isFinite(count) || count < 2) return null return { count: Math.max(2, Math.floor(count)), stepSeconds: Number.isFinite(stepSeconds) && stepSeconds > 0 ? stepSeconds : 5, } }, [parseMeta] ) const [scrubActiveByKey, setScrubActiveByKey] = useState>({}) const setScrubActiveIndex = useCallback((key: string, index: number | undefined) => { setScrubActiveByKey((prev) => { if (prev[key] === index) return prev return { ...prev, [key]: index } }) }, []) const allSelectedOnPage = rows.length > 0 && rows.every((j) => selectedKeys.has(keyFor(j))) const someSelectedOnPage = rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage const handlePrimaryAction = useCallback( (job: RecordJob) => { if (selectionModeActive) { onToggleSelected(job) return } onOpenPlayer(job) }, [selectionModeActive, onToggleSelected, onOpenPlayer] ) const columns = useMemo[]>(() => { return [ { key: 'select', header: (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > onToggleSelectAllPage(checked)} />
), widthClassName: 'w-[44px]', align: 'center', cell: (j) => { const checked = selectedKeys.has(keyFor(j)) return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > onToggleSelected(j)} />
) }, }, { key: 'preview', header: 'Vorschau', widthClassName: 'w-[140px]', cell: (j) => { const k = keyFor(j) const isPreviewActive = teaserState.activeKey === k || teaserState.hoverKey === k const allowSound = shouldEnableTeaserAudio({ state: teaserState, itemKey: k, }) const previewMuted = forcePreviewMuted ? true : !allowSound const spriteInfo = previewSpriteInfoOf(j) const scrubActiveIndex = scrubActiveByKey[k] const fileRaw = baseName(j.output || '') const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim() const scrubTimeSec = spriteInfo && typeof scrubActiveIndex === 'number' ? Math.max(0, scrubActiveIndex * spriteInfo.stepSeconds) : undefined const scrubFrameSrc = previewId && typeof scrubTimeSec === 'number' ? `/api/preview?id=${encodeURIComponent(previewId)}&t=${scrubTimeSec.toFixed(3)}&v=${assetNonce ?? 0}` : '' return (
{ e.preventDefault() e.stopPropagation() handlePrimaryAction(j) }} onMouseDown={(e) => e.stopPropagation()} onMouseLeave={() => { setScrubActiveIndex(k, undefined) }} > {/* Scrub-Frame Overlay */} {spriteInfo && typeof scrubActiveIndex === 'number' && scrubFrameSrc ? ( ) : null}
) }, }, { key: 'Model', header: 'Model', sortable: true, sortValue: (j) => { const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) const model = modelNameFromOutput(j.output) const file = stripHotPrefix(fileRaw) return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim() }, cell: (j) => { const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) const file = stripHotPrefix(fileRaw) const model = modelNameFromOutput(j.output) const { flags } = resolveFinishedModelFlags( j, modelsByKey, modelNameFromOutput, lower ) const tags = parseTags(flags?.tags) const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile) const showPostworkBadge = !!postworkBadge const country = resolveFinishedCountry(flags, j) return (
{model}
{(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
{renderIntegrityBadge ? renderIntegrityBadge(j) : null} {showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
) : null}
{file || '—'} {(() => { const k = keyFor(j) const res = resolutions[k] ?? null const label = formatResolution(res) if (!label) return null return ( {label} ) })()} {isHot ? ( HOT ) : null}
{tags.length > 0 ? (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
) : null}
) }, }, { key: 'completedAt', header: 'Fertiggestellt am', sortable: true, widthClassName: 'w-[150px]', sortValue: (j) => { const t = Date.parse(String(j.endedAt || '')) return Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY }, cell: (j) => { const t = Date.parse(String(j.endedAt || '')) if (!Number.isFinite(t)) return const d = new Date(t) const date = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) return ( ) }, }, { key: 'runtime', header: 'Dauer', align: 'right', sortable: true, sortValue: (j) => runtimeSecondsForSort(j), cell: (j) => {runtimeOf(j)}, }, { key: 'size', header: 'Größe', align: 'right', sortable: true, sortValue: (j) => { const s = sizeBytesOf(j) return typeof s === 'number' ? s : Number.NEGATIVE_INFINITY }, cell: (j) => {formatBytes(sizeBytesOf(j))}, }, { key: 'actions', header: 'Aktionen', align: 'right', srOnlyHeader: true, cell: (j) => { const k = keyFor(j) const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) const { flags } = resolveFinishedModelFlags( j, modelsByKey, modelNameFromOutput, lower ) const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) return ( ) }, }, ] }, [ keyFor, baseName, durations, postworkByFile, registerTeaserHost, canHover, setHoverTeaserKey, handleDuration, handleResolution, blurPreviews, teaserState, assetNonce, lower, modelNameFromOutput, modelsByKey, activeTagSet, onToggleTagFilter, handlePrimaryAction, resolutions, deletingKeys, keepingKeys, removingKeys, previewSpriteInfoOf, scrubActiveByKey, setScrubActiveIndex, onToggleWatch, onToggleFavorite, onToggleLike, onToggleHot, keepVideo, deleteVideo, sizeBytesOf, formatBytes, runtimeOf, runtimeSecondsForSort, forcePreviewMuted, renderPostworkBadge, rows, selectedKeys, onToggleSelected, onToggleSelectAllPage, allSelectedOnPage, someSelectedOnPage, renderIntegrityBadge ]) const sortStateToMode = useCallback((s: SortState): DoneSortMode => { if (!s) return 'completed_desc' const anyS = s as any const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '') const dirRaw = String(anyS.dir ?? anyS.direction ?? anyS.order ?? '').toLowerCase() const asc = dirRaw === 'asc' || dirRaw === '1' || dirRaw === 'true' if (key === 'completedAt') return asc ? 'completed_asc' : 'completed_desc' if (key === 'runtime') return asc ? 'duration_asc' : 'duration_desc' if (key === 'size') return asc ? 'size_asc' : 'size_desc' if (key === 'Model') return asc ? 'file_asc' : 'file_desc' if (key === 'video') return asc ? 'file_asc' : 'file_desc' return asc ? 'completed_asc' : 'completed_desc' }, []) const handleSortChange = useCallback( (s: SortState) => { setSort(s) // Table Pfeil onSortModeChange(sortStateToMode(s)) if (page !== 1) onPageChange(1) }, [onSortModeChange, sortStateToMode, page, onPageChange] ) const rowClassName = useCallback( (j: RecordJob) => { const k = keyFor(j) return [ 'transition-all duration-300', (deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none', deletingKeys.has(k) && 'animate-pulse', (keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none', keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse', removingKeys.has(k) && 'opacity-0', ] .filter(Boolean) .join(' ') }, [keyFor, deletingKeys, keepingKeys, removingKeys] ) return (
keyFor(j)} striped fullWidth stickyHeader compact={false} card sort={sort} onSortChange={handleSortChange} onRowClick={handlePrimaryAction} rowAttrs={(j) => ({ 'data-finished-hover-key': keyFor(j), })} rowClassName={rowClassName} /> {isLoading && rows.length === 0 ? (
Lade…
) : null}
) }