// frontend\src\components\ui\FinishedDownloadsTableView.tsx 'use client' import * as React from 'react' import Table, { type Column, type SortState } from './Table' import type { RecordJob } from '../../types' import FinishedVideoPreview from './FinishedVideoPreview' import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' import { isHotName, stripHotPrefix } from './hotName' import { formatResolution } from './formatters' type SortMode = | 'completed_desc' | 'completed_asc' | 'file_asc' | 'file_desc' | 'duration_desc' | 'duration_asc' | 'size_desc' | 'size_asc' type Props = { rows: RecordJob[] isLoading?: boolean // 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 // teaser/preview canHover: boolean teaserAudio?: boolean hoverTeaserKey: string | null setHoverTeaserKey: React.Dispatch> teaserPlayback?: 'still' | 'hover' | 'all' teaserKey: string | null 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: SortMode) => 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 deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise // optional queued actions (bevorzugt verwenden, falls vorhanden) enqueueDeleteVideo?: (job: RecordJob) => boolean enqueueKeepVideo?: (job: RecordJob) => boolean enqueueToggleHot?: (job: RecordJob) => boolean } export default function FinishedDownloadsTableView({ rows, isLoading, keyFor, baseName, lower, modelNameFromOutput, runtimeOf, sizeBytesOf, formatBytes, resolutions, durations, canHover, teaserAudio, hoverTeaserKey, setHoverTeaserKey, teaserPlayback = 'hover', teaserKey, registerTeaserHost, handleDuration, handleResolution, blurPreviews, assetNonce, deletingKeys, keepingKeys, removingKeys, modelsByKey, activeTagSet, onToggleTagFilter, onOpenPlayer, onSortModeChange, page, onPageChange, onToggleHot, onToggleFavorite, onToggleLike, onToggleWatch, onSplit, deleteVideo, keepVideo, enqueueDeleteVideo, enqueueKeepVideo, enqueueToggleHot, }: Props) { const [sort, setSort] = React.useState(null) const runtimeSecondsForSort = React.useCallback((job: RecordJob) => { // 1) Prefer real video duration (ffprobe / backend) const sec = (job as any)?.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 = React.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 = React.useCallback((j: RecordJob): any | null => { const raw: any = (j as any)?.meta if (!raw) return null if (typeof raw === 'string') { try { return JSON.parse(raw) } catch { return null } } return raw }, []) const previewSpriteInfoOf = React.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] = React.useState>({}) const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => { setScrubActiveByKey((prev) => { if (prev[key] === index) return prev return { ...prev, [key]: index } }) }, []) const columns = React.useMemo[]>(() => { return [ { key: 'preview', header: 'Vorschau', widthClassName: 'w-[140px]', cell: (j) => { const k = keyFor(j) const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k const previewMuted = !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.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onMouseEnter={() => { if (canHover) setHoverTeaserKey(k) }} onMouseLeave={() => { if (canHover) setHoverTeaserKey(null) 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 modelKey = lower(modelNameFromOutput(j.output)) const tags = parseTags(modelsByKey[modelKey]?.tags) return (
{model}
{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 modelKey = lower(modelNameFromOutput(j.output)) const flags = modelsByKey[modelKey] const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) return ( { if (enqueueToggleHot) { const accepted = enqueueToggleHot(job) if (accepted) return } return onToggleHot(job) } : undefined } onKeep={async (job) => { if (enqueueKeepVideo) { const accepted = enqueueKeepVideo(job) if (accepted) return true } return keepVideo(job) }} onDelete={async (job) => { if (enqueueDeleteVideo) { const accepted = enqueueDeleteVideo(job) if (accepted) return true } return deleteVideo(job) }} onSplit={onSplit} order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']} className="flex items-center justify-end gap-1" /> ) }, }, ] }, [ keyFor, baseName, durations, teaserAudio, hoverTeaserKey, registerTeaserHost, canHover, setHoverTeaserKey, handleDuration, handleResolution, blurPreviews, teaserPlayback, teaserKey, assetNonce, lower, modelNameFromOutput, modelsByKey, activeTagSet, onToggleTagFilter, resolutions, deletingKeys, keepingKeys, removingKeys, previewSpriteInfoOf, scrubActiveByKey, setScrubActiveIndex, onToggleWatch, onToggleFavorite, onToggleLike, onToggleHot, keepVideo, deleteVideo, sizeBytesOf, formatBytes, runtimeOf, parseTags, runtimeSecondsForSort, ]) const sortStateToMode = React.useCallback((s: SortState): SortMode => { 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 = React.useCallback( (s: SortState) => { setSort(s) // Table Pfeil onSortModeChange(sortStateToMode(s)) if (page !== 1) onPageChange(1) }, [onSortModeChange, sortStateToMode, page, onPageChange] ) const rowClassName = React.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={onOpenPlayer} rowClassName={rowClassName} /> {isLoading && rows.length === 0 ? (
Lade…
) : null}
) }