nsfwapp/frontend/src/components/ui/FinishedDownloadsTableView.tsx
2026-06-22 13:57:33 +02:00

724 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend\src\components\ui\FinishedDownloadsTableView.tsx
'use client'
import { useState, useMemo, useCallback, useRef, 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'
import PreviewRatingOverlay from './PreviewRatingOverlay'
import { useFinishedDownloadsAutoTablePageSize } from './useFinishedDownloadsAutoPageSize'
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
type Props = {
rows: RecordJob[]
isLoading?: boolean
selectedKeys: Set<string>
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<string, { w: number; h: number }>
durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkSummary>
getPostworkSummaryForOutput: (
output: string | undefined,
summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined
jobForDetails: (job: RecordJob) => RecordJob
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
// teaser/preview
canHover: boolean
teaserState: FinishedDownloadsTeaserState
setHoverTeaserKey: Dispatch<SetStateAction<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
assetNonceForJob?: (job: RecordJob) => number
// state/flags for actions row + rowClassName
deletingKeys: Set<string>
deletedSuccessKeys: Set<string>
keepingKeys: Set<string>
removingKeys: Set<string>
modelsByKey: Record<string, StoredModelFlags>
activeTagSet: Set<string>
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<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>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
forcePreviewMuted?: boolean
renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode
onRecommendedPageSizeChange?: (size: number) => void
suspendAutoPageSize?: boolean
}
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,
assetNonceForJob,
deletingKeys,
deletedSuccessKeys,
keepingKeys,
removingKeys,
modelsByKey,
activeTagSet,
onToggleTagFilter,
onOpenPlayer,
onSortModeChange,
page,
onPageChange,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
deleteVideo,
keepVideo,
forcePreviewMuted,
renderIntegrityBadge,
renderRatingOverlay,
onRecommendedPageSizeChange,
suspendAutoPageSize,
}: Props) {
const [sort, setSort] = useState<SortState>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
useFinishedDownloadsAutoTablePageSize({
rootRef,
enabled: !suspendAutoPageSize,
fallbackRowHeight: 96,
fallbackHeaderHeight: 48,
onRecommendedPageSizeChange,
})
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<string>()
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<Record<string, number | undefined>>({})
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<Column<RecordJob>[]>(() => {
return [
{
key: 'select',
header: (
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={allSelectedOnPage}
indeterminate={someSelectedOnPage}
ariaLabel={
allSelectedOnPage
? 'Auswahl aufheben'
: 'Alle Downloads auf dieser Seite auswählen'
}
onChange={(checked) => onToggleSelectAllPage(checked)}
/>
</div>
),
widthClassName: 'w-[44px]',
align: 'center',
cell: (j) => {
const checked = selectedKeys.has(keyFor(j))
return (
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
)
},
},
{
key: 'preview',
header: 'Vorschau',
widthClassName: 'w-[140px]',
cell: (j) => {
const k = keyFor(j)
const isDeleting = deletingKeys.has(k)
const isDeleted = deletedSuccessKeys.has(k)
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 rowAssetNonce = assetNonceForJob?.(j) ?? assetNonce ?? 0
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=${rowAssetNonce}`
: ''
return (
<div
ref={registerTeaserHost(k)}
className="group relative py-1"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handlePrimaryAction(j)
}}
onMouseDown={(e) => e.stopPropagation()}
onMouseLeave={() => {
setScrubActiveIndex(k, undefined)
}}
>
<div className="relative w-28 h-16">
<FinishedVideoPreview
job={jobForDetails(j)}
getFileName={baseName}
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
muted={previewMuted}
popoverMuted={previewMuted}
onDuration={handleDuration}
onResolution={handleResolution}
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false}
blur={blurPreviews}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
})}
animatedMode="teaser"
animatedTrigger="always"
assetNonce={rowAssetNonce}
forceActive={isPreviewActive}
teaserPreloadEnabled={false}
/>
<PreviewRatingOverlay>
{renderRatingOverlay ? renderRatingOverlay(j) : null}
</PreviewRatingOverlay>
{spriteInfo && typeof scrubActiveIndex === 'number' && scrubFrameSrc ? (
<img
src={scrubFrameSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[15] h-16 w-28 rounded-md object-cover ring-1 ring-black/5 dark:ring-white/10"
draggable={false}
/>
) : null}
{isDeleting || isDeleted ? (
<DeletingPreviewOverlay done={isDeleted} compact className="rounded-md" />
) : null}
</div>
</div>
)
},
},
{
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 (
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="min-w-0 flex items-center gap-1.5">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{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 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<div className="flex min-h-6 items-center gap-2 min-w-0">
<span className="truncate" title={file}>
{file || '—'}
</span>
{(() => {
const k = keyFor(j)
const res = resolutions[k] ?? null
const label = formatResolution(res)
if (!label) return null
return (
<span
className="shrink-0 rounded-md bg-gray-100 px-1.5 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200
dark:bg-white/5 dark:text-gray-200 dark:ring-white/10"
title={res ? `${res.w}×${res.h}` : 'Auflösung'}
>
{label}
</span>
)
})()}
{isHot ? (
<span className="shrink-0 rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
</div>
{tags.length > 0 ? (
<div className="mt-1 py-0.5 overflow-visible" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={keyFor(j)}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
maxWidthClassName="max-w-[11rem]"
/>
</div>
) : null}
</div>
</div>
)
},
},
{
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 <span className="text-xs text-gray-400"></span>
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 (
<time
dateTime={d.toISOString()}
title={`${date} ${time}`}
className="tabular-nums whitespace-nowrap text-sm text-gray-900 dark:text-white"
>
<span className="font-medium">{date}</span>
<span className="mx-1 text-gray-400 dark:text-gray-600">·</span>
<span className="text-gray-600 dark:text-gray-300">{time}</span>
</time>
)
},
},
{
key: 'runtime',
header: 'Dauer',
align: 'right',
sortable: true,
sortValue: (j) => runtimeSecondsForSort(j),
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{runtimeOf(j)}</span>,
},
{
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) => <span className="font-medium text-gray-900 dark:text-white">{formatBytes(sizeBytesOf(j))}</span>,
},
{
key: 'actions',
header: 'Aktionen',
align: 'right',
srOnlyHeader: true,
cell: (j) => {
const k = keyFor(j)
const busy = deletingKeys.has(k) || deletedSuccessKeys.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 (
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
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', 'training']}
className="flex items-center justify-end gap-1"
/>
)
},
},
]
}, [
keyFor,
baseName,
durations,
postworkByFile,
registerTeaserHost,
canHover,
setHoverTeaserKey,
handleDuration,
handleResolution,
blurPreviews,
teaserState,
assetNonce,
assetNonceForJob,
lower,
modelNameFromOutput,
modelsByKey,
activeTagSet,
onToggleTagFilter,
handlePrimaryAction,
resolutions,
deletingKeys,
deletedSuccessKeys,
keepingKeys,
removingKeys,
previewSpriteInfoOf,
scrubActiveByKey,
setScrubActiveIndex,
onToggleWatch,
onToggleFavorite,
onToggleLike,
onToggleHot,
keepVideo,
deleteVideo,
sizeBytesOf,
formatBytes,
runtimeOf,
runtimeSecondsForSort,
forcePreviewMuted,
renderPostworkBadge,
rows,
selectedKeys,
onToggleSelected,
onToggleSelectAllPage,
allSelectedOnPage,
someSelectedOnPage,
renderIntegrityBadge,
renderRatingOverlay,
])
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',
deletedSuccessKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 pointer-events-none',
(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, deletedSuccessKeys, keepingKeys, removingKeys]
)
return (
<div ref={rootRef} className="relative overflow-x-auto rounded-2xl border border-gray-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<Table
rows={rows}
columns={columns}
getRowKey={(j) => 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 ? (
<div className="absolute inset-0 z-20 grid place-items-center rounded-2xl 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>
)
}