nsfwapp/frontend/src/components/ui/FinishedDownloadsTableView.tsx
2026-03-16 12:46:38 +01:00

582 lines
19 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 * 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<string, { w: number; h: number }>
durations: Record<string, number>
// teaser/preview
canHover: boolean
teaserAudio?: boolean
hoverTeaserKey: string | null
setHoverTeaserKey: React.Dispatch<React.SetStateAction<string | null>>
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<string>
keepingKeys: Set<string>
removingKeys: Set<string>
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
activeTagSet: Set<string>
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<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>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
// 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<SortState>(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<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 = 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<Record<string, number | undefined>>({})
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<Column<RecordJob>[]>(() => {
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 (
<div
ref={registerTeaserHost(k)}
className="group relative py-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => {
if (canHover) setHoverTeaserKey(k)
}}
onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null)
setScrubActiveIndex(k, undefined)
}}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
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={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
/>
{/* Scrub-Frame Overlay */}
{spriteInfo && typeof scrubActiveIndex === 'number' && scrubFrameSrc ? (
<img
src={scrubFrameSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute left-0 top-1 z-[15] h-16 w-28 rounded-md object-cover ring-1 ring-black/5 dark:ring-white/10"
draggable={false}
/>
) : null}
</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 modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags)
return (
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400">
<div className="flex 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) || 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 (
<RecordJobActions
job={j}
variant="table"
busy={busy}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={
onToggleHot
? async (job) => {
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 (
<div 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={onOpenPlayer}
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>
)
}