582 lines
19 KiB
TypeScript
582 lines
19 KiB
TypeScript
// 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>
|
||
)
|
||
}
|