// 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 teaserKey: string | null inlinePlay: InlinePlayState setInlinePlay: React.Dispatch> deletingKeys: Set keepingKeys: Set removingKeys: Set swipeRefs: React.MutableRefObject> 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 keepVideo: (job: RecordJob) => Promise releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise modelsByKey: Record activeTagSet: Set onToggleTagFilter: (tag: string) => void onToggleHot?: (job: RecordJob) => void | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise } 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() 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 (
{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 = (
openPlayer(j)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) }} > {/* Card shell keeps backgrounds consistent */} {/* Preview */}
onHoverPreviewKeyChange?.(k)} onMouseLeave={isSmall ? undefined : () => onHoverPreviewKeyChange?.(null)} onClick={(e) => { e.preventDefault() e.stopPropagation() if (isSmall) return startInline(k) }} > {/* media */}
{/* Actions top-right (wie Gallery: je nach Größe ausblenden) */}
e.stopPropagation()}>
{/* Restart (wenn inline läuft) */} {!isSmall && inlinePlay?.key === k ? ( ) : null} {/* Bottom overlay (ohne Gradient) */}
{dur} {resLabel ? ( {resLabel} ) : null} {size}
{/* Footer / Meta (wie Gallery strukturiert) */}
{model}
{isHot ? ( HOT ) : null} {stripHotPrefix(fileRaw) || '—'}
{isWatching ? : null} {isLiked ? : null} {isFav ? : null}
{/* Tags */}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
) return isSmall ? ( { 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} ) : ( {cardInner} ) })}
{isLoading && rows.length === 0 ? (
{/* Spinner (zentriert) */}
Lade…
) : null}
) }