397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
// frontend\src\components\ui\FinishedDownloadsCardsView.tsx
|
||
'use client'
|
||
|
||
import * as React from 'react'
|
||
import Card from './Card'
|
||
import type { RecordJob } from '../../types'
|
||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
||
import {
|
||
StarIcon as StarSolidIcon,
|
||
HeartIcon as HeartSolidIcon,
|
||
EyeIcon as EyeSolidIcon,
|
||
} from '@heroicons/react/24/solid'
|
||
import RecordJobActions from './RecordJobActions'
|
||
import TagOverflowRow from './TagOverflowRow'
|
||
import { isHotName, stripHotPrefix } from './hotName'
|
||
import { formatResolution } from './formatters'
|
||
|
||
type InlinePlayState = { key: string; nonce: number } | null
|
||
|
||
type Props = {
|
||
rows: RecordJob[]
|
||
isLoading?: boolean
|
||
isSmall: boolean
|
||
teaserPlayback: 'still' | 'hover' | 'all'
|
||
teaserAudio?: boolean
|
||
hoverTeaserKey?: string | null
|
||
|
||
blurPreviews?: boolean
|
||
durations: Record<string, number>
|
||
teaserKey: string | null
|
||
inlinePlay: InlinePlayState
|
||
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
|
||
|
||
deletingKeys: Set<string>
|
||
keepingKeys: Set<string>
|
||
removingKeys: Set<string>
|
||
|
||
swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>>
|
||
|
||
assetNonce?: number
|
||
|
||
// helpers
|
||
keyFor: (j: RecordJob) => string
|
||
baseName: (p: string) => string
|
||
modelNameFromOutput: (output?: string) => string
|
||
runtimeOf: (job: RecordJob) => string
|
||
sizeBytesOf: (job: RecordJob) => number | null
|
||
formatBytes: (bytes?: number | null) => string
|
||
lower: (s: string) => string
|
||
|
||
// callbacks/actions
|
||
onHoverPreviewKeyChange?: (key: string | null) => void
|
||
onOpenPlayer: (job: RecordJob) => void
|
||
openPlayer: (job: RecordJob) => void
|
||
startInline: (key: string) => void
|
||
tryAutoplayInline: (domId: string) => boolean
|
||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||
|
||
handleDuration: (job: RecordJob, seconds: number) => void
|
||
|
||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||
keepVideo: (job: RecordJob) => Promise<boolean>
|
||
|
||
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
|
||
|
||
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
|
||
activeTagSet: Set<string>
|
||
onToggleTagFilter: (tag: string) => 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>
|
||
}
|
||
|
||
const parseTags = (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)
|
||
}
|
||
return out
|
||
}
|
||
|
||
export default function FinishedDownloadsCardsView({
|
||
rows,
|
||
isSmall,
|
||
isLoading,
|
||
teaserPlayback,
|
||
teaserAudio,
|
||
hoverTeaserKey,
|
||
blurPreviews,
|
||
teaserKey,
|
||
inlinePlay,
|
||
setInlinePlay,
|
||
|
||
deletingKeys,
|
||
keepingKeys,
|
||
removingKeys,
|
||
|
||
swipeRefs,
|
||
assetNonce,
|
||
|
||
keyFor,
|
||
baseName,
|
||
modelNameFromOutput,
|
||
runtimeOf,
|
||
sizeBytesOf,
|
||
formatBytes,
|
||
lower,
|
||
|
||
onHoverPreviewKeyChange,
|
||
onOpenPlayer,
|
||
openPlayer,
|
||
startInline,
|
||
tryAutoplayInline,
|
||
registerTeaserHost,
|
||
|
||
deleteVideo,
|
||
keepVideo,
|
||
|
||
modelsByKey,
|
||
activeTagSet,
|
||
onToggleTagFilter,
|
||
|
||
onToggleHot,
|
||
onToggleFavorite,
|
||
onToggleLike,
|
||
onToggleWatch,
|
||
}: Props) {
|
||
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
|
||
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
|
||
const w =
|
||
(typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) ||
|
||
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
|
||
const h =
|
||
(typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) ||
|
||
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
|
||
|
||
if (w > 0 && h > 0) return { w, h }
|
||
return null
|
||
}, [])
|
||
|
||
const metaChipCls = 'rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium backdrop-blur-[2px]'
|
||
|
||
return (
|
||
<div className="relative">
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{rows.map((j) => {
|
||
const k = keyFor(j)
|
||
const inlineActive = inlinePlay?.key === k
|
||
const allowSound = Boolean(teaserAudio) && (inlineActive || hoverTeaserKey === k)
|
||
const previewMuted = !allowSound
|
||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||
|
||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||
|
||
const model = modelNameFromOutput(j.output)
|
||
const fileRaw = baseName(j.output || '')
|
||
const isHot = isHotName(fileRaw)
|
||
|
||
const flags = modelsByKey[lower(model)]
|
||
const isFav = Boolean(flags?.favorite)
|
||
const isLiked = flags?.liked === true
|
||
const isWatching = Boolean(flags?.watching)
|
||
|
||
const tags = parseTags(flags?.tags)
|
||
|
||
const dur = runtimeOf(j)
|
||
const size = formatBytes(sizeBytesOf(j))
|
||
|
||
const resObj = resolutionObjOf(j)
|
||
const resLabel = formatResolution(resObj)
|
||
|
||
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||
|
||
// ✅ Shell an Gallery angelehnt
|
||
const shellCls = [
|
||
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||
'bg-white dark:bg-gray-900/40',
|
||
'transition-all duration-200',
|
||
!isSmall && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||
busy && 'pointer-events-none opacity-70',
|
||
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||
keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
|
||
const cardInner = (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className={shellCls}
|
||
onClick={isSmall ? undefined : () => openPlayer(j)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||
}}
|
||
>
|
||
{/* Card shell keeps backgrounds consistent */}
|
||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||
{/* Preview */}
|
||
<div
|
||
id={inlineDomId}
|
||
ref={registerTeaserHost(k)}
|
||
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||
onMouseEnter={isSmall ? undefined : () => onHoverPreviewKeyChange?.(k)}
|
||
onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)}
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (isSmall) return
|
||
startInline(k)
|
||
}}
|
||
>
|
||
{/* media */}
|
||
<div className="absolute inset-0">
|
||
<FinishedVideoPreview
|
||
job={j}
|
||
getFileName={baseName}
|
||
className="h-full w-full"
|
||
showPopover={false}
|
||
blur={isSmall ? false : inlineActive ? false : blurPreviews}
|
||
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
|
||
animatedMode="teaser"
|
||
animatedTrigger="always"
|
||
inlineVideo={inlineActive ? 'always' : false}
|
||
inlineNonce={inlineNonce}
|
||
inlineControls={inlineActive}
|
||
inlineLoop={false}
|
||
muted={previewMuted}
|
||
popoverMuted={previewMuted}
|
||
assetNonce={assetNonce ?? 0}
|
||
/>
|
||
</div>
|
||
|
||
{/* Actions top-right (wie Gallery: je nach Größe ausblenden) */}
|
||
<div className="absolute inset-x-2 top-2 z-10 flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||
<RecordJobActions
|
||
job={j}
|
||
variant="overlay"
|
||
busy={busy}
|
||
collapseToMenu
|
||
isHot={isHot}
|
||
isFavorite={isFav}
|
||
isLiked={isLiked}
|
||
isWatching={isWatching}
|
||
onToggleWatch={onToggleWatch}
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleLike={onToggleLike}
|
||
onToggleHot={onToggleHot}
|
||
onKeep={keepVideo}
|
||
onDelete={deleteVideo}
|
||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
|
||
className="w-full justify-end gap-1"
|
||
/>
|
||
</div>
|
||
|
||
{/* Restart (wenn inline läuft) */}
|
||
{!isSmall && inlinePlay?.key === k ? (
|
||
<button
|
||
type="button"
|
||
className="absolute left-2 top-10 z-10 rounded-md bg-black/45 px-2 py-1 text-xs font-semibold text-white hover:bg-black/60"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setInlinePlay((prev) => ({ key: k, nonce: prev?.key === k ? prev.nonce + 1 : 1 }))
|
||
}}
|
||
title="Von vorne starten"
|
||
aria-label="Von vorne starten"
|
||
>
|
||
↻
|
||
</button>
|
||
) : null}
|
||
|
||
{/* Bottom overlay (ohne Gradient) */}
|
||
<div
|
||
className="
|
||
pointer-events-none absolute inset-x-0 bottom-0
|
||
px-2 pb-2 pt-8 text-white
|
||
"
|
||
>
|
||
<div className="flex items-center justify-end gap-2">
|
||
<div className="shrink-0 flex items-center gap-1.5">
|
||
<span className={metaChipCls}>{dur}</span>
|
||
{resLabel ? (
|
||
<span className={metaChipCls} title={resObj ? `${resObj.w}×${resObj.h}` : 'Auflösung'}>
|
||
{resLabel}
|
||
</span>
|
||
) : null}
|
||
<span className={metaChipCls}>{size}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer / Meta (wie Gallery strukturiert) */}
|
||
<div className="relative min-h-[92px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<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 flex items-center gap-2 min-w-0">
|
||
{isHot ? (
|
||
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
|
||
HOT
|
||
</span>
|
||
) : null}
|
||
|
||
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
||
{stripHotPrefix(fileRaw) || '—'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
|
||
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
|
||
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
|
||
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||
<TagOverflowRow
|
||
rowKey={k}
|
||
tags={tags}
|
||
activeTagSet={activeTagSet}
|
||
lower={lower}
|
||
onToggleTagFilter={onToggleTagFilter}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)
|
||
|
||
return isSmall ? (
|
||
<SwipeCard
|
||
ref={(h) => {
|
||
if (h) swipeRefs.current.set(k, h)
|
||
else swipeRefs.current.delete(k)
|
||
}}
|
||
key={k}
|
||
enabled
|
||
disabled={busy}
|
||
ignoreFromBottomPx={110}
|
||
doubleTapMs={360}
|
||
doubleTapMaxMovePx={48}
|
||
onDoubleTap={async () => {
|
||
if (isHot) return
|
||
await onToggleHot?.(j)
|
||
}}
|
||
onTap={() => {
|
||
const domId = `inline-prev-${encodeURIComponent(k)}`
|
||
startInline(k)
|
||
requestAnimationFrame(() => {
|
||
if (!tryAutoplayInline(domId)) requestAnimationFrame(() => tryAutoplayInline(domId))
|
||
})
|
||
}}
|
||
onSwipeLeft={() => deleteVideo(j)}
|
||
onSwipeRight={() => keepVideo(j)}
|
||
>
|
||
{cardInner}
|
||
</SwipeCard>
|
||
) : (
|
||
<React.Fragment key={k}>{cardInner}</React.Fragment>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{isLoading && rows.length === 0 ? (
|
||
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
|
||
{/* Spinner (zentriert) */}
|
||
<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>
|
||
)
|
||
}
|