5624 lines
175 KiB
TypeScript
5624 lines
175 KiB
TypeScript
// frontend\src\components\ui\FinishedDownloads.tsx
|
||
|
||
'use client'
|
||
|
||
import { useEffect, useRef, useState, useMemo, useCallback, type MouseEvent, useDeferredValue, type ReactNode } from 'react'
|
||
import Card from './Card'
|
||
import type {
|
||
ViewMode,
|
||
UndoAction,
|
||
PersistedUndoState,
|
||
RunFileMutationOptions,
|
||
RecordJob,
|
||
RecordJobMeta,
|
||
StoredModelFlags,
|
||
FinishedPostworkBadge,
|
||
FinishedPostworkState,
|
||
FinishedPostworkStepSummary,
|
||
FinishedPostworkSummary,
|
||
DoneSortMode,
|
||
} from '../../types'
|
||
import ButtonGroup from './ButtonGroup'
|
||
import {
|
||
ArrowUturnLeftIcon,
|
||
TableCellsIcon,
|
||
RectangleStackIcon,
|
||
Squares2X2Icon,
|
||
AdjustmentsHorizontalIcon,
|
||
CheckCircleIcon,
|
||
ExclamationTriangleIcon,
|
||
ClockIcon,
|
||
CheckIcon,
|
||
XMarkIcon,
|
||
ArchiveBoxIcon,
|
||
TrashIcon,
|
||
ClipboardDocumentIcon,
|
||
} from '@heroicons/react/24/outline'
|
||
import { type SwipeCardHandle } from './SwipeCard'
|
||
import { flushSync } from 'react-dom'
|
||
import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
|
||
import FinishedDownloadsTableView from './FinishedDownloadsTableView'
|
||
import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
|
||
import Pagination from './Pagination'
|
||
import { applyInlineVideoPolicy } from './videoPolicy'
|
||
import Button from './Button'
|
||
import { useNotify } from './notify'
|
||
import { isHotName, stripHotPrefix } from './hotName'
|
||
import LabeledSwitch from './LabeledSwitch'
|
||
import Switch from './Switch'
|
||
import LoadingSpinner from './LoadingSpinner'
|
||
import HoverPopover from './HoverPopover'
|
||
import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback'
|
||
import Slider from './Slider'
|
||
import TextInput from './TextInput'
|
||
import TagFilterBar from './TagFilterBar'
|
||
import { parseTagList, lowerTag } from './tagUtils'
|
||
import {
|
||
createFinishedSelectionStore,
|
||
useSelectionVersion,
|
||
type FinishedSelectionStore,
|
||
} from './finishedSelectionStore'
|
||
import { formatResolution, formatDuration, formatBytes } from './formatters'
|
||
import RatingOverlay from './RatingOverlay'
|
||
|
||
type Props = {
|
||
jobs: RecordJob[]
|
||
doneJobs: RecordJob[]
|
||
modelsByKey: Record<string, StoredModelFlags>
|
||
blurPreviews?: boolean
|
||
teaserPlayback?: TeaserPlaybackMode
|
||
teaserAudio?: boolean
|
||
pauseTeasers?: boolean
|
||
onOpenPlayer: (job: RecordJob, startAtSec?: number) => void
|
||
onDeleteJob?: (
|
||
job: RecordJob
|
||
) =>
|
||
| void
|
||
| { undoToken?: string; from?: string }
|
||
| Promise<void | { undoToken?: string; from?: string }>
|
||
onToggleHot?: (
|
||
job: RecordJob
|
||
) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
|
||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||
onKeepJob?: (
|
||
job: RecordJob
|
||
) => void | { newFile?: string } | Promise<void | { newFile?: string }>
|
||
onSplitJob?: (job: RecordJob) => void | Promise<void>
|
||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||
doneTotal: number
|
||
page: number
|
||
pageSize: number
|
||
onPageSizeChange?: (size: number) => void
|
||
onPageChange: (page: number) => void
|
||
assetNonce?: number
|
||
sortMode: DoneSortMode
|
||
onSortModeChange: (m: DoneSortMode) => void
|
||
loadMode?: 'paged' | 'all'
|
||
includeKeep: boolean
|
||
onIncludeKeepChange: (checked: boolean) => void
|
||
splitProgressByFile?: Record<string, SplitProgressOverlay>
|
||
}
|
||
|
||
const norm = (p: string) => (p || '').replaceAll('\\', '/')
|
||
|
||
const baseName = (p: string) => {
|
||
const n = norm(p)
|
||
const parts = n.split('/')
|
||
return parts[parts.length - 1] || ''
|
||
}
|
||
|
||
const stemWithoutExt = (p: string) => {
|
||
const f = baseName(p || '')
|
||
return stripHotPrefix(f.replace(/\.[^.]+$/, ''))
|
||
}
|
||
|
||
const keyFor = (j: RecordJob) => {
|
||
const id = String((j as any)?.id ?? '').trim()
|
||
const stableFromOutput = stemWithoutExt(j.output || '')
|
||
|
||
// doneJobs aus dem Backend verwenden oft dateinamige IDs.
|
||
// Wenn die ID effektiv nur der Dateiname ist, HOT-normalisieren.
|
||
if (id) {
|
||
const normalizedId = stripHotPrefix(id)
|
||
if (stableFromOutput && normalizedId === stableFromOutput) {
|
||
return stableFromOutput
|
||
}
|
||
return id
|
||
}
|
||
|
||
return stableFromOutput || String((j as any)?.output || '')
|
||
}
|
||
|
||
const isTrashOutput = (output?: string) => {
|
||
const p = norm(String(output ?? ''))
|
||
// match: ".../.trash/file.ext" oder "...\ .trash\file.ext"
|
||
return p.includes('/.trash/') || p.endsWith('/.trash')
|
||
}
|
||
|
||
const GALLERY_CARD_SCALE_PRESETS = [
|
||
{ label: 'Klein', scale: 0.8 },
|
||
{ label: 'Normal', scale: 1.0 },
|
||
{ label: 'XL', scale: 1.2 },
|
||
] as const
|
||
|
||
const DEFAULT_GALLERY_CARD_SCALE_INDEX = 2
|
||
|
||
function clampPresetIndex(value: number) {
|
||
return Math.max(
|
||
0,
|
||
Math.min(GALLERY_CARD_SCALE_PRESETS.length - 1, Math.round(value))
|
||
)
|
||
}
|
||
|
||
function nearestPresetIndex(value: number) {
|
||
let bestIndex = 0
|
||
let bestDist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[0].scale)
|
||
|
||
for (let i = 1; i < GALLERY_CARD_SCALE_PRESETS.length; i++) {
|
||
const dist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[i].scale)
|
||
if (dist < bestDist) {
|
||
bestDist = dist
|
||
bestIndex = i
|
||
}
|
||
}
|
||
|
||
return bestIndex
|
||
}
|
||
|
||
function useMediaQuery(query: string) {
|
||
const [matches, setMatches] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const mql = window.matchMedia(query)
|
||
const onChange = () => setMatches(mql.matches)
|
||
onChange()
|
||
|
||
if (mql.addEventListener) mql.addEventListener('change', onChange)
|
||
else mql.addListener(onChange)
|
||
|
||
return () => {
|
||
if (mql.removeEventListener) mql.removeEventListener('change', onChange)
|
||
else mql.removeListener(onChange)
|
||
}
|
||
}, [query])
|
||
|
||
return matches
|
||
}
|
||
|
||
function modelHrefFromFlags(modelKey: string, flags?: StoredModelFlags): string | undefined {
|
||
const key = String(modelKey || '').trim()
|
||
if (!key) return undefined
|
||
|
||
const anyFlags = flags as any
|
||
const host = String(anyFlags?.host ?? anyFlags?.inputHost ?? '').trim().toLowerCase()
|
||
const input = String(anyFlags?.input ?? '').trim()
|
||
|
||
if (/^https?:\/\//i.test(input)) return input
|
||
|
||
if (host.includes('chaturbate')) {
|
||
return `https://chaturbate.com/${encodeURIComponent(key)}/`
|
||
}
|
||
|
||
if (host.includes('myfreecams')) {
|
||
return `https://www.myfreecams.com/#${encodeURIComponent(key)}`
|
||
}
|
||
|
||
return undefined
|
||
}
|
||
|
||
function enrichJobForDetails(job: RecordJob, modelsByKey: Record<string, StoredModelFlags>): RecordJob {
|
||
const model = modelNameFromOutput(job.output)
|
||
const modelKey = lower(model)
|
||
const flags = modelsByKey[modelKey]
|
||
|
||
const href = modelHrefFromFlags(model, flags)
|
||
|
||
if (job.sourceUrl || !href) return job
|
||
|
||
return {
|
||
...job,
|
||
sourceUrl: href,
|
||
}
|
||
}
|
||
|
||
const reModelFromFilename = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
|
||
|
||
const modelNameFromOutput = (output?: string) => {
|
||
const fileRaw = baseName(output || '')
|
||
const file = stripHotPrefix(fileRaw)
|
||
if (!file) return '—'
|
||
|
||
const stem = file.replace(/\.[^.]+$/, '')
|
||
const m = stem.match(reModelFromFilename)
|
||
|
||
if (m?.[1]?.trim()) {
|
||
return m[1].trim()
|
||
}
|
||
|
||
const i = stem.lastIndexOf('_')
|
||
return i > 0 ? stem.slice(0, i) : stem
|
||
}
|
||
|
||
// Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt)
|
||
const parseTags = parseTagList
|
||
|
||
const lower = lowerTag
|
||
|
||
// liest “irgendein” Size-Feld (falls du eins hast) aus dem Job
|
||
const sizeBytesOf = (job: RecordJob): number | null => {
|
||
const anyJob = job as any
|
||
const v =
|
||
anyJob.sizeBytes ??
|
||
anyJob.fileSizeBytes ??
|
||
anyJob.bytes ??
|
||
anyJob.size ??
|
||
null
|
||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
|
||
}
|
||
|
||
function sleep(ms: number) {
|
||
return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
|
||
}
|
||
|
||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||
const value = String(text ?? '').trim()
|
||
if (!value) return false
|
||
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(value)
|
||
return true
|
||
}
|
||
} catch {
|
||
// fallback below
|
||
}
|
||
|
||
try {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = value
|
||
ta.setAttribute('readonly', '')
|
||
ta.style.position = 'fixed'
|
||
ta.style.opacity = '0'
|
||
ta.style.pointerEvents = 'none'
|
||
document.body.appendChild(ta)
|
||
ta.focus()
|
||
ta.select()
|
||
const ok = document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
return ok
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function CopyErrorButton({ text }: { text: string }) {
|
||
const [copied, setCopied] = useState(false)
|
||
const timeoutRef = useRef<number | null>(null)
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timeoutRef.current != null) {
|
||
window.clearTimeout(timeoutRef.current)
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
const handleClick = async (e: MouseEvent<HTMLButtonElement>) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
|
||
const ok = await copyTextToClipboard(text || 'Unbekannter Fehler')
|
||
if (!ok) return
|
||
|
||
setCopied(true)
|
||
|
||
if (timeoutRef.current != null) {
|
||
window.clearTimeout(timeoutRef.current)
|
||
}
|
||
|
||
timeoutRef.current = window.setTimeout(() => {
|
||
setCopied(false)
|
||
timeoutRef.current = null
|
||
}, 1200)
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center justify-center rounded p-1 text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-white/10"
|
||
title={copied ? 'Kopiert' : 'Fehler kopieren'}
|
||
aria-label={copied ? 'Fehler kopiert' : 'Fehler kopieren'}
|
||
onClick={handleClick}
|
||
>
|
||
{copied ? (
|
||
<CheckIcon className="size-3.5 shrink-0" />
|
||
) : (
|
||
<ClipboardDocumentIcon className="size-3.5 shrink-0" />
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function errorTextOf(err: unknown): string {
|
||
if (err instanceof Error) return err.message || String(err)
|
||
return String(err ?? '')
|
||
}
|
||
|
||
function looksLikeFileInUseError(err: unknown): boolean {
|
||
const s = errorTextOf(err).toLowerCase()
|
||
return (
|
||
s.includes('wird gerade verwendet') ||
|
||
s.includes('wird gerade abgespielt') ||
|
||
s.includes('sharing violation') ||
|
||
s.includes('used by another process') ||
|
||
s.includes('file in use') ||
|
||
s.includes('cannot access the file') ||
|
||
s.includes('access is denied') ||
|
||
s.includes('zugriff verweigert') ||
|
||
s.includes('wird von einem anderen prozess verwendet') ||
|
||
s.includes('409')
|
||
)
|
||
}
|
||
|
||
async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit) {
|
||
const res = await fetch(input, init)
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
const msg = (text || `HTTP ${res.status}`).trim()
|
||
throw new Error(msg)
|
||
}
|
||
return res
|
||
}
|
||
|
||
function fileStemFromOutput(output?: string): string {
|
||
const file = baseName(output || '')
|
||
if (!file) return ''
|
||
return stripHotPrefix(file.replace(/\.[^.]+$/, ''))
|
||
}
|
||
|
||
function getPostworkSummaryForOutput(
|
||
output: string | undefined,
|
||
summaries: Record<string, FinishedPostworkSummary>
|
||
): FinishedPostworkSummary | undefined {
|
||
const fileRaw = baseName(output || '')
|
||
if (!fileRaw) return undefined
|
||
|
||
const filePlain = stripHotPrefix(fileRaw)
|
||
|
||
return summaries[fileRaw] ?? summaries[filePlain] ?? undefined
|
||
}
|
||
|
||
function parseMetaObject(metaRaw: unknown): RecordJobMeta | null {
|
||
if (!metaRaw) return null
|
||
|
||
if (typeof metaRaw === 'string') {
|
||
try {
|
||
return JSON.parse(metaRaw) as RecordJobMeta
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
return typeof metaRaw === 'object' ? (metaRaw as RecordJobMeta) : null
|
||
}
|
||
|
||
function isPlainObject(value: unknown): value is Record<string, any> {
|
||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||
}
|
||
|
||
function mergeMetaPreferDone(
|
||
existingMetaRaw: string | RecordJobMeta | undefined,
|
||
incomingMetaRaw: string | RecordJobMeta | undefined
|
||
): string | RecordJobMeta | undefined {
|
||
const existingMeta = parseMetaObject(existingMetaRaw)
|
||
const incomingMeta = parseMetaObject(incomingMetaRaw)
|
||
|
||
if (!existingMeta && !incomingMeta) return existingMetaRaw ?? incomingMetaRaw
|
||
if (!existingMeta) return incomingMetaRaw
|
||
if (!incomingMeta) return existingMetaRaw
|
||
|
||
const merged: RecordJobMeta = {
|
||
...(incomingMeta as Record<string, any>),
|
||
...(existingMeta as Record<string, any>),
|
||
} as RecordJobMeta
|
||
|
||
const incomingPostwork = isPlainObject(incomingMeta.postwork) ? incomingMeta.postwork : {}
|
||
const existingPostwork = isPlainObject(existingMeta.postwork) ? existingMeta.postwork : {}
|
||
|
||
const incomingCompleted = isPlainObject(incomingPostwork.completed)
|
||
? incomingPostwork.completed
|
||
: {}
|
||
|
||
const existingCompleted = isPlainObject(existingPostwork.completed)
|
||
? existingPostwork.completed
|
||
: {}
|
||
|
||
merged.postwork = {
|
||
...incomingPostwork,
|
||
...existingPostwork,
|
||
completed: {
|
||
...incomingCompleted,
|
||
...existingCompleted,
|
||
},
|
||
history:
|
||
Array.isArray(existingPostwork.history) && existingPostwork.history.length > 0
|
||
? existingPostwork.history
|
||
: Array.isArray(incomingPostwork.history)
|
||
? incomingPostwork.history
|
||
: undefined,
|
||
} as any
|
||
|
||
const incomingPreview = isPlainObject(incomingMeta.preview) ? incomingMeta.preview : {}
|
||
const existingPreview = isPlainObject(existingMeta.preview) ? existingMeta.preview : {}
|
||
|
||
merged.preview = {
|
||
...incomingPreview,
|
||
...existingPreview,
|
||
clips:
|
||
Array.isArray(existingPreview.clips) && existingPreview.clips.length > 0
|
||
? existingPreview.clips
|
||
: Array.isArray(incomingPreview.clips)
|
||
? incomingPreview.clips
|
||
: undefined,
|
||
thumb: {
|
||
...(isPlainObject(incomingPreview.thumb) ? incomingPreview.thumb : {}),
|
||
...(isPlainObject(existingPreview.thumb) ? existingPreview.thumb : {}),
|
||
},
|
||
sprite: {
|
||
...(isPlainObject(incomingPreview.sprite) ? incomingPreview.sprite : {}),
|
||
...(isPlainObject(existingPreview.sprite) ? existingPreview.sprite : {}),
|
||
},
|
||
} as any
|
||
|
||
const incomingMedia = isPlainObject(incomingMeta.media) ? incomingMeta.media : {}
|
||
const existingMedia = isPlainObject(existingMeta.media) ? existingMeta.media : {}
|
||
|
||
merged.media = {
|
||
...incomingMedia,
|
||
...existingMedia,
|
||
durationSeconds:
|
||
typeof existingMedia.durationSeconds === 'number' && existingMedia.durationSeconds > 0
|
||
? existingMedia.durationSeconds
|
||
: typeof incomingMedia.durationSeconds === 'number' && incomingMedia.durationSeconds > 0
|
||
? incomingMedia.durationSeconds
|
||
: undefined,
|
||
video: {
|
||
...(isPlainObject(incomingMedia.video) ? incomingMedia.video : {}),
|
||
...(isPlainObject(existingMedia.video) ? existingMedia.video : {}),
|
||
},
|
||
} as any
|
||
|
||
const incomingAnalysis = isPlainObject(incomingMeta.analysis) ? incomingMeta.analysis : {}
|
||
const existingAnalysis = isPlainObject(existingMeta.analysis) ? existingMeta.analysis : {}
|
||
|
||
const incomingHighlights = isPlainObject(incomingAnalysis.highlights)
|
||
? incomingAnalysis.highlights
|
||
: {}
|
||
|
||
const existingHighlights = isPlainObject(existingAnalysis.highlights)
|
||
? existingAnalysis.highlights
|
||
: {}
|
||
|
||
const incomingAi = isPlainObject(incomingAnalysis.ai)
|
||
? incomingAnalysis.ai
|
||
: {}
|
||
|
||
const existingAi = isPlainObject(existingAnalysis.ai)
|
||
? existingAnalysis.ai
|
||
: {}
|
||
|
||
merged.analysis = {
|
||
...incomingAnalysis,
|
||
...existingAnalysis,
|
||
|
||
highlights:
|
||
Object.keys(existingHighlights).length > 0
|
||
? {
|
||
...incomingHighlights,
|
||
...existingHighlights,
|
||
}
|
||
: Object.keys(incomingHighlights).length > 0
|
||
? incomingHighlights
|
||
: undefined,
|
||
|
||
// Altbestand weiter unterstützen.
|
||
ai:
|
||
Object.keys(existingAi).length > 0
|
||
? {
|
||
...incomingAi,
|
||
...existingAi,
|
||
}
|
||
: Object.keys(incomingAi).length > 0
|
||
? incomingAi
|
||
: undefined,
|
||
} as any
|
||
|
||
return merged
|
||
}
|
||
|
||
function metaDurationSeconds(meta: any): number | undefined {
|
||
const v =
|
||
meta?.media?.durationSeconds ??
|
||
meta?.file?.durationSeconds
|
||
|
||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
|
||
}
|
||
|
||
function postworkBadgeTone(summary?: FinishedPostworkSummary): string {
|
||
const state = String(summary?.state ?? '').toLowerCase()
|
||
const label = String(summary?.label ?? '').toLowerCase()
|
||
const isPartialDone = state === 'done' && label.startsWith('fertig (')
|
||
|
||
if (isPartialDone) {
|
||
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
|
||
}
|
||
|
||
switch (state) {
|
||
case 'running':
|
||
return 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/30'
|
||
case 'queued':
|
||
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
|
||
case 'done':
|
||
return 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30'
|
||
case 'error':
|
||
return 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30'
|
||
default:
|
||
return 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
|
||
}
|
||
}
|
||
|
||
type SplitProgressOverlay = {
|
||
phase: 'preparing' | 'splitting' | 'done' | 'error'
|
||
total: number
|
||
current: number
|
||
completed: number
|
||
segmentProgress?: number
|
||
overallProgress?: number
|
||
message?: string
|
||
segment?: {
|
||
index: number
|
||
label: string
|
||
start: number
|
||
end: number
|
||
duration: number
|
||
}
|
||
startedAtMs?: number
|
||
sourceFile?: string
|
||
}
|
||
|
||
type DownloadIntegrityBadgeInfo = {
|
||
label: string
|
||
detail: string
|
||
blackTailFrames?: number
|
||
sampleCount?: number
|
||
}
|
||
|
||
function matchesCorruptOnlyFilter(metaRaw: unknown, enabled: boolean): boolean {
|
||
if (!enabled) return true
|
||
return readDownloadIntegrityInfo(metaRaw).state === 'corrupt'
|
||
}
|
||
|
||
function readDownloadIntegrityState(metaRaw: unknown): 'corrupt' | 'ok' | 'unchecked' {
|
||
const meta = parseMetaObject(metaRaw) as any
|
||
const node = meta?.validation?.tailBlackout
|
||
|
||
if (!node || typeof node !== 'object') return 'unchecked'
|
||
if (node?.isCorrupt === true) return 'corrupt'
|
||
return 'ok'
|
||
}
|
||
|
||
type DownloadIntegrityState = 'corrupt' | 'ok' | 'unchecked'
|
||
|
||
function readDownloadIntegrityInfo(metaRaw: unknown): {
|
||
state: DownloadIntegrityState
|
||
checked: boolean
|
||
node?: Record<string, any>
|
||
} {
|
||
const meta = parseMetaObject(metaRaw) as any
|
||
const node = meta?.validation?.tailBlackout
|
||
|
||
if (!node || typeof node !== 'object') {
|
||
return {
|
||
state: 'unchecked',
|
||
checked: false,
|
||
}
|
||
}
|
||
|
||
return {
|
||
state: node?.isCorrupt === true ? 'corrupt' : 'ok',
|
||
checked: true,
|
||
node,
|
||
}
|
||
}
|
||
|
||
function readDownloadIntegrityBadge(metaRaw: unknown): DownloadIntegrityBadgeInfo | null {
|
||
const info = readDownloadIntegrityInfo(metaRaw)
|
||
const node = info.node
|
||
|
||
if (!info.checked || info.state !== 'corrupt' || !node) return null
|
||
|
||
const blackTailFrames =
|
||
typeof node?.blackTailFrames === 'number' && Number.isFinite(node.blackTailFrames)
|
||
? node.blackTailFrames
|
||
: undefined
|
||
|
||
const sampleCount =
|
||
typeof node?.sampleCount === 'number' && Number.isFinite(node.sampleCount)
|
||
? node.sampleCount
|
||
: Array.isArray(node?.samples)
|
||
? node.samples.length
|
||
: undefined
|
||
|
||
const reason = String(node?.reason ?? '').trim()
|
||
|
||
const fallbackDetail =
|
||
typeof blackTailFrames === 'number' &&
|
||
typeof sampleCount === 'number' &&
|
||
sampleCount > 0
|
||
? `${blackTailFrames}/${sampleCount} Frames am Ende sind fast komplett schwarz`
|
||
: 'Mehrere Frames am Ende sind fast komplett schwarz'
|
||
|
||
return {
|
||
label: 'Fehlerhaft',
|
||
detail: reason || fallbackDetail,
|
||
blackTailFrames,
|
||
sampleCount,
|
||
}
|
||
}
|
||
|
||
function renderDownloadIntegrityBadge(metaRaw: unknown): ReactNode {
|
||
const info = readDownloadIntegrityBadge(metaRaw)
|
||
if (!info) return null
|
||
|
||
const badgeNode = (
|
||
<span
|
||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30"
|
||
title={info.detail}
|
||
>
|
||
<ExclamationTriangleIcon className="size-3.5 shrink-0" />
|
||
<span className="truncate">{info.label}</span>
|
||
</span>
|
||
)
|
||
|
||
return (
|
||
<HoverPopover
|
||
content={(open) =>
|
||
open && (
|
||
<div className="w-[280px] rounded-lg">
|
||
<div className="px-3 py-2 border-b border-gray-200 dark:border-white/10">
|
||
<div className="text-xs font-semibold text-gray-900 dark:text-white">
|
||
Download-Prüfung
|
||
</div>
|
||
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
|
||
Verdacht auf beschädigtes Videoende
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-3 py-2 text-[12px] text-gray-800 dark:text-gray-200">
|
||
{info.detail}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
>
|
||
{badgeNode}
|
||
</HoverPopover>
|
||
)
|
||
}
|
||
|
||
function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string {
|
||
const p = String(phase ?? '').trim().toLowerCase()
|
||
|
||
switch (`${queue}:${p}`) {
|
||
case 'postwork:meta':
|
||
return 'Meta'
|
||
case 'postwork:thumb':
|
||
return 'Vorschaubild'
|
||
case 'postwork:teaser':
|
||
return 'Teaser'
|
||
case 'postwork:sprites':
|
||
return 'Sprites'
|
||
case 'enrich:analyze':
|
||
return 'Analyse'
|
||
default:
|
||
return p || (queue === 'enrich' ? 'Analyse' : 'Meta')
|
||
}
|
||
}
|
||
|
||
function shortRunningLabel(summary?: FinishedPostworkSummary): string {
|
||
if (!summary) return 'Läuft'
|
||
|
||
const runningStep = summary.steps.find((s) => s.state === 'running')
|
||
if (runningStep) return runningStep.label
|
||
|
||
const queuedStep = summary.steps.find((s) => s.state === 'queued')
|
||
if (queuedStep) return queuedStep.label
|
||
|
||
return 'Läuft'
|
||
}
|
||
|
||
function expectedStepsForBadges(): Array<{ key: string; queue: 'postwork' | 'enrich'; phase: string }> {
|
||
return defaultPostworkStepDefs({
|
||
includeAnalyze: true,
|
||
})
|
||
}
|
||
|
||
function defaultPostworkStepDefs(opts?: { includeAnalyze?: boolean }): Array<{
|
||
key: string
|
||
queue: 'postwork' | 'enrich'
|
||
phase: string
|
||
}> {
|
||
const includeAnalyze = opts?.includeAnalyze ?? true
|
||
|
||
const steps: Array<{
|
||
key: string
|
||
queue: 'postwork' | 'enrich'
|
||
phase: string
|
||
}> = [
|
||
{ key: 'postwork:meta', queue: 'postwork', phase: 'meta' },
|
||
{ key: 'postwork:thumb', queue: 'postwork', phase: 'thumb' },
|
||
{ key: 'postwork:teaser', queue: 'postwork', phase: 'teaser' },
|
||
{ key: 'postwork:sprites', queue: 'postwork', phase: 'sprites' },
|
||
]
|
||
|
||
if (includeAnalyze) {
|
||
steps.push({ key: 'enrich:analyze', queue: 'enrich', phase: 'analyze' })
|
||
}
|
||
|
||
return steps
|
||
}
|
||
|
||
function buildMissingPostworkSummary(
|
||
file: string,
|
||
assetId?: string,
|
||
label = 'Kein Status'
|
||
): FinishedPostworkSummary | null {
|
||
if (!file) return null
|
||
|
||
const steps: FinishedPostworkStepSummary[] = defaultPostworkStepDefs({
|
||
includeAnalyze: true,
|
||
}).map((stepDef) => ({
|
||
key: stepDef.key,
|
||
queue: stepDef.queue,
|
||
phase: stepDef.phase,
|
||
label: labelForStep(stepDef.queue, stepDef.phase),
|
||
state: 'missing',
|
||
ts: 0,
|
||
}))
|
||
|
||
return {
|
||
file,
|
||
assetId,
|
||
state: 'missing',
|
||
label,
|
||
steps,
|
||
ts: 0,
|
||
}
|
||
}
|
||
|
||
function hasNonEmptyObject(value: unknown): boolean {
|
||
return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length > 0
|
||
}
|
||
|
||
function firstNonEmptyPath(...values: unknown[]): string {
|
||
for (const v of values) {
|
||
if (typeof v === 'string') {
|
||
const s = v.trim()
|
||
if (s) return s
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function readMetaVideoWidth(meta: any): number | undefined {
|
||
const v =
|
||
meta?.media?.video?.width ??
|
||
meta?.media?.video?.w ??
|
||
meta?.media?.width ??
|
||
meta?.file?.video?.width ??
|
||
meta?.file?.video?.w ??
|
||
meta?.file?.width ??
|
||
meta?.video?.width ??
|
||
meta?.video?.w ??
|
||
meta?.width
|
||
|
||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
|
||
}
|
||
|
||
function readMetaVideoHeight(meta: any): number | undefined {
|
||
const v =
|
||
meta?.media?.video?.height ??
|
||
meta?.media?.video?.h ??
|
||
meta?.media?.height ??
|
||
meta?.file?.video?.height ??
|
||
meta?.file?.video?.h ??
|
||
meta?.file?.height ??
|
||
meta?.video?.height ??
|
||
meta?.video?.h ??
|
||
meta?.height
|
||
|
||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
|
||
}
|
||
|
||
function readMetaDurationSeconds(meta: any): number | undefined {
|
||
const v =
|
||
meta?.media?.durationSeconds ??
|
||
meta?.media?.durationSec ??
|
||
meta?.media?.duration ??
|
||
meta?.file?.durationSeconds ??
|
||
meta?.file?.durationSec ??
|
||
meta?.file?.duration ??
|
||
meta?.durationSeconds ??
|
||
meta?.durationSec ??
|
||
meta?.duration
|
||
|
||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
|
||
}
|
||
|
||
function readCompletedFlag(meta: any, ...keys: string[]): boolean {
|
||
const completed = meta?.postwork?.completed
|
||
|
||
for (const key of keys) {
|
||
const v =
|
||
completed?.[key] ??
|
||
meta?.postwork?.[key] ??
|
||
meta?.preview?.[key] ??
|
||
meta?.[key]
|
||
|
||
if (v === true) return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function hasDoneHistoryForPhase(
|
||
meta: any,
|
||
queue: 'postwork' | 'enrich',
|
||
phase: string
|
||
): boolean {
|
||
const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : []
|
||
|
||
return rawHistory.some((entry: any) => {
|
||
const queueRaw = String(entry?.queue ?? '').trim().toLowerCase()
|
||
const phaseRaw = normalizeFinishedPhase(
|
||
queueRaw === 'enrich' ? 'enrich' : 'postwork',
|
||
String(entry?.phase ?? '').trim().toLowerCase()
|
||
)
|
||
const stateRaw = String(entry?.state ?? '').trim().toLowerCase()
|
||
|
||
return queueRaw === queue && phaseRaw === phase && stateRaw === 'done'
|
||
})
|
||
}
|
||
|
||
function hasNonEmptyArray(value: unknown): boolean {
|
||
return Array.isArray(value) && value.length > 0
|
||
}
|
||
|
||
function buildCompletedPostworkSummaryFromMeta(
|
||
file: string,
|
||
assetId: string | undefined,
|
||
meta: any
|
||
): FinishedPostworkSummary | null {
|
||
if (!file || !meta || typeof meta !== 'object') return null
|
||
|
||
const durationSeconds = readMetaDurationSeconds(meta)
|
||
const videoWidth = readMetaVideoWidth(meta)
|
||
const videoHeight = readMetaVideoHeight(meta)
|
||
|
||
const thumbPath = firstNonEmptyPath(
|
||
meta?.preview?.thumb?.path,
|
||
meta?.preview?.thumb?.url,
|
||
meta?.preview?.thumb?.src,
|
||
meta?.preview?.thumbPath,
|
||
meta?.previewThumb?.path,
|
||
meta?.previewThumb?.url,
|
||
meta?.previewThumbPath,
|
||
meta?.thumbPath,
|
||
meta?.thumbUrl,
|
||
meta?.coverPath,
|
||
meta?.coverUrl
|
||
)
|
||
|
||
const spritePath = firstNonEmptyPath(
|
||
meta?.preview?.sprite?.path,
|
||
meta?.preview?.sprite?.url,
|
||
meta?.preview?.sprite?.src,
|
||
meta?.preview?.spritePath,
|
||
meta?.previewSprite?.path,
|
||
meta?.previewSprite?.url,
|
||
meta?.previewSpritePath,
|
||
meta?.spritePath,
|
||
meta?.spriteUrl
|
||
)
|
||
|
||
const clips =
|
||
Array.isArray(meta?.preview?.clips) ? meta.preview.clips
|
||
: Array.isArray(meta?.preview?.teaserClips) ? meta.preview.teaserClips
|
||
: Array.isArray(meta?.preview?.segments) ? meta.preview.segments
|
||
: Array.isArray(meta?.preview?.teaser?.clips) ? meta.preview.teaser.clips
|
||
: Array.isArray(meta?.previewClips) ? meta.previewClips
|
||
: Array.isArray(meta?.teaserClips) ? meta.teaserClips
|
||
: []
|
||
|
||
const teaserPath = firstNonEmptyPath(
|
||
meta?.preview?.teaser?.path,
|
||
meta?.preview?.teaser?.url,
|
||
meta?.preview?.teaser?.src,
|
||
meta?.preview?.teaserPath,
|
||
meta?.previewTeaser?.path,
|
||
meta?.previewTeaser?.url,
|
||
meta?.previewTeaserPath,
|
||
meta?.teaserPath,
|
||
meta?.teaserUrl
|
||
)
|
||
|
||
const aiObject =
|
||
meta?.analysis?.highlights ??
|
||
meta?.analysis?.ai ??
|
||
meta?.ai
|
||
|
||
const hasAiHits =
|
||
Array.isArray(aiObject?.hits) && aiObject.hits.length > 0
|
||
|
||
const hasAiSegments =
|
||
Array.isArray(aiObject?.segments) && aiObject.segments.length > 0
|
||
|
||
const hasMeta =
|
||
readCompletedFlag(meta, 'meta') ||
|
||
hasDoneHistoryForPhase(meta, 'postwork', 'meta') ||
|
||
typeof durationSeconds === 'number' ||
|
||
(typeof videoWidth === 'number' && typeof videoHeight === 'number') ||
|
||
(typeof meta?.media?.video?.resolution === 'string' && meta.media.video.resolution.trim() !== '') ||
|
||
(typeof meta?.file?.video?.resolution === 'string' && meta.file.video.resolution.trim() !== '')
|
||
|
||
const hasTeaser =
|
||
readCompletedFlag(meta, 'teaser', 'assets') ||
|
||
hasDoneHistoryForPhase(meta, 'postwork', 'teaser') ||
|
||
teaserPath !== '' ||
|
||
hasNonEmptyArray(clips)
|
||
|
||
const hasThumb =
|
||
readCompletedFlag(meta, 'thumb', 'thumbnail', 'thumbnails') ||
|
||
hasDoneHistoryForPhase(meta, 'postwork', 'thumb') ||
|
||
meta?.preview?.thumb?.exists === true ||
|
||
thumbPath !== ''
|
||
|
||
const hasSprites =
|
||
readCompletedFlag(meta, 'sprites', 'sprite') ||
|
||
hasDoneHistoryForPhase(meta, 'postwork', 'sprites') ||
|
||
meta?.preview?.sprite?.exists === true ||
|
||
spritePath !== '' ||
|
||
(
|
||
typeof meta?.preview?.sprite?.count === 'number' &&
|
||
Number.isFinite(meta.preview.sprite.count) &&
|
||
meta.preview.sprite.count > 0
|
||
)
|
||
|
||
const hasAiAnalysis =
|
||
readCompletedFlag(meta, 'analyze', 'analysis', 'ai', 'highlights') ||
|
||
hasDoneHistoryForPhase(meta, 'enrich', 'analyze') ||
|
||
hasNonEmptyObject(aiObject) ||
|
||
hasAiHits ||
|
||
hasAiSegments
|
||
|
||
const stepDefs = defaultPostworkStepDefs({
|
||
includeAnalyze: true,
|
||
})
|
||
|
||
const steps: FinishedPostworkStepSummary[] = stepDefs.map((stepDef) => {
|
||
let state: FinishedPostworkState = 'missing'
|
||
|
||
if (stepDef.key === 'postwork:meta') {
|
||
state = hasMeta ? 'done' : 'missing'
|
||
} else if (stepDef.key === 'postwork:thumb') {
|
||
state = hasThumb ? 'done' : 'missing'
|
||
} else if (stepDef.key === 'postwork:teaser') {
|
||
state = hasTeaser ? 'done' : 'missing'
|
||
} else if (stepDef.key === 'postwork:sprites') {
|
||
state = hasSprites ? 'done' : 'missing'
|
||
} else if (stepDef.key === 'enrich:analyze') {
|
||
state = hasAiAnalysis ? 'done' : 'missing'
|
||
}
|
||
|
||
return {
|
||
key: stepDef.key,
|
||
queue: stepDef.queue,
|
||
phase: stepDef.phase,
|
||
label: labelForStep(stepDef.queue, stepDef.phase),
|
||
state,
|
||
ts: Number(meta?.updatedAtUnix ?? 0),
|
||
}
|
||
})
|
||
|
||
const doneCount = steps.filter((s) => s.state === 'done').length
|
||
const allDone = steps.length > 0 && steps.every((s) => s.state === 'done')
|
||
|
||
return {
|
||
file,
|
||
assetId,
|
||
state: allDone ? 'done' : doneCount > 0 ? 'done' : 'missing',
|
||
label: allDone
|
||
? 'Fertig'
|
||
: doneCount > 0
|
||
? `Fertig (${doneCount}/${steps.length})`
|
||
: 'Kein Status',
|
||
steps,
|
||
ts: Number(meta?.updatedAtUnix ?? 0),
|
||
}
|
||
}
|
||
|
||
function buildPostworkSummaryFromMetaHistory(
|
||
file: string,
|
||
assetId: string | undefined,
|
||
meta: any
|
||
): FinishedPostworkSummary | null {
|
||
if (!file || !meta || typeof meta !== 'object') return null
|
||
|
||
const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : []
|
||
if (rawHistory.length === 0) return null
|
||
|
||
const badges: FinishedPostworkBadge[] = rawHistory
|
||
.map((entry: any): FinishedPostworkBadge | null => {
|
||
const queueRaw = String(entry?.queue ?? '').trim().toLowerCase()
|
||
const phase = normalizeFinishedPhase(
|
||
(queueRaw === 'enrich' ? 'enrich' : 'postwork'),
|
||
String(entry?.phase ?? '').trim().toLowerCase()
|
||
)
|
||
const state = String(entry?.state ?? '').trim().toLowerCase()
|
||
|
||
if ((queueRaw !== 'postwork' && queueRaw !== 'enrich') || !phase) return null
|
||
if (
|
||
state !== 'queued' &&
|
||
state !== 'running' &&
|
||
state !== 'done' &&
|
||
state !== 'error' &&
|
||
state !== 'missing'
|
||
) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
file,
|
||
assetId,
|
||
queue: queueRaw as 'postwork' | 'enrich',
|
||
phase,
|
||
state: state as FinishedPostworkBadge['state'],
|
||
label: typeof entry?.label === 'string' ? entry.label : undefined,
|
||
position: typeof entry?.position === 'number' ? entry.position : undefined,
|
||
waiting: typeof entry?.waiting === 'number' ? entry.waiting : undefined,
|
||
running: typeof entry?.running === 'number' ? entry.running : undefined,
|
||
maxParallel: typeof entry?.maxParallel === 'number' ? entry.maxParallel : undefined,
|
||
ts: typeof entry?.ts === 'number' ? entry.ts : 0,
|
||
}
|
||
})
|
||
.filter(Boolean) as FinishedPostworkBadge[]
|
||
|
||
if (badges.length === 0) return null
|
||
return summarizePostworkSteps(file, badges)
|
||
}
|
||
|
||
function normalizeFinishedPhase(
|
||
queue: 'postwork' | 'enrich',
|
||
phase?: string
|
||
): string {
|
||
const p = String(phase ?? '').trim().toLowerCase()
|
||
|
||
if (queue === 'enrich') {
|
||
switch (p) {
|
||
case '':
|
||
case 'assets':
|
||
case 'analyze':
|
||
return 'analyze'
|
||
default:
|
||
return p
|
||
}
|
||
}
|
||
|
||
switch (p) {
|
||
case 'postwork':
|
||
case 'probe':
|
||
case 'remuxing':
|
||
case 'moving':
|
||
return 'meta'
|
||
case 'thumb':
|
||
return 'thumb'
|
||
case 'assets':
|
||
return 'teaser'
|
||
case 'sprites':
|
||
return 'sprites'
|
||
default:
|
||
return p || 'meta'
|
||
}
|
||
}
|
||
|
||
function summarizePostworkSteps(
|
||
file: string,
|
||
badges: FinishedPostworkBadge[]
|
||
): FinishedPostworkSummary | null {
|
||
if (!file) return null
|
||
|
||
const realBadges = badges.filter((b) => b && b.state !== 'missing')
|
||
if (realBadges.length === 0) return null
|
||
|
||
const expectedSteps = expectedStepsForBadges()
|
||
const byKey = new Map<string, FinishedPostworkStepSummary>()
|
||
|
||
for (const badge of realBadges) {
|
||
const queue = badge.queue
|
||
const rawPhase = String(badge.phase ?? '').trim().toLowerCase()
|
||
const phase = normalizeFinishedPhase(queue, rawPhase)
|
||
const key = `${queue}:${phase}`
|
||
const badgeLabel = String(badge.label ?? '').trim()
|
||
|
||
const normalizedBadgeLabel = normalizeAnalysisProgressLabel(badgeLabel) ?? badgeLabel
|
||
|
||
const candidate: FinishedPostworkStepSummary = {
|
||
key,
|
||
queue,
|
||
phase,
|
||
label: normalizedBadgeLabel || labelForStep(queue, phase),
|
||
state: badge.state,
|
||
error: String(badge.error ?? '').trim() || undefined,
|
||
ts: badge.ts,
|
||
position: badge.position,
|
||
waiting: badge.waiting,
|
||
running: badge.running,
|
||
maxParallel: badge.maxParallel,
|
||
}
|
||
|
||
const prev = byKey.get(key)
|
||
|
||
if (prev) {
|
||
if ((prev.ts || 0) > (candidate.ts || 0)) {
|
||
byKey.set(key, preferProgressStepLabel(prev, candidate))
|
||
} else {
|
||
byKey.set(key, preferProgressStepLabel(candidate, prev))
|
||
}
|
||
continue
|
||
}
|
||
|
||
byKey.set(key, candidate)
|
||
}
|
||
|
||
// ✅ Erst alle erwarteten Schritte anlegen
|
||
for (const stepDef of expectedSteps) {
|
||
if (byKey.has(stepDef.key)) continue
|
||
|
||
byKey.set(stepDef.key, {
|
||
key: stepDef.key,
|
||
queue: stepDef.queue,
|
||
phase: stepDef.phase,
|
||
label: labelForStep(stepDef.queue, stepDef.phase),
|
||
state: 'missing',
|
||
ts: 0,
|
||
})
|
||
}
|
||
|
||
const order = expectedSteps.map((s) => s.key)
|
||
|
||
const steps = Array.from(byKey.values()).sort((a, b) => {
|
||
const ai = order.indexOf(a.key)
|
||
const bi = order.indexOf(b.key)
|
||
|
||
if (ai >= 0 && bi >= 0) return ai - bi
|
||
if (ai >= 0) return -1
|
||
if (bi >= 0) return 1
|
||
return a.key.localeCompare(b.key)
|
||
})
|
||
|
||
if (steps.length === 0) return null
|
||
|
||
const visibleSteps = steps.filter((s) => s.state !== 'missing')
|
||
|
||
const hasError = visibleSteps.some((s) => s.state === 'error')
|
||
const hasRunning = visibleSteps.some((s) => s.state === 'running')
|
||
const hasQueued = visibleSteps.some((s) => s.state === 'queued')
|
||
|
||
const doneCount = steps.filter((s) => s.state === 'done').length
|
||
const allDone = steps.length > 0 && steps.every((s) => s.state === 'done')
|
||
const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError
|
||
|
||
const state: FinishedPostworkSummary['state'] =
|
||
hasError ? 'error'
|
||
: hasRunning ? 'running'
|
||
: hasQueued ? 'queued'
|
||
: allDone ? 'done'
|
||
: partiallyDone ? 'done'
|
||
: 'missing'
|
||
|
||
const label =
|
||
hasError
|
||
? 'Fehler'
|
||
: hasRunning
|
||
? shortRunningLabel({ file, steps, state: 'running', label: '', ts: 0 })
|
||
: hasQueued
|
||
? shortRunningLabel({ file, steps, state: 'queued', label: '', ts: 0 })
|
||
: allDone
|
||
? 'Fertig'
|
||
: partiallyDone
|
||
? `Fertig (${doneCount}/${steps.length})`
|
||
: 'Kein Status'
|
||
|
||
const ts = Math.max(0, ...steps.map((s) => s.ts || 0))
|
||
|
||
return {
|
||
file,
|
||
assetId: realBadges.find((b) => b.assetId)?.assetId,
|
||
state,
|
||
label,
|
||
steps,
|
||
ts,
|
||
}
|
||
}
|
||
|
||
function mergePostworkSummaries(
|
||
historySummary: FinishedPostworkSummary | null | undefined,
|
||
completedSummary: FinishedPostworkSummary | null | undefined,
|
||
fallbackFile: string,
|
||
fallbackAssetId?: string
|
||
): FinishedPostworkSummary | null {
|
||
if (!historySummary && !completedSummary) return null
|
||
if (!historySummary) return completedSummary ?? null
|
||
if (!completedSummary) return historySummary
|
||
|
||
const stepMap = new Map<string, FinishedPostworkStepSummary>()
|
||
|
||
for (const step of completedSummary.steps) {
|
||
stepMap.set(step.key, step)
|
||
}
|
||
|
||
for (const step of historySummary.steps) {
|
||
const prev = stepMap.get(step.key)
|
||
|
||
if (!prev) {
|
||
stepMap.set(step.key, step)
|
||
continue
|
||
}
|
||
|
||
// Live-/History-Zustände mit Aktivität/Fehler sollen sichtbar bleiben
|
||
if (
|
||
step.state === 'running' ||
|
||
step.state === 'queued' ||
|
||
step.state === 'error'
|
||
) {
|
||
if (!prev) {
|
||
stepMap.set(step.key, step)
|
||
continue
|
||
}
|
||
|
||
if ((step.ts || 0) >= (prev.ts || 0)) {
|
||
stepMap.set(step.key, preferProgressStepLabel(step, prev))
|
||
continue
|
||
}
|
||
|
||
// Auch wenn der ältere Step vom Polling kommt:
|
||
// Ein vorhandenes Analyse-Prozentlabel darf nicht durch "Analyse" verloren gehen.
|
||
stepMap.set(step.key, preferProgressStepLabel(prev, step))
|
||
continue
|
||
}
|
||
|
||
// "done" aus History ist ok, aber nur wenn Completed nicht schon auch done liefert.
|
||
// Wenn Completed mehr Informationen hat, füllt es die restlichen Steps ohnehin.
|
||
if (step.state === 'done' && prev.state === 'missing') {
|
||
stepMap.set(step.key, step)
|
||
continue
|
||
}
|
||
|
||
// missing aus History darf nie ein echtes Completed-Ergebnis überschreiben
|
||
}
|
||
|
||
const orderedSteps = defaultPostworkStepDefs({ includeAnalyze: true }).map((def) => {
|
||
return (
|
||
stepMap.get(def.key) ?? {
|
||
key: def.key,
|
||
queue: def.queue,
|
||
phase: def.phase,
|
||
label: labelForStep(def.queue, def.phase),
|
||
state: 'missing' as FinishedPostworkState,
|
||
ts: 0,
|
||
}
|
||
)
|
||
})
|
||
|
||
const visibleSteps = orderedSteps.filter((s) => s.state !== 'missing')
|
||
const hasError = visibleSteps.some((s) => s.state === 'error')
|
||
const hasRunning = visibleSteps.some((s) => s.state === 'running')
|
||
const hasQueued = visibleSteps.some((s) => s.state === 'queued')
|
||
|
||
const doneCount = orderedSteps.filter((s) => s.state === 'done').length
|
||
const allDone = orderedSteps.length > 0 && orderedSteps.every((s) => s.state === 'done')
|
||
const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError
|
||
|
||
const state: FinishedPostworkSummary['state'] =
|
||
hasError ? 'error'
|
||
: hasRunning ? 'running'
|
||
: hasQueued ? 'queued'
|
||
: allDone ? 'done'
|
||
: partiallyDone ? 'done'
|
||
: 'missing'
|
||
|
||
const label =
|
||
hasError
|
||
? 'Fehler'
|
||
: hasRunning
|
||
? shortRunningLabel({
|
||
file: historySummary.file || completedSummary.file || fallbackFile,
|
||
steps: orderedSteps,
|
||
state: 'running',
|
||
label: '',
|
||
ts: 0,
|
||
})
|
||
: hasQueued
|
||
? shortRunningLabel({
|
||
file: historySummary.file || completedSummary.file || fallbackFile,
|
||
steps: orderedSteps,
|
||
state: 'queued',
|
||
label: '',
|
||
ts: 0,
|
||
})
|
||
: allDone
|
||
? 'Fertig'
|
||
: partiallyDone
|
||
? `Fertig (${doneCount}/${orderedSteps.length})`
|
||
: 'Kein Status'
|
||
|
||
const ts = Math.max(
|
||
historySummary.ts || 0,
|
||
completedSummary.ts || 0,
|
||
...orderedSteps.map((s) => s.ts || 0)
|
||
)
|
||
|
||
return {
|
||
file: historySummary.file || completedSummary.file || fallbackFile,
|
||
assetId: historySummary.assetId || completedSummary.assetId || fallbackAssetId,
|
||
state,
|
||
label,
|
||
steps: orderedSteps,
|
||
ts,
|
||
}
|
||
}
|
||
|
||
function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge): boolean {
|
||
return (
|
||
a.file === b.file &&
|
||
a.assetId === b.assetId &&
|
||
a.queue === b.queue &&
|
||
a.phase === b.phase &&
|
||
a.state === b.state &&
|
||
a.label === b.label &&
|
||
a.error === b.error &&
|
||
a.position === b.position &&
|
||
a.waiting === b.waiting &&
|
||
a.running === b.running &&
|
||
a.maxParallel === b.maxParallel
|
||
)
|
||
}
|
||
|
||
function analysisProgressPercent(label?: string): number | null {
|
||
const s = String(label ?? '').trim()
|
||
|
||
const percentMatch = s.match(/^analyse\s+(\d{1,3})%$/i)
|
||
if (percentMatch) {
|
||
const n = Number(percentMatch[1])
|
||
if (!Number.isFinite(n)) return null
|
||
return Math.max(0, Math.min(100, Math.round(n)))
|
||
}
|
||
|
||
const framesMatch = s.match(/^analyse\s+(\d+)\s*\/\s*(\d+)\s*frames?$/i)
|
||
if (framesMatch) {
|
||
const current = Number(framesMatch[1])
|
||
const total = Number(framesMatch[2])
|
||
|
||
if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0) {
|
||
return null
|
||
}
|
||
|
||
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function postworkStepDisplayLabel(step: FinishedPostworkStepSummary): string {
|
||
if (isAnalyzeStep(step)) {
|
||
return 'Analyse'
|
||
}
|
||
|
||
return step.label
|
||
}
|
||
|
||
function runningStepStateLabel(step: FinishedPostworkStepSummary): string {
|
||
if (isAnalyzeStep(step)) {
|
||
const percent = analysisProgressPercent(step.label)
|
||
if (percent != null) return `${percent}%`
|
||
}
|
||
|
||
return 'Läuft'
|
||
}
|
||
|
||
function normalizeAnalysisProgressLabel(label?: string): string | undefined {
|
||
const percent = analysisProgressPercent(label)
|
||
if (percent == null) return label
|
||
return `Analyse ${percent}%`
|
||
}
|
||
|
||
function isAnalyzeBadge(badge?: Pick<FinishedPostworkBadge, 'queue' | 'phase'> | null): boolean {
|
||
return (
|
||
badge?.queue === 'enrich' &&
|
||
normalizeFinishedPhase('enrich', badge.phase) === 'analyze'
|
||
)
|
||
}
|
||
|
||
function isAnalyzeStep(step?: Pick<FinishedPostworkStepSummary, 'queue' | 'phase'> | null): boolean {
|
||
return (
|
||
step?.queue === 'enrich' &&
|
||
normalizeFinishedPhase('enrich', step.phase) === 'analyze'
|
||
)
|
||
}
|
||
|
||
function preferProgressLabel(
|
||
incoming: FinishedPostworkBadge,
|
||
previous?: FinishedPostworkBadge
|
||
): FinishedPostworkBadge {
|
||
if (!isAnalyzeBadge(incoming) || incoming.state !== 'running') {
|
||
return incoming
|
||
}
|
||
|
||
const normalizedIncoming: FinishedPostworkBadge = {
|
||
...incoming,
|
||
label: normalizeAnalysisProgressLabel(incoming.label),
|
||
}
|
||
|
||
if (!previous || previous.state !== 'running' || !isAnalyzeBadge(previous)) {
|
||
return normalizedIncoming
|
||
}
|
||
|
||
const incomingPercent = analysisProgressPercent(normalizedIncoming.label)
|
||
const previousPercent = analysisProgressPercent(previous.label)
|
||
|
||
if (previousPercent != null && incomingPercent == null) {
|
||
return {
|
||
...normalizedIncoming,
|
||
label: previous.label,
|
||
}
|
||
}
|
||
|
||
if (
|
||
previousPercent != null &&
|
||
incomingPercent != null &&
|
||
incomingPercent < previousPercent
|
||
) {
|
||
return {
|
||
...normalizedIncoming,
|
||
label: previous.label,
|
||
}
|
||
}
|
||
|
||
return normalizedIncoming
|
||
}
|
||
|
||
function preferProgressStepLabel(
|
||
incoming: FinishedPostworkStepSummary,
|
||
previous?: FinishedPostworkStepSummary
|
||
): FinishedPostworkStepSummary {
|
||
if (!isAnalyzeStep(incoming) || incoming.state !== 'running') {
|
||
return incoming
|
||
}
|
||
|
||
const normalizedIncoming: FinishedPostworkStepSummary = {
|
||
...incoming,
|
||
label: normalizeAnalysisProgressLabel(incoming.label) ?? incoming.label,
|
||
}
|
||
|
||
if (!previous || previous.state !== 'running' || !isAnalyzeStep(previous)) {
|
||
return normalizedIncoming
|
||
}
|
||
|
||
const incomingPercent = analysisProgressPercent(normalizedIncoming.label)
|
||
const previousPercent = analysisProgressPercent(previous.label)
|
||
|
||
if (previousPercent != null && incomingPercent == null) {
|
||
return {
|
||
...normalizedIncoming,
|
||
label: previous.label,
|
||
}
|
||
}
|
||
|
||
if (
|
||
previousPercent != null &&
|
||
incomingPercent != null &&
|
||
incomingPercent < previousPercent
|
||
) {
|
||
return {
|
||
...normalizedIncoming,
|
||
label: previous.label,
|
||
}
|
||
}
|
||
|
||
return normalizedIncoming
|
||
}
|
||
|
||
function samePostworkBadgeList(
|
||
a?: FinishedPostworkBadge[],
|
||
b?: FinishedPostworkBadge[]
|
||
): boolean {
|
||
const aa = Array.isArray(a) ? a : []
|
||
const bb = Array.isArray(b) ? b : []
|
||
|
||
if (aa.length !== bb.length) return false
|
||
|
||
for (let i = 0; i < aa.length; i++) {
|
||
if (!samePostworkBadge(aa[i], bb[i])) return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
function samePostworkBadgeMap(
|
||
a?: Record<string, FinishedPostworkBadge[]>,
|
||
b?: Record<string, FinishedPostworkBadge[]>
|
||
): boolean {
|
||
const aa = a ?? {}
|
||
const bb = b ?? {}
|
||
|
||
const aKeys = Object.keys(aa)
|
||
const bKeys = Object.keys(bb)
|
||
|
||
if (aKeys.length !== bKeys.length) return false
|
||
|
||
for (const key of aKeys) {
|
||
if (!(key in bb)) return false
|
||
if (!samePostworkBadgeList(aa[key], bb[key])) return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
function postworkSummaryTitle(summary: FinishedPostworkSummary): string {
|
||
switch (summary.state) {
|
||
case 'running':
|
||
return 'Postwork läuft'
|
||
case 'queued':
|
||
return 'Postwork wartet'
|
||
case 'done':
|
||
return 'Postwork fertig'
|
||
case 'error':
|
||
return 'Postwork Fehler'
|
||
case 'missing':
|
||
return 'Postwork fehlt'
|
||
default:
|
||
return 'Postwork-Status'
|
||
}
|
||
}
|
||
|
||
function postworkSummaryIcon(summary: FinishedPostworkSummary): ReactNode {
|
||
const iconClass = 'size-5 shrink-0'
|
||
|
||
switch (summary.state) {
|
||
case 'running':
|
||
return <LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
|
||
case 'queued':
|
||
return <ClockIcon className={`${iconClass} text-amber-600 dark:text-amber-300`} />
|
||
case 'done':
|
||
return <CheckCircleIcon className={`${iconClass} text-emerald-600 dark:text-emerald-300`} />
|
||
case 'error':
|
||
return <ExclamationTriangleIcon className={`${iconClass} text-red-600 dark:text-red-300`} />
|
||
case 'missing':
|
||
return <span className="size-5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
|
||
default:
|
||
return <span className="size-5 shrink-0" />
|
||
}
|
||
}
|
||
|
||
function postworkDoneCount(summary: FinishedPostworkSummary): number {
|
||
return summary.steps.filter((step) => step.state === 'done').length
|
||
}
|
||
|
||
function postworkStepProgressValue(step: FinishedPostworkStepSummary): number {
|
||
if (step.state === 'done') return 1
|
||
|
||
if (step.state === 'running' && isAnalyzeStep(step)) {
|
||
const percent = analysisProgressPercent(step.label)
|
||
if (percent != null) return percent / 100
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
function postworkProgressPercent(summary: FinishedPostworkSummary): number {
|
||
const total = summary.steps.length
|
||
if (total <= 0) return 0
|
||
|
||
const value = summary.steps.reduce(
|
||
(sum, step) => sum + postworkStepProgressValue(step),
|
||
0
|
||
)
|
||
|
||
return Math.max(0, Math.min(100, Math.round((value / total) * 100)))
|
||
}
|
||
|
||
function postworkStepStateLabel(step: FinishedPostworkStepSummary): string {
|
||
if (step.state === 'running') return runningStepStateLabel(step)
|
||
|
||
if (step.state === 'queued') {
|
||
return typeof step.position === 'number' && step.position > 0
|
||
? `Wartet #${step.position}`
|
||
: 'Wartet'
|
||
}
|
||
|
||
if (step.state === 'done') return 'Fertig'
|
||
if (step.state === 'error') return 'Fehler'
|
||
if (step.state === 'missing') return 'Offen'
|
||
|
||
return '—'
|
||
}
|
||
|
||
function postworkStepTone(step: FinishedPostworkStepSummary): string {
|
||
switch (step.state) {
|
||
case 'running':
|
||
return 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/30'
|
||
case 'queued':
|
||
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
|
||
case 'done':
|
||
return 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30'
|
||
case 'error':
|
||
return 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30'
|
||
default:
|
||
return 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10'
|
||
}
|
||
}
|
||
|
||
function postworkStepIcon(step: FinishedPostworkStepSummary): ReactNode {
|
||
if (step.state === 'running') {
|
||
return <LoadingSpinner size="sm" className="shrink-0" srLabel={step.label} />
|
||
}
|
||
|
||
if (step.state === 'queued') {
|
||
return <ClockIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||
}
|
||
|
||
if (step.state === 'done') {
|
||
return <CheckCircleIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
|
||
}
|
||
|
||
if (step.state === 'error') {
|
||
return <ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
|
||
}
|
||
|
||
return <span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
|
||
}
|
||
|
||
function postworkPreviewSrc(summary: FinishedPostworkSummary): string | null {
|
||
const id = String(summary.assetId || fileStemFromOutput(summary.file)).trim()
|
||
if (!id) return null
|
||
|
||
const base = `/api/preview?id=${encodeURIComponent(id)}&file=preview.jpg`
|
||
const version = Number.isFinite(summary.ts) && summary.ts > 0 ? String(summary.ts) : ''
|
||
|
||
return version ? `${base}&v=${encodeURIComponent(version)}` : base
|
||
}
|
||
|
||
function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
|
||
const done = postworkDoneCount(summary)
|
||
const total = summary.steps.length
|
||
const progress = postworkProgressPercent(summary)
|
||
const previewSrc = postworkPreviewSrc(summary)
|
||
|
||
return (
|
||
<div className="w-[320px] overflow-hidden rounded-[inherit] bg-white dark:bg-gray-950">
|
||
<div className="relative overflow-hidden border-b border-gray-200/60 bg-gray-950 px-3 py-3 text-white dark:border-white/10">
|
||
{previewSrc ? (
|
||
<img
|
||
src={previewSrc}
|
||
alt=""
|
||
aria-hidden="true"
|
||
referrerPolicy="no-referrer"
|
||
className="absolute inset-0 h-full w-full object-cover blur-[1px]"
|
||
onError={(e) => {
|
||
e.currentTarget.style.display = 'none'
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
<div className="absolute inset-0 bg-gradient-to-br from-black/75 via-black/45 to-black/75" />
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||
|
||
<div className="relative flex items-center gap-2.5">
|
||
<div className="grid size-8 shrink-0 place-items-center rounded-xl bg-white/90 shadow-sm ring-1 ring-white/40 backdrop-blur dark:bg-gray-950/75 dark:ring-white/15">
|
||
{postworkSummaryIcon(summary)}
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="min-w-0 truncate text-[13px] font-semibold text-white">
|
||
{postworkSummaryTitle(summary)}
|
||
</div>
|
||
|
||
<span
|
||
className={[
|
||
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold ring-1 backdrop-blur',
|
||
postworkBadgeTone(summary),
|
||
].join(' ')}
|
||
>
|
||
{summary.label}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-0.5 truncate text-[10px] text-white/70" title={summary.file}>
|
||
{summary.file}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative mt-2.5 flex items-center gap-2">
|
||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/25">
|
||
<div
|
||
className={[
|
||
'h-full rounded-full transition-all duration-300',
|
||
summary.state === 'error'
|
||
? 'bg-red-400'
|
||
: summary.state === 'queued'
|
||
? 'bg-amber-400'
|
||
: summary.state === 'running'
|
||
? 'bg-indigo-400'
|
||
: 'bg-emerald-400',
|
||
].join(' ')}
|
||
style={{ width: `${progress}%` }}
|
||
/>
|
||
</div>
|
||
|
||
<span className="shrink-0 text-[10px] tabular-nums text-white/75">
|
||
{done}/{total}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-2.5 py-2.5">
|
||
<div className="space-y-1">
|
||
{summary.steps.map((step) => (
|
||
<div
|
||
key={step.key}
|
||
className={[
|
||
'grid grid-cols-[minmax(0,1fr)_20px_auto] items-center gap-1.5 rounded-lg px-2 py-1.5',
|
||
'bg-gray-50/80 ring-1 ring-gray-200/70 dark:bg-white/[0.03] dark:ring-white/10',
|
||
].join(' ')}
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[11px] font-medium text-gray-900 dark:text-gray-100">
|
||
{postworkStepDisplayLabel(step)}
|
||
</div>
|
||
|
||
{step.state === 'error' && step.error ? (
|
||
<div className="mt-0.5 truncate text-[11px] text-red-600 dark:text-red-300" title={step.error}>
|
||
{step.error}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex size-5 items-center justify-center">
|
||
{postworkStepIcon(step)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
<span
|
||
className={[
|
||
'inline-flex min-w-[54px] justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ring-1',
|
||
postworkStepTone(step),
|
||
].join(' ')}
|
||
title={step.state === 'error' ? step.error || 'Unbekannter Fehler' : undefined}
|
||
>
|
||
{postworkStepStateLabel(step)}
|
||
</span>
|
||
|
||
{step.state === 'error' && step.error ? (
|
||
<CopyErrorButton text={step.error} />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function renderFinishedPostworkBadge(
|
||
summary?: FinishedPostworkSummary,
|
||
opts?: {
|
||
onClick?: () => void
|
||
clickable?: boolean
|
||
disablePopover?: boolean
|
||
}
|
||
): ReactNode {
|
||
if (!summary) return null
|
||
|
||
const clickable = Boolean(opts?.clickable && opts?.onClick)
|
||
|
||
const className = [
|
||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1',
|
||
postworkBadgeTone(summary),
|
||
clickable ? 'cursor-pointer transition-opacity hover:opacity-80' : '',
|
||
].join(' ')
|
||
|
||
const badgeNode = clickable ? (
|
||
<button
|
||
type="button"
|
||
className={className}
|
||
title={summary.label}
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
opts?.onClick?.()
|
||
}}
|
||
>
|
||
{summary.state === 'running' ? (
|
||
<LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
|
||
) : summary.state === 'queued' ? (
|
||
<ClockIcon className="size-3.5 shrink-0" />
|
||
) : summary.state === 'done' ? (
|
||
<CheckCircleIcon className="size-3.5 shrink-0" />
|
||
) : summary.state === 'error' ? (
|
||
<ExclamationTriangleIcon className="size-3.5 shrink-0" />
|
||
) : summary.state === 'missing' ? (
|
||
<span className="size-3.5 shrink-0 rounded-full border border-current opacity-70" />
|
||
) : null}
|
||
|
||
<span className="truncate">{summary.label}</span>
|
||
</button>
|
||
) : (
|
||
<span className={className} title={summary.label}>
|
||
{summary.state === 'running' ? (
|
||
<LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
|
||
) : summary.state === 'queued' ? (
|
||
<ClockIcon className="size-3.5 shrink-0" />
|
||
) : summary.state === 'done' ? (
|
||
<CheckCircleIcon className="size-3.5 shrink-0" />
|
||
) : summary.state === 'error' ? (
|
||
<ExclamationTriangleIcon className="size-3.5 shrink-0" />
|
||
) : summary.state === 'missing' ? (
|
||
<span className="size-3.5 shrink-0 rounded-full border border-current opacity-70" />
|
||
) : null}
|
||
|
||
<span className="truncate">{summary.label}</span>
|
||
</span>
|
||
)
|
||
|
||
if (opts?.disablePopover) {
|
||
return badgeNode
|
||
}
|
||
|
||
return (
|
||
<HoverPopover
|
||
content={(open) => open && renderPostworkPopover(summary)}
|
||
>
|
||
{badgeNode}
|
||
</HoverPopover>
|
||
)
|
||
}
|
||
|
||
function buildSplitProgressSummary(
|
||
file: string,
|
||
progress?: SplitProgressOverlay
|
||
): FinishedPostworkSummary | null {
|
||
if (!file || !progress) return null
|
||
|
||
const rawOverall =
|
||
typeof progress.overallProgress === 'number'
|
||
? progress.overallProgress
|
||
: progress.phase === 'done'
|
||
? 1
|
||
: 0
|
||
|
||
const overall = Math.max(0, Math.min(1, rawOverall))
|
||
const percent = Math.round(overall * 100)
|
||
|
||
const state: FinishedPostworkState =
|
||
progress.phase === 'error'
|
||
? 'error'
|
||
: progress.phase === 'done'
|
||
? 'done'
|
||
: 'running'
|
||
|
||
const label =
|
||
progress.phase === 'error'
|
||
? 'Split Fehler'
|
||
: progress.phase === 'done'
|
||
? 'Split fertig'
|
||
: `Split ${percent}%`
|
||
|
||
const stepLabel =
|
||
progress.segment?.label?.trim() ||
|
||
progress.message?.trim() ||
|
||
'Video wird gesplittet'
|
||
|
||
return {
|
||
file,
|
||
assetId: fileStemFromOutput(file),
|
||
state,
|
||
label,
|
||
ts: Number(progress.startedAtMs ?? Date.now()),
|
||
steps: [
|
||
{
|
||
key: 'postwork:split',
|
||
queue: 'postwork',
|
||
phase: 'split',
|
||
label: stepLabel,
|
||
state,
|
||
error:
|
||
progress.phase === 'error'
|
||
? String(progress.message ?? 'Split fehlgeschlagen')
|
||
: undefined,
|
||
ts: Number(progress.startedAtMs ?? Date.now()),
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
export default function FinishedDownloads({
|
||
jobs,
|
||
doneJobs,
|
||
blurPreviews,
|
||
teaserPlayback,
|
||
teaserAudio,
|
||
pauseTeasers = false,
|
||
onOpenPlayer,
|
||
onDeleteJob,
|
||
onToggleHot,
|
||
onToggleFavorite,
|
||
onToggleLike,
|
||
onToggleWatch,
|
||
onKeepJob,
|
||
onSplitJob,
|
||
onAddToDownloads,
|
||
doneTotal,
|
||
page,
|
||
pageSize,
|
||
onPageChange,
|
||
onPageSizeChange,
|
||
assetNonce,
|
||
sortMode,
|
||
onSortModeChange,
|
||
modelsByKey,
|
||
loadMode = 'paged',
|
||
includeKeep,
|
||
onIncludeKeepChange,
|
||
splitProgressByFile = {},
|
||
}: Props) {
|
||
// -----------------------------------------------------------------------------
|
||
// local constants
|
||
// -----------------------------------------------------------------------------
|
||
const VIEW_KEY = 'finishedDownloads_view'
|
||
const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1'
|
||
const GALLERY_CARD_SCALE_KEY = 'finishedDownloads_galleryCardScale_v1'
|
||
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
|
||
|
||
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
|
||
const teaserPlaybackMode: TeaserPlaybackMode = requestedTeaserPlaybackMode
|
||
|
||
const notify = useNotify()
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// media queries + simple derived flags
|
||
// -----------------------------------------------------------------------------
|
||
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
|
||
const isCoarsePointer = useMediaQuery('(hover: none) and (pointer: coarse)')
|
||
const isTabletOrSmaller = useMediaQuery('(max-width: 1024px)')
|
||
const isSmall = useMediaQuery('(max-width: 639px)')
|
||
|
||
const effectiveTeaserPlaybackMode =
|
||
!canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode
|
||
|
||
const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
|
||
|
||
const cardsMobileOffsetTopClass = ''
|
||
const cardsMobileOffsetBottomClass = 'mb-2'
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// state
|
||
// -----------------------------------------------------------------------------
|
||
const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = useState(false)
|
||
|
||
const [teaserKey, setTeaserKey] = useState<string | null>(null)
|
||
const [hoverTeaserKey, setHoverTeaserKey] = useState<string | null>(null)
|
||
|
||
const [deletedKeys, setDeletedKeys] = useState<Set<string>>(() => new Set())
|
||
const [deletingKeys, setDeletingKeys] = useState<Set<string>>(() => new Set())
|
||
const [keepingKeys, setKeepingKeys] = useState<Set<string>>(() => new Set())
|
||
const [removingKeys, setRemovingKeys] = useState<Set<string>>(() => new Set())
|
||
const [hiddenFiles, setHiddenFiles] = useState<Set<string>>(() => new Set())
|
||
|
||
const [undoing, setUndoing] = useState(false)
|
||
const [bulkBusy, setBulkBusy] = useState(false)
|
||
|
||
const [showOnlyCorrupt, setShowOnlyCorrupt] = useState(false)
|
||
|
||
const [postworkByFile, setPostworkByFile] =
|
||
useState<Record<string, FinishedPostworkBadge[]>>({})
|
||
|
||
const [generateAssetsLiveByFile, setGenerateAssetsLiveByFile] =
|
||
useState<Record<string, FinishedPostworkBadge[]>>({})
|
||
|
||
const [renamedFiles, setRenamedFiles] = useState<Record<string, string>>({})
|
||
|
||
const [view, setView] = useState<ViewMode>('table')
|
||
const [mobileOptionsOpen, setMobileOptionsOpen] = useState(false)
|
||
const [selectionActionsOpen, setSelectionActionsOpen] = useState(false)
|
||
|
||
const [galleryCardScaleIndex, setGalleryCardScaleIndex] = useState<number>(
|
||
DEFAULT_GALLERY_CARD_SCALE_INDEX
|
||
)
|
||
|
||
const selectionStoreRef = useRef<FinishedSelectionStore | null>(null)
|
||
|
||
const selectionStore =
|
||
selectionStoreRef.current ??
|
||
(selectionStoreRef.current = createFinishedSelectionStore())
|
||
const selectionVersion = useSelectionVersion(selectionStore)
|
||
|
||
const galleryCardScale =
|
||
GALLERY_CARD_SCALE_PRESETS[galleryCardScaleIndex]?.scale ??
|
||
GALLERY_CARD_SCALE_PRESETS[DEFAULT_GALLERY_CARD_SCALE_INDEX].scale
|
||
|
||
const handleGalleryScalePresetChange = useCallback((nextIndex: number) => {
|
||
setGalleryCardScaleIndex(clampPresetIndex(nextIndex))
|
||
}, [])
|
||
|
||
const [allDoneJobs, setAllDoneJobs] = useState<RecordJob[] | null>(null)
|
||
const [allDoneJobsLoading, setAllDoneJobsLoading] = useState(false)
|
||
|
||
const allDoneJobsRequestRef = useRef(0)
|
||
|
||
const [tagFilter, setTagFilter] = useState<string[]>([])
|
||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||
|
||
const [durations, setDurations] = useState<Record<string, number>>({})
|
||
const [resolutions, setResolutions] =
|
||
useState<Record<string, { w: number; h: number }>>({})
|
||
|
||
const [inlinePlay, setInlinePlay] =
|
||
useState<{ key: string; nonce: number } | null>(null)
|
||
|
||
const [hotBusyKeys, setHotBusyKeys] = useState<Set<string>>(() => new Set())
|
||
|
||
const [lastAction, setLastAction] = useState<UndoAction | null>(() => {
|
||
if (typeof window === 'undefined') return null
|
||
try {
|
||
const raw = localStorage.getItem(LAST_UNDO_KEY)
|
||
if (!raw) return null
|
||
|
||
const parsed = JSON.parse(raw) as PersistedUndoState
|
||
if (!parsed || parsed.v !== 1 || !parsed.action) return null
|
||
|
||
const ageMs = Date.now() - Number(parsed.ts || 0)
|
||
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 30 * 60 * 1000) {
|
||
localStorage.removeItem(LAST_UNDO_KEY)
|
||
return null
|
||
}
|
||
|
||
return parsed.action
|
||
} catch {
|
||
return null
|
||
}
|
||
})
|
||
|
||
const resolutionOf = useCallback((job: RecordJob): { w: number; h: number } | null => {
|
||
const k = keyFor(job)
|
||
|
||
const cached = resolutions[k]
|
||
if (cached?.w > 0 && cached?.h > 0) {
|
||
return cached
|
||
}
|
||
|
||
const meta = parseMetaObject((job as any)?.meta)
|
||
const w = readMetaVideoWidth(meta)
|
||
const h = readMetaVideoHeight(meta)
|
||
|
||
if (w && h) {
|
||
return { w, h }
|
||
}
|
||
|
||
return null
|
||
}, [resolutions])
|
||
|
||
const resolutionLabelOf = useCallback((job: RecordJob): string => {
|
||
return formatResolution(resolutionOf(job)) || '—'
|
||
}, [resolutionOf])
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// refs
|
||
// -----------------------------------------------------------------------------
|
||
const pointerPosRef = useRef<{ x: number; y: number } | null>(null)
|
||
|
||
const teaserHostsRef = useRef<Map<string, HTMLElement>>(new Map())
|
||
const teaserIORef = useRef<IntersectionObserver | null>(null)
|
||
const elToKeyRef = useRef<WeakMap<Element, string>>(new WeakMap())
|
||
const teaserKeyRafRef = useRef<number | null>(null)
|
||
const pendingTeaserKeyRef = useRef<string | null>(null)
|
||
|
||
const countHintRef = useRef({ pending: 0, t: 0, timer: 0 as any })
|
||
|
||
const swipeRefs = useRef<Map<string, SwipeCardHandle>>(new Map())
|
||
const fileToKeyRef = useRef<Map<string, string>>(new Map())
|
||
|
||
const durationsRef = useRef<Record<string, number>>({})
|
||
const durationsFlushTimerRef = useRef<number | null>(null)
|
||
|
||
const resolutionsRef = useRef<Record<string, { w: number; h: number }>>({})
|
||
const resolutionsFlushTimerRef = useRef<number | null>(null)
|
||
|
||
const removeTimersRef = useRef<Map<string, number>>(new Map())
|
||
|
||
const regenerateTaskFilesRef = useRef<Set<string>>(new Set())
|
||
|
||
const [doneMetaByFile, setDoneMetaByFile] = useState<Record<string, RecordJobMeta>>({})
|
||
|
||
const refreshDoneMetaForFile = useCallback(async (file: string) => {
|
||
const cleanFile = String(file || '').trim()
|
||
if (!cleanFile) return
|
||
|
||
try {
|
||
const res = await fetch(
|
||
`/api/record/done/meta?file=${encodeURIComponent(cleanFile)}`,
|
||
{ cache: 'no-store' }
|
||
)
|
||
|
||
if (!res.ok) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
const meta = data?.meta
|
||
|
||
if (!meta || typeof meta !== 'object') return
|
||
|
||
setDoneMetaByFile((prev) => ({
|
||
...prev,
|
||
[cleanFile]: meta as RecordJobMeta,
|
||
}))
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [])
|
||
|
||
const renderIntegrityBadge = useCallback((job: RecordJob) => {
|
||
return renderDownloadIntegrityBadge((job as any)?.meta)
|
||
}, [])
|
||
|
||
const openPlayerAndPauseTeasers = useCallback((job: RecordJob, startAtSec?: number) => {
|
||
onOpenPlayer(job, startAtSec)
|
||
}, [onOpenPlayer])
|
||
|
||
const renderRatingOverlay = useCallback((job: RecordJob) => {
|
||
return (
|
||
<RatingOverlay
|
||
metaRaw={(job as any)?.meta}
|
||
variant="overlay"
|
||
onOpenAt={(seconds) => {
|
||
openPlayerAndPauseTeasers(job, seconds)
|
||
}}
|
||
/>
|
||
)
|
||
}, [openPlayerAndPauseTeasers])
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// memoized derived data
|
||
// -----------------------------------------------------------------------------
|
||
const forcePreviewMuted =
|
||
Boolean(pauseTeasers) ||
|
||
(mobileNeedsTapForAudio && teaserAudio
|
||
? !mobileTeaserAudioUnlocked
|
||
: false)
|
||
|
||
const previewMuted = !Boolean(teaserAudio) || forcePreviewMuted
|
||
|
||
const audibleTeaserKey =
|
||
previewMuted
|
||
? null
|
||
: canHover
|
||
? hoverTeaserKey
|
||
: inlinePlay?.key ?? teaserKey
|
||
|
||
const deferredSearchQuery = useDeferredValue(searchQuery)
|
||
|
||
const activeTagSet = useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter])
|
||
|
||
const allKnownTags = useMemo(() => {
|
||
const seen = new Set<string>()
|
||
const out: string[] = []
|
||
|
||
for (const flags of Object.values(modelsByKey ?? {})) {
|
||
for (const tag of parseTags(flags?.tags)) {
|
||
const k = lowerTag(tag)
|
||
if (!k || seen.has(k)) continue
|
||
seen.add(k)
|
||
out.push(tag)
|
||
}
|
||
}
|
||
|
||
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||
return out
|
||
}, [modelsByKey])
|
||
|
||
const teaserState = useMemo<FinishedDownloadsTeaserState>(
|
||
() => ({
|
||
mode: effectiveTeaserPlaybackMode,
|
||
activeKey: teaserKey,
|
||
hoverKey: hoverTeaserKey,
|
||
audioEnabled: Boolean(teaserAudio) && !forcePreviewMuted && Boolean(audibleTeaserKey),
|
||
}),
|
||
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio, forcePreviewMuted, audibleTeaserKey]
|
||
)
|
||
|
||
const modelTags = useMemo(() => {
|
||
const tagsByModelKey: Record<string, string[]> = {}
|
||
const tagSetByModelKey: Record<string, Set<string>> = {}
|
||
|
||
for (const [k, flags] of Object.entries(modelsByKey ?? {})) {
|
||
const key = lowerTag(k)
|
||
const arr = parseTags(flags?.tags)
|
||
tagsByModelKey[key] = arr
|
||
tagSetByModelKey[key] = new Set(arr.map(lowerTag))
|
||
}
|
||
|
||
return { tagsByModelKey, tagSetByModelKey }
|
||
}, [modelsByKey])
|
||
|
||
const searchTokens = useMemo(
|
||
() => lowerTag(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
|
||
[deferredSearchQuery]
|
||
)
|
||
|
||
const searchActiveForGlobalFetch =
|
||
activeTagSet.size > 0 ||
|
||
searchTokens.length > 0 ||
|
||
showOnlyCorrupt
|
||
|
||
const globalFilterActive = searchActiveForGlobalFetch
|
||
const mobileCardStackMode = isSmall && view === 'cards'
|
||
|
||
const shouldLoadAllDoneJobs =
|
||
globalFilterActive || loadMode === 'all' || mobileCardStackMode
|
||
|
||
const effectiveAllMode = shouldLoadAllDoneJobs
|
||
|
||
const DEFAULT_GALLERY_PAGE_SIZE = 8
|
||
const [galleryPageSize, setGalleryPageSize] = useState<number>(
|
||
DEFAULT_GALLERY_PAGE_SIZE
|
||
)
|
||
|
||
const handleGalleryRecommendedPageSizeChange = useCallback((rawSize: number) => {
|
||
const rounded = Math.round(Number(rawSize))
|
||
if (!Number.isFinite(rounded) || rounded <= 0) return
|
||
|
||
const next = Math.max(1, Math.min(24, rounded))
|
||
|
||
setGalleryPageSize((current) => {
|
||
if (current === next) return current
|
||
return next
|
||
})
|
||
|
||
onPageSizeChange?.(next)
|
||
|
||
if (page !== 1) {
|
||
onPageChange(1)
|
||
}
|
||
}, [onPageSizeChange, onPageChange, page])
|
||
|
||
const effectivePageSize =
|
||
view === 'gallery'
|
||
? galleryPageSize
|
||
: pageSize
|
||
|
||
const doneJobsPage =
|
||
shouldLoadAllDoneJobs
|
||
? (allDoneJobs ?? doneJobs)
|
||
: doneJobs
|
||
|
||
const doneTotalPage =
|
||
shouldLoadAllDoneJobs
|
||
? (allDoneJobs?.length ?? doneTotal)
|
||
: doneTotal
|
||
|
||
const applyRenamedOutputLocal = useCallback(
|
||
(job: RecordJob): RecordJob => {
|
||
const out = norm(job.output || '')
|
||
const file = baseName(out)
|
||
const override = renamedFiles[file]
|
||
if (!override) return job
|
||
|
||
const idx = out.lastIndexOf('/')
|
||
const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
|
||
return { ...job, output: dir + override }
|
||
},
|
||
[renamedFiles]
|
||
)
|
||
|
||
const rows = useMemo(() => {
|
||
const map = new Map<string, RecordJob>()
|
||
const doneBaseKeys = new Set<string>()
|
||
|
||
for (const j of doneJobsPage) {
|
||
let jj = applyRenamedOutputLocal(j)
|
||
|
||
const file = baseName(jj.output || '')
|
||
const metaOverride = file ? doneMetaByFile[file] : undefined
|
||
|
||
if (metaOverride) {
|
||
jj = {
|
||
...jj,
|
||
meta: mergeMetaPreferDone(metaOverride, (jj as any)?.meta),
|
||
}
|
||
}
|
||
const k = keyFor(jj)
|
||
|
||
map.set(k, jj)
|
||
doneBaseKeys.add(k)
|
||
}
|
||
|
||
for (const j of jobs) {
|
||
const jj = applyRenamedOutputLocal(j)
|
||
const k = keyFor(jj)
|
||
|
||
if (map.has(k)) {
|
||
const existing = map.get(k)!
|
||
|
||
map.set(k, {
|
||
...existing,
|
||
...jj,
|
||
meta: mergeMetaPreferDone((existing as any)?.meta, (jj as any)?.meta),
|
||
})
|
||
continue
|
||
}
|
||
|
||
const file = baseName(jj.output || '')
|
||
if (!file) continue
|
||
|
||
for (const [existingKey, existingJob] of map.entries()) {
|
||
if (baseName(existingJob.output || '') === file) {
|
||
map.set(existingKey, {
|
||
...existingJob,
|
||
...jj,
|
||
meta: mergeMetaPreferDone((existingJob as any)?.meta, (jj as any)?.meta),
|
||
})
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
return Array.from(map.entries())
|
||
.map(([k, j]) => ({ k, j }))
|
||
.filter(({ k, j }) => {
|
||
const file = baseName(j.output || '')
|
||
if (deletedKeys.has(k)) return false
|
||
if (file && hiddenFiles.has(file)) return false
|
||
if (isTrashOutput(j.output)) return false
|
||
|
||
const anyJ = j as any
|
||
const status = String(anyJ?.status ?? '').trim().toLowerCase()
|
||
const endedAt = anyJ?.endedAt
|
||
|
||
if (doneBaseKeys.has(k)) return true
|
||
|
||
if (
|
||
status === 'finished' ||
|
||
status === 'failed' ||
|
||
status === 'stopped'
|
||
) {
|
||
return true
|
||
}
|
||
|
||
if (endedAt) return true
|
||
|
||
return false
|
||
})
|
||
.map(({ j }) => j)
|
||
}, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutputLocal, doneMetaByFile])
|
||
|
||
const effectivePostworkSummaryByFile = useMemo<Record<string, FinishedPostworkSummary>>(() => {
|
||
const next: Record<string, FinishedPostworkSummary> = {}
|
||
|
||
for (const [file, badges] of Object.entries(postworkByFile)) {
|
||
const summary = summarizePostworkSteps(file, badges)
|
||
if (summary) {
|
||
next[file] = summary
|
||
|
||
const filePlain = stripHotPrefix(file)
|
||
if (filePlain && filePlain !== file) {
|
||
next[filePlain] = summary
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const [file, badges] of Object.entries(generateAssetsLiveByFile)) {
|
||
const summary = summarizePostworkSteps(file, badges)
|
||
if (!summary) continue
|
||
|
||
const filePlain = stripHotPrefix(file)
|
||
const existing = next[file] ?? (filePlain ? next[filePlain] : undefined)
|
||
|
||
const merged =
|
||
mergePostworkSummaries(summary, existing ?? null, file, summary.assetId) ?? summary
|
||
|
||
next[file] = merged
|
||
if (filePlain && filePlain !== file) {
|
||
next[filePlain] = merged
|
||
}
|
||
}
|
||
|
||
for (const job of rows) {
|
||
const fileRaw = baseName(job.output || '')
|
||
const filePlain = stripHotPrefix(fileRaw)
|
||
|
||
if (!fileRaw) continue
|
||
|
||
const rawMeta = (job as any)?.meta
|
||
const meta = parseMetaObject(rawMeta)
|
||
const assetId = fileStemFromOutput(job.output)
|
||
|
||
const summaryFromHistory = buildPostworkSummaryFromMetaHistory(fileRaw, assetId, meta)
|
||
const summaryFromCompleted = buildCompletedPostworkSummaryFromMeta(fileRaw, assetId, meta)
|
||
|
||
const mergedMetaSummary =
|
||
mergePostworkSummaries(
|
||
summaryFromHistory,
|
||
summaryFromCompleted,
|
||
fileRaw,
|
||
assetId
|
||
)
|
||
|
||
const metaSummary =
|
||
mergedMetaSummary ??
|
||
buildMissingPostworkSummary(fileRaw, assetId, 'Kein Status')
|
||
|
||
const liveSummary = next[fileRaw] ?? next[filePlain]
|
||
|
||
const summary =
|
||
mergePostworkSummaries(
|
||
liveSummary ?? null,
|
||
metaSummary ?? null,
|
||
fileRaw,
|
||
assetId
|
||
) ?? metaSummary ?? liveSummary ?? null
|
||
|
||
if (summary) {
|
||
next[fileRaw] = summary
|
||
if (filePlain && filePlain !== fileRaw) {
|
||
next[filePlain] = summary
|
||
}
|
||
}
|
||
}
|
||
|
||
return next
|
||
}, [postworkByFile, generateAssetsLiveByFile, rows])
|
||
|
||
const effectiveBadgeSummaryByFile = useMemo<Record<string, FinishedPostworkSummary>>(() => {
|
||
const next = { ...effectivePostworkSummaryByFile }
|
||
|
||
for (const [file, progress] of Object.entries(splitProgressByFile)) {
|
||
const summary = buildSplitProgressSummary(file, progress)
|
||
if (!summary) continue
|
||
|
||
next[file] = summary
|
||
|
||
const plain = stripHotPrefix(file)
|
||
if (plain && plain !== file) {
|
||
next[plain] = summary
|
||
}
|
||
}
|
||
|
||
return next
|
||
}, [effectivePostworkSummaryByFile, splitProgressByFile])
|
||
|
||
const viewRows = rows
|
||
|
||
const visibleRows = useMemo(() => {
|
||
const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
|
||
|
||
const searched = searchTokens.length
|
||
? base.filter((j) => {
|
||
const file = baseName(j.output || '')
|
||
const model = modelNameFromOutput(j.output)
|
||
const modelKey = lowerTag(model)
|
||
const tags = modelTags.tagsByModelKey[modelKey] ?? []
|
||
|
||
const hay = lowerTag([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
|
||
|
||
for (const t of searchTokens) {
|
||
if (!hay.includes(t)) return false
|
||
}
|
||
return true
|
||
})
|
||
: base
|
||
|
||
const tagged =
|
||
activeTagSet.size === 0
|
||
? searched
|
||
: searched.filter((j) => {
|
||
const modelKey = lowerTag(modelNameFromOutput(j.output))
|
||
const have = modelTags.tagSetByModelKey[modelKey]
|
||
if (!have || have.size === 0) return false
|
||
|
||
for (const t of activeTagSet) {
|
||
if (!have.has(t)) return false
|
||
}
|
||
return true
|
||
})
|
||
|
||
const corruptFiltered = tagged.filter((j) => {
|
||
return matchesCorruptOnlyFilter((j as any)?.meta, showOnlyCorrupt)
|
||
})
|
||
|
||
return corruptFiltered
|
||
}, [
|
||
viewRows,
|
||
deletedKeys,
|
||
activeTagSet,
|
||
modelTags,
|
||
searchTokens,
|
||
showOnlyCorrupt,
|
||
])
|
||
|
||
const finishedDownloadsLoading = allDoneJobsLoading
|
||
|
||
const totalItemsForPagination =
|
||
finishedDownloadsLoading
|
||
? 0
|
||
: effectiveAllMode
|
||
? visibleRows.length
|
||
: doneTotalPage
|
||
|
||
const pageRows = useMemo(() => {
|
||
if (effectiveAllMode) {
|
||
const start = (page - 1) * effectivePageSize
|
||
const end = start + effectivePageSize
|
||
return visibleRows.slice(start, end)
|
||
}
|
||
|
||
return visibleRows
|
||
}, [visibleRows, page, effectivePageSize, effectiveAllMode])
|
||
|
||
const cardRows = mobileCardStackMode ? visibleRows : pageRows
|
||
|
||
const activeVisibleRows = pageRows
|
||
|
||
const emptyFolder =
|
||
!finishedDownloadsLoading &&
|
||
!effectiveAllMode &&
|
||
totalItemsForPagination === 0
|
||
|
||
const emptyByFilter =
|
||
!finishedDownloadsLoading &&
|
||
globalFilterActive &&
|
||
visibleRows.length === 0
|
||
|
||
const selectedCount = useMemo(() => {
|
||
return selectionStore.getCount()
|
||
}, [selectionStore, selectionVersion])
|
||
|
||
const selectedFiles = useMemo(() => {
|
||
return selectionStore.getSelectedFiles()
|
||
}, [selectionStore, selectionVersion])
|
||
|
||
const allPageSelected = useMemo(() => {
|
||
return (
|
||
activeVisibleRows.length > 0 &&
|
||
activeVisibleRows.every((j) => selectionStore.isSelected(keyFor(j)))
|
||
)
|
||
}, [activeVisibleRows, keyFor, selectionStore, selectionVersion])
|
||
|
||
const hasSearchQuery = (searchQuery || '').trim() !== ''
|
||
const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen
|
||
const selectionModeActive = selectedCount > 0
|
||
|
||
const selectedKeys = useMemo(() => {
|
||
const next = new Set<string>()
|
||
|
||
for (const j of activeVisibleRows) {
|
||
const k = keyFor(j)
|
||
if (selectionStore.isSelected(k)) {
|
||
next.add(k)
|
||
}
|
||
}
|
||
|
||
return next
|
||
}, [activeVisibleRows, keyFor, selectionStore, selectionVersion])
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// callbacks
|
||
// -----------------------------------------------------------------------------
|
||
const toggleSelected = useCallback((job: RecordJob) => {
|
||
const k = keyFor(job)
|
||
const file = baseName(job.output || '')
|
||
if (!file) return
|
||
|
||
selectionStore.toggle({ key: k, file })
|
||
}, [selectionStore])
|
||
|
||
const clearSelection = useCallback(() => {
|
||
selectionStore.clear()
|
||
}, [selectionStore])
|
||
|
||
const selectPageRows = useCallback((rows: RecordJob[]) => {
|
||
const items = rows
|
||
.map((j) => {
|
||
const key = keyFor(j)
|
||
const file = baseName(j.output || '')
|
||
if (!file) return null
|
||
return { key, file }
|
||
})
|
||
.filter((x): x is { key: string; file: string } => Boolean(x))
|
||
|
||
selectionStore.selectMany(items)
|
||
}, [selectionStore])
|
||
|
||
const deselectPageRows = useCallback((rows: RecordJob[]) => {
|
||
selectionStore.deselectMany(rows.map((j) => keyFor(j)))
|
||
}, [selectionStore, keyFor])
|
||
|
||
const refreshHoverPreviewFromPointer = useCallback(() => {
|
||
if (!canHover) return
|
||
|
||
const pos = pointerPosRef.current
|
||
if (!pos) {
|
||
setHoverTeaserKey(null)
|
||
return
|
||
}
|
||
|
||
const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null
|
||
if (!el) {
|
||
setHoverTeaserKey(null)
|
||
return
|
||
}
|
||
|
||
const hoverHost = el.closest<HTMLElement>('[data-finished-hover-key]')
|
||
const nextKey = hoverHost?.dataset.finishedHoverKey ?? null
|
||
|
||
setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey))
|
||
}, [canHover])
|
||
|
||
const emitCountHint = useCallback((delta: number) => {
|
||
if (!delta || !Number.isFinite(delta)) return
|
||
|
||
countHintRef.current.pending += delta
|
||
if (countHintRef.current.timer) return
|
||
|
||
countHintRef.current.timer = window.setTimeout(() => {
|
||
countHintRef.current.timer = 0
|
||
const d = countHintRef.current.pending
|
||
countHintRef.current.pending = 0
|
||
if (!d) return
|
||
|
||
window.dispatchEvent(
|
||
new CustomEvent('finished-downloads:count-hint', { detail: { delta: d } })
|
||
)
|
||
}, 120)
|
||
}, [])
|
||
|
||
const jobForDetails = useCallback(
|
||
(job: RecordJob): RecordJob => enrichJobForDetails(job, modelsByKey),
|
||
[modelsByKey]
|
||
)
|
||
|
||
const clearSearch = useCallback(() => setSearchQuery(''), [])
|
||
|
||
const toggleTagFilter = useCallback((tag: string) => {
|
||
const k = lowerTag(tag)
|
||
setTagFilter((prev) => {
|
||
const has = prev.some((t) => lowerTag(t) === k)
|
||
return has ? prev.filter((t) => lowerTag(t) !== k) : [...prev, tag]
|
||
})
|
||
}, [])
|
||
|
||
const clearTagFilter = useCallback(() => {
|
||
setTagFilter([])
|
||
}, [])
|
||
|
||
const addTagFilter = useCallback((tag: string) => {
|
||
const clean = String(tag ?? '').trim()
|
||
if (!clean) return
|
||
|
||
setTagFilter((prev) =>
|
||
prev.some((t) => lowerTag(t) === lowerTag(clean)) ? prev : [...prev, clean]
|
||
)
|
||
}, [])
|
||
|
||
const removeTagFilter = useCallback((tag: string) => {
|
||
setTagFilter((prev) => prev.filter((t) => lowerTag(t) !== lowerTag(tag)))
|
||
}, [])
|
||
|
||
const renderTagFilterControls = (opts?: { mobile?: boolean }): ReactNode => (
|
||
<TagFilterBar
|
||
mobile={Boolean(opts?.mobile)}
|
||
activeTags={tagFilter}
|
||
allTags={allKnownTags}
|
||
onAddTag={addTagFilter}
|
||
onRemoveTag={removeTagFilter}
|
||
onClear={clearTagFilter}
|
||
/>
|
||
)
|
||
|
||
const loadAllDoneJobs = useCallback(async (): Promise<RecordJob[]> => {
|
||
const res = await fetch(
|
||
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}${
|
||
includeKeep ? '&includeKeep=1' : ''
|
||
}`,
|
||
{ cache: 'no-store' as any }
|
||
)
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
const data = await res.json().catch(() => null)
|
||
|
||
const items = Array.isArray(data?.items)
|
||
? (data.items as RecordJob[])
|
||
: Array.isArray(data)
|
||
? (data as RecordJob[])
|
||
: []
|
||
|
||
return items
|
||
}, [sortMode, includeKeep])
|
||
|
||
const flushResolutionsSoon = useCallback(() => {
|
||
if (resolutionsFlushTimerRef.current != null) return
|
||
resolutionsFlushTimerRef.current = window.setTimeout(() => {
|
||
resolutionsFlushTimerRef.current = null
|
||
setResolutions({ ...resolutionsRef.current })
|
||
}, 200)
|
||
}, [])
|
||
|
||
const flushDurationsSoon = useCallback(() => {
|
||
if (durationsFlushTimerRef.current != null) return
|
||
durationsFlushTimerRef.current = window.setTimeout(() => {
|
||
durationsFlushTimerRef.current = null
|
||
setDurations({ ...durationsRef.current })
|
||
}, 200)
|
||
}, [])
|
||
|
||
const tryAutoplayInline = useCallback((domId: string) => {
|
||
const host = document.getElementById(domId)
|
||
const v = host?.querySelector('video') as HTMLVideoElement | null
|
||
if (!host || !v) return false
|
||
|
||
const teaserHost =
|
||
host.closest<HTMLElement>('[data-finished-hover-key]') ??
|
||
host.querySelector<HTMLElement>('[data-finished-hover-key]')
|
||
|
||
const key = teaserHost?.dataset.finishedHoverKey ?? null
|
||
const muted = !audibleTeaserKey || key !== audibleTeaserKey
|
||
|
||
applyInlineVideoPolicy(v, { muted })
|
||
v.defaultMuted = muted
|
||
v.muted = muted
|
||
|
||
const p = v.play?.()
|
||
if (p && typeof (p as any).catch === 'function') {
|
||
;(p as Promise<void>).catch(() => {})
|
||
}
|
||
|
||
return true
|
||
}, [audibleTeaserKey])
|
||
|
||
const startInline = useCallback((key: string) => {
|
||
setInlinePlay((prev) => (
|
||
prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }
|
||
))
|
||
}, [])
|
||
|
||
const startInlineAt = useCallback((key: string, seconds: number, domId: string) => {
|
||
const safeSeconds = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
|
||
|
||
setInlinePlay((prev) => (
|
||
prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }
|
||
))
|
||
|
||
const trySeekAndPlay = (retriesLeft: number) => {
|
||
const host = document.getElementById(domId)
|
||
const v = host?.querySelector('video') as HTMLVideoElement | null
|
||
|
||
if (!v) {
|
||
if (retriesLeft > 0) {
|
||
requestAnimationFrame(() => trySeekAndPlay(retriesLeft - 1))
|
||
}
|
||
return
|
||
}
|
||
|
||
const muted = !audibleTeaserKey || key !== audibleTeaserKey
|
||
|
||
applyInlineVideoPolicy(v, { muted })
|
||
v.defaultMuted = muted
|
||
v.muted = muted
|
||
|
||
const applySeek = () => {
|
||
try {
|
||
const dur = Number(v.duration)
|
||
const maxSeek =
|
||
Number.isFinite(dur) && dur > 0
|
||
? Math.max(0, dur - 0.05)
|
||
: safeSeconds
|
||
|
||
v.currentTime = Math.max(0, Math.min(safeSeconds, maxSeek))
|
||
} catch {}
|
||
|
||
const p = v.play?.()
|
||
if (p && typeof (p as any).catch === 'function') {
|
||
;(p as Promise<void>).catch(() => {})
|
||
}
|
||
}
|
||
|
||
if (v.readyState >= 1) {
|
||
applySeek()
|
||
return
|
||
}
|
||
|
||
const onLoadedMetadata = () => {
|
||
v.removeEventListener('loadedmetadata', onLoadedMetadata)
|
||
applySeek()
|
||
}
|
||
|
||
v.addEventListener('loadedmetadata', onLoadedMetadata, { once: true })
|
||
|
||
const p = v.play?.()
|
||
if (p && typeof (p as any).catch === 'function') {
|
||
;(p as Promise<void>).catch(() => {})
|
||
}
|
||
}
|
||
|
||
requestAnimationFrame(() => trySeekAndPlay(8))
|
||
}, [audibleTeaserKey])
|
||
|
||
const openPlayer = useCallback((job: RecordJob) => {
|
||
openPlayerAndPauseTeasers(job)
|
||
}, [openPlayerAndPauseTeasers])
|
||
|
||
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
|
||
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
|
||
openPlayerAndPauseTeasers(job, s)
|
||
}, [openPlayerAndPauseTeasers])
|
||
|
||
const handleScrubberClickIndex = useCallback(
|
||
(job: RecordJob, segmentIndex: number, segmentCount: number) => {
|
||
const idx = Number.isFinite(segmentIndex) ? Math.floor(segmentIndex) : 0
|
||
const count = Number.isFinite(segmentCount) ? Math.floor(segmentCount) : 0
|
||
|
||
if (count <= 0) {
|
||
openPlayer(job)
|
||
return
|
||
}
|
||
|
||
const k = keyFor(job)
|
||
const durationSec =
|
||
durations[k] ??
|
||
((job as any)?.meta?.file?.durationSeconds as number | undefined) ??
|
||
0
|
||
|
||
if (!Number.isFinite(durationSec) || durationSec <= 0) {
|
||
openPlayer(job)
|
||
return
|
||
}
|
||
|
||
const clampedIdx = Math.max(0, Math.min(idx, count - 1))
|
||
const secPerSegment = durationSec / count
|
||
const startAtSec = clampedIdx * secPerSegment
|
||
|
||
openPlayerAt(job, startAtSec)
|
||
},
|
||
[durations, openPlayer, openPlayerAt]
|
||
)
|
||
|
||
const markDeleting = useCallback((key: string, value: boolean) => {
|
||
setDeletingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
if (value) next.add(key)
|
||
else next.delete(key)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const markDeleted = useCallback((key: string) => {
|
||
setDeletedKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.add(key)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const markKeeping = useCallback((key: string, value: boolean) => {
|
||
setKeepingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
if (value) next.add(key)
|
||
else next.delete(key)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const markRemoving = useCallback((key: string, value: boolean) => {
|
||
setRemovingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
if (value) next.add(key)
|
||
else next.delete(key)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const cancelRemoveTimer = useCallback((key: string) => {
|
||
const t = removeTimersRef.current.get(key)
|
||
if (t != null) {
|
||
window.clearTimeout(t)
|
||
removeTimersRef.current.delete(key)
|
||
}
|
||
}, [])
|
||
|
||
const restoreRow = useCallback(
|
||
(key: string, file?: string) => {
|
||
cancelRemoveTimer(key)
|
||
|
||
setDeletedKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(key)
|
||
return next
|
||
})
|
||
|
||
setRemovingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(key)
|
||
return next
|
||
})
|
||
|
||
setDeletingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(key)
|
||
return next
|
||
})
|
||
|
||
setKeepingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(key)
|
||
return next
|
||
})
|
||
|
||
if (file) {
|
||
setHiddenFiles((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(file)
|
||
return next
|
||
})
|
||
}
|
||
},
|
||
[cancelRemoveTimer]
|
||
)
|
||
|
||
const animateRemove = useCallback(
|
||
(key: string, file?: string) => {
|
||
if (file) {
|
||
setHiddenFiles((prev) => {
|
||
const next = new Set(prev)
|
||
next.add(file)
|
||
return next
|
||
})
|
||
}
|
||
|
||
markRemoving(key, true)
|
||
cancelRemoveTimer(key)
|
||
|
||
const t = window.setTimeout(() => {
|
||
removeTimersRef.current.delete(key)
|
||
|
||
markDeleted(key)
|
||
markRemoving(key, false)
|
||
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
refreshHoverPreviewFromPointer()
|
||
})
|
||
})
|
||
}, 320)
|
||
|
||
removeTimersRef.current.set(key, t)
|
||
},
|
||
[markDeleted, markRemoving, cancelRemoveTimer, refreshHoverPreviewFromPointer]
|
||
)
|
||
|
||
const releasePlayingFile = useCallback(
|
||
async (file: string, opts?: { close?: boolean }) => {
|
||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||
if (opts?.close) {
|
||
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
|
||
}
|
||
|
||
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||
await new Promise((r) => window.setTimeout(r, 260))
|
||
},
|
||
[]
|
||
)
|
||
|
||
const forceReleaseFileTasks = useCallback(async (file: string) => {
|
||
const cleanFile = String(file || '').trim()
|
||
if (!cleanFile) return false
|
||
|
||
try {
|
||
const res = await fetch('/api/record/release-file', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
body: JSON.stringify({ file: cleanFile }),
|
||
})
|
||
|
||
if (!res.ok) return false
|
||
|
||
const data = (await res.json().catch(() => null)) as any
|
||
return Boolean(data?.releasedAny)
|
||
} catch {
|
||
return false
|
||
}
|
||
}, [])
|
||
|
||
const withFileReleaseRetry = useCallback(
|
||
async <T,>(
|
||
file: string,
|
||
run: () => Promise<T>,
|
||
opts?: { close?: boolean; attempts?: number; baseDelayMs?: number }
|
||
): Promise<T> => {
|
||
const attempts = Math.max(1, opts?.attempts ?? 5)
|
||
const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)
|
||
|
||
let lastErr: unknown
|
||
let taskReleaseTriggered = false
|
||
|
||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||
try {
|
||
await releasePlayingFile(file, { close: opts?.close ?? true })
|
||
|
||
if (attempt > 1) {
|
||
await sleep(baseDelayMs * attempt)
|
||
} else {
|
||
await sleep(80)
|
||
}
|
||
|
||
return await run()
|
||
} catch (e) {
|
||
lastErr = e
|
||
|
||
if (!looksLikeFileInUseError(e)) {
|
||
throw e
|
||
}
|
||
|
||
// Beim ersten "file in use" zusätzlich Backend-Tasks abbrechen
|
||
if (!taskReleaseTriggered) {
|
||
taskReleaseTriggered = true
|
||
|
||
await releasePlayingFile(file, { close: true })
|
||
await forceReleaseFileTasks(file)
|
||
await sleep(450)
|
||
|
||
continue
|
||
}
|
||
|
||
if (attempt >= attempts) {
|
||
throw e
|
||
}
|
||
}
|
||
}
|
||
|
||
throw lastErr instanceof Error
|
||
? lastErr
|
||
: new Error(String(lastErr ?? 'Unbekannter Fehler'))
|
||
},
|
||
[releasePlayingFile, forceReleaseFileTasks]
|
||
)
|
||
|
||
const runFileMutation = useCallback(
|
||
async <T,>(opts: RunFileMutationOptions<T>): Promise<{ ok: boolean; result?: T }> => {
|
||
const {
|
||
file,
|
||
rowKey,
|
||
setBusy,
|
||
isBusyNow,
|
||
optimisticRemove,
|
||
alreadyRemoved,
|
||
run,
|
||
onSuccess,
|
||
onError,
|
||
labels,
|
||
} = opts
|
||
|
||
if (!file) {
|
||
notify.error(labels.invalidTitle, labels.invalidBody)
|
||
return { ok: false }
|
||
}
|
||
|
||
if (isBusyNow?.()) return { ok: false }
|
||
|
||
setBusy?.(true)
|
||
|
||
try {
|
||
if (optimisticRemove && !alreadyRemoved) {
|
||
animateRemove(rowKey)
|
||
}
|
||
|
||
const result = await run()
|
||
await onSuccess?.(result)
|
||
|
||
return { ok: true, result }
|
||
} catch (e: any) {
|
||
if (optimisticRemove) {
|
||
restoreRow(rowKey, file)
|
||
}
|
||
|
||
await onError?.(e)
|
||
|
||
if (looksLikeFileInUseError(e)) {
|
||
notify.error(
|
||
labels.inUseTitle,
|
||
`${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.`
|
||
)
|
||
} else {
|
||
const suffix = e?.message ? ` — ${String(e.message)}` : ''
|
||
notify.error(labels.failTitle, `${labels.failPrefix ?? file}${suffix}`)
|
||
}
|
||
|
||
return { ok: false }
|
||
} finally {
|
||
setBusy?.(false)
|
||
}
|
||
},
|
||
[notify, animateRemove, restoreRow]
|
||
)
|
||
|
||
const deleteVideo = useCallback(
|
||
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise<boolean> => {
|
||
const file = baseName(job.output || '')
|
||
const key = keyFor(job)
|
||
|
||
const res = await runFileMutation({
|
||
kind: 'delete',
|
||
job,
|
||
file,
|
||
rowKey: key,
|
||
setBusy: (v) => markDeleting(key, v),
|
||
isBusyNow: () => deletingKeys.has(key),
|
||
optimisticRemove: true,
|
||
alreadyRemoved: opts?.alreadyRemoved,
|
||
labels: {
|
||
invalidTitle: 'Löschen nicht möglich',
|
||
invalidBody: 'Kein Dateiname gefunden – kann nicht löschen.',
|
||
inUseTitle: 'Löschen fehlgeschlagen',
|
||
failTitle: 'Löschen fehlgeschlagen',
|
||
failPrefix: file,
|
||
},
|
||
run: async () => {
|
||
if (onDeleteJob) {
|
||
return await withFileReleaseRetry(
|
||
file,
|
||
async () => await onDeleteJob(job),
|
||
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||
)
|
||
}
|
||
|
||
const r = await withFileReleaseRetry(
|
||
file,
|
||
async () =>
|
||
await fetchWithTextError(`/api/record/delete?file=${encodeURIComponent(file)}`, {
|
||
method: 'POST',
|
||
}),
|
||
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||
)
|
||
|
||
return (await r.json().catch(() => null)) as any
|
||
},
|
||
onSuccess: async (result: any) => {
|
||
selectionStore.deselect(key)
|
||
|
||
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
||
const from = typeof result?.from === 'string' ? result.from : undefined
|
||
|
||
if (undoToken) {
|
||
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
|
||
} else {
|
||
setLastAction(null)
|
||
}
|
||
|
||
emitCountHint(-1)
|
||
}
|
||
})
|
||
|
||
return res.ok
|
||
},
|
||
[
|
||
deletingKeys,
|
||
markDeleting,
|
||
onDeleteJob,
|
||
withFileReleaseRetry,
|
||
runFileMutation,
|
||
emitCountHint,
|
||
selectionStore,
|
||
]
|
||
)
|
||
|
||
const keepVideo = useCallback(
|
||
async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => {
|
||
const file = baseName(job.output || '')
|
||
const key = keyFor(job)
|
||
|
||
const res = await runFileMutation({
|
||
kind: 'keep',
|
||
job,
|
||
file,
|
||
rowKey: key,
|
||
setBusy: (v) => markKeeping(key, v),
|
||
isBusyNow: () => keepingKeys.has(key) || deletingKeys.has(key),
|
||
optimisticRemove: true,
|
||
alreadyRemoved: opts?.alreadyRemoved,
|
||
labels: {
|
||
invalidTitle: 'Keep nicht möglich',
|
||
invalidBody: 'Kein Dateiname gefunden – kann nicht behalten.',
|
||
inUseTitle: 'Keep fehlgeschlagen',
|
||
failTitle: 'Keep fehlgeschlagen',
|
||
failPrefix: file,
|
||
},
|
||
run: async () => {
|
||
if (onKeepJob) {
|
||
return await withFileReleaseRetry(
|
||
file,
|
||
async () => await onKeepJob(job),
|
||
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||
)
|
||
}
|
||
|
||
const r = await withFileReleaseRetry(
|
||
file,
|
||
async () =>
|
||
await fetchWithTextError(`/api/record/keep?file=${encodeURIComponent(file)}`, {
|
||
method: 'POST',
|
||
}),
|
||
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||
)
|
||
return (await r.json().catch(() => null)) as any
|
||
},
|
||
onSuccess: async (data: any) => {
|
||
selectionStore.deselect(key)
|
||
|
||
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
|
||
|
||
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
|
||
|
||
emitCountHint(includeKeep ? 0 : -1)
|
||
}
|
||
})
|
||
|
||
return res.ok
|
||
},
|
||
[
|
||
markKeeping,
|
||
keepingKeys,
|
||
deletingKeys,
|
||
withFileReleaseRetry,
|
||
runFileMutation,
|
||
emitCountHint,
|
||
includeKeep,
|
||
selectionStore,
|
||
]
|
||
)
|
||
|
||
|
||
|
||
const applyRename = useCallback((oldFile: string, newFile: string) => {
|
||
if (!oldFile || !newFile || oldFile === newFile) return
|
||
|
||
setRenamedFiles((prev) => {
|
||
const next: Record<string, string> = { ...prev }
|
||
for (const [k, v] of Object.entries(next)) {
|
||
if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k]
|
||
}
|
||
next[oldFile] = newFile
|
||
return next
|
||
})
|
||
|
||
selectionStore.replaceFile(oldFile, newFile)
|
||
}, [selectionStore])
|
||
|
||
const clearRenamePair = useCallback((a: string, b: string) => {
|
||
if (!a && !b) return
|
||
setRenamedFiles((prev) => {
|
||
const next: Record<string, string> = { ...prev }
|
||
for (const [k, v] of Object.entries(next)) {
|
||
if (k === a || k === b || v === a || v === b) delete next[k]
|
||
}
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const selectOnly = useCallback((job: RecordJob) => {
|
||
const key = keyFor(job)
|
||
const file = baseName(job.output || '')
|
||
if (!file) return
|
||
|
||
selectionStore.clear()
|
||
selectionStore.selectMany([{ key, file }])
|
||
}, [selectionStore])
|
||
|
||
const undoLastAction = useCallback(async () => {
|
||
if (!lastAction || undoing) return
|
||
setUndoing(true)
|
||
|
||
const unhideRow = (keyOrToken: string, file?: string) => {
|
||
setDeletedKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(keyOrToken)
|
||
return next
|
||
})
|
||
setDeletingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(keyOrToken)
|
||
return next
|
||
})
|
||
setKeepingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(keyOrToken)
|
||
return next
|
||
})
|
||
setRemovingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(keyOrToken)
|
||
return next
|
||
})
|
||
|
||
if (file) {
|
||
setHiddenFiles((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(file)
|
||
return next
|
||
})
|
||
}
|
||
}
|
||
|
||
try {
|
||
if (lastAction.kind === 'delete') {
|
||
const res = await fetch(
|
||
`/api/record/restore?token=${encodeURIComponent(lastAction.undoToken)}`,
|
||
{ method: 'POST' }
|
||
)
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
const data = await res.json().catch(() => null) as any
|
||
const restoredFile = String(data?.restoredFile || lastAction.originalFile)
|
||
|
||
if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile)
|
||
unhideRow(lastAction.originalFile, lastAction.originalFile)
|
||
unhideRow(restoredFile, restoredFile)
|
||
|
||
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
|
||
emitCountHint(visibleDelta)
|
||
setLastAction(null)
|
||
return
|
||
}
|
||
|
||
if (lastAction.kind === 'keep') {
|
||
const res = await fetch(
|
||
`/api/record/unkeep?file=${encodeURIComponent(lastAction.keptFile)}`,
|
||
{ method: 'POST' }
|
||
)
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
const data = await res.json().catch(() => null) as any
|
||
const restoredFile = String(data?.newFile || lastAction.originalFile)
|
||
|
||
if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile)
|
||
unhideRow(lastAction.originalFile, lastAction.originalFile)
|
||
unhideRow(restoredFile, restoredFile)
|
||
|
||
emitCountHint(includeKeep ? 0 : +1)
|
||
setLastAction(null)
|
||
return
|
||
}
|
||
|
||
if (lastAction.kind === 'hot') {
|
||
const res = await fetch(
|
||
`/api/record/toggle-hot?file=${encodeURIComponent(lastAction.currentFile)}`,
|
||
{ method: 'POST' }
|
||
)
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
const data = (await res.json().catch(() => null)) as any
|
||
const oldFile = String(data?.oldFile || lastAction.currentFile)
|
||
const newFile = String(data?.newFile || '')
|
||
|
||
if (newFile) {
|
||
applyRename(oldFile, newFile)
|
||
}
|
||
|
||
setLastAction(null)
|
||
return
|
||
}
|
||
} catch (e: any) {
|
||
notify.error('Undo fehlgeschlagen', String(e?.message || e))
|
||
} finally {
|
||
setUndoing(false)
|
||
}
|
||
}, [
|
||
lastAction,
|
||
undoing,
|
||
notify,
|
||
includeKeep,
|
||
emitCountHint,
|
||
applyRename,
|
||
])
|
||
|
||
const markHotBusy = useCallback((key: string, value: boolean) => {
|
||
setHotBusyKeys((prev) => {
|
||
const next = new Set(prev)
|
||
if (value) next.add(key)
|
||
else next.delete(key)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const toggleHotVideo = useCallback(
|
||
async (job: RecordJob): Promise<void> => {
|
||
const currentFile = baseName(job.output || '')
|
||
const key = keyFor(job)
|
||
|
||
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
|
||
const oldFile = currentFile
|
||
const optimisticNew = toggledName(oldFile)
|
||
|
||
await runFileMutation({
|
||
kind: 'rename',
|
||
job,
|
||
file: currentFile,
|
||
rowKey: key,
|
||
setBusy: (v) => markHotBusy(key, v),
|
||
isBusyNow: () => hotBusyKeys.has(key),
|
||
optimisticRemove: false,
|
||
labels: {
|
||
invalidTitle: 'HOT nicht möglich',
|
||
invalidBody: 'Kein Dateiname gefunden – kann nicht HOT togglen.',
|
||
inUseTitle: 'HOT umbenennen fehlgeschlagen',
|
||
failTitle: 'HOT umbenennen fehlgeschlagen',
|
||
failPrefix: oldFile,
|
||
},
|
||
run: async () => {
|
||
applyRename(oldFile, optimisticNew)
|
||
|
||
if (onToggleHot) {
|
||
const r = await onToggleHot(job)
|
||
return r as any
|
||
}
|
||
|
||
const r = await withFileReleaseRetry(
|
||
oldFile,
|
||
async () =>
|
||
await fetchWithTextError(
|
||
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
|
||
{ method: 'POST' }
|
||
),
|
||
{ close: true, attempts: 4, baseDelayMs: 220 }
|
||
)
|
||
|
||
return (await r.json().catch(() => null)) as any
|
||
},
|
||
onSuccess: async (data: any) => {
|
||
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
|
||
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
|
||
|
||
if (apiOld && apiNew && apiOld !== apiNew) {
|
||
applyRename(apiOld, apiNew)
|
||
}
|
||
|
||
setLastAction({ kind: 'hot', currentFile: apiNew })
|
||
},
|
||
onError: async () => {
|
||
clearRenamePair(oldFile, optimisticNew)
|
||
selectionStore.replaceFile(optimisticNew, oldFile)
|
||
setLastAction(null)
|
||
},
|
||
})
|
||
},
|
||
[
|
||
hotBusyKeys,
|
||
markHotBusy,
|
||
runFileMutation,
|
||
applyRename,
|
||
clearRenamePair,
|
||
onToggleHot,
|
||
withFileReleaseRetry,
|
||
selectionStore,
|
||
]
|
||
)
|
||
|
||
const regenerateAssetsForFile = useCallback(async (file: string) => {
|
||
const cleanFile = String(file || '').trim()
|
||
if (!cleanFile) return false
|
||
|
||
const assetId = fileStemFromOutput(cleanFile)
|
||
const now = Date.now()
|
||
|
||
// Nur den ersten Schritt optimistisch als queued setzen.
|
||
// Die weiteren Phasen sollen erst durch echte SSE-/Backend-Events auftauchen.
|
||
setPostworkByFile((prev) => ({
|
||
...prev,
|
||
[cleanFile]: [
|
||
{
|
||
file: cleanFile,
|
||
assetId,
|
||
queue: 'postwork',
|
||
state: 'queued',
|
||
phase: 'meta',
|
||
label: 'Meta',
|
||
ts: now,
|
||
},
|
||
],
|
||
}))
|
||
|
||
try {
|
||
const res = await fetch('/api/record/regenerate-assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ file: cleanFile }),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
return true
|
||
} catch (e: any) {
|
||
console.error('regenerate-assets failed', {
|
||
file: cleanFile,
|
||
error: e?.message ? String(e.message) : String(e),
|
||
})
|
||
|
||
// Request/Enqueue ist fehlgeschlagen -> KEIN Sammelstatus, KEIN enrich:assets.
|
||
// Wir markieren nur den ersten Schritt als Fehler.
|
||
setPostworkByFile((prev) => ({
|
||
...prev,
|
||
[cleanFile]: [
|
||
{
|
||
file: cleanFile,
|
||
assetId,
|
||
queue: 'postwork',
|
||
state: 'error',
|
||
phase: 'meta',
|
||
label: 'Fehler',
|
||
error: e?.message ? String(e.message) : String(e),
|
||
ts: Date.now(),
|
||
},
|
||
],
|
||
}))
|
||
|
||
notify.error(
|
||
'Assets neu generieren fehlgeschlagen',
|
||
e?.message ? String(e.message) : String(e)
|
||
)
|
||
return false
|
||
}
|
||
}, [notify])
|
||
|
||
const runtimeOf = useCallback((job: RecordJob): string => {
|
||
const k = keyFor(job)
|
||
const meta = parseMetaObject((job as any)?.meta)
|
||
const sec = durations[k] ?? metaDurationSeconds(meta)
|
||
|
||
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) {
|
||
return formatDuration(sec * 1000)
|
||
}
|
||
|
||
const start = Date.parse(String(job.startedAt || ''))
|
||
const end = Date.parse(String(job.endedAt || ''))
|
||
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
|
||
return formatDuration(end - start)
|
||
}
|
||
|
||
return '—'
|
||
}, [durations])
|
||
|
||
const teaserHostCallbackMapRef = useRef(
|
||
new Map<string, (el: HTMLDivElement | null) => void>()
|
||
)
|
||
|
||
const integrityMetaPendingRef = useRef<Set<string>>(new Set())
|
||
|
||
const registerTeaserHost = useCallback((key: string) => {
|
||
const existing = teaserHostCallbackMapRef.current.get(key)
|
||
if (existing) return existing
|
||
|
||
const cb = (el: HTMLDivElement | null) => {
|
||
const prev = teaserHostsRef.current.get(key)
|
||
if (prev && teaserIORef.current && prev !== el) {
|
||
teaserIORef.current.unobserve(prev)
|
||
}
|
||
|
||
if (el) {
|
||
teaserHostsRef.current.set(key, el)
|
||
elToKeyRef.current.set(el, key)
|
||
teaserIORef.current?.observe(el)
|
||
} else {
|
||
if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev)
|
||
teaserHostsRef.current.delete(key)
|
||
}
|
||
}
|
||
|
||
teaserHostCallbackMapRef.current.set(key, cb)
|
||
return cb
|
||
}, [])
|
||
|
||
const handleDuration = useCallback((job: RecordJob, seconds: number) => {
|
||
if (!Number.isFinite(seconds) || seconds <= 0) return
|
||
const k = keyFor(job)
|
||
|
||
const old = durationsRef.current[k]
|
||
if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) return
|
||
|
||
durationsRef.current = { ...durationsRef.current, [k]: seconds }
|
||
flushDurationsSoon()
|
||
}, [flushDurationsSoon])
|
||
|
||
const handleResolution = useCallback((job: RecordJob, w: number, h: number) => {
|
||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return
|
||
|
||
const k = keyFor(job)
|
||
const prev = resolutionsRef.current[k]
|
||
if (prev && prev.w === w && prev.h === h) return
|
||
|
||
resolutionsRef.current = { ...resolutionsRef.current, [k]: { w, h } }
|
||
flushResolutionsSoon()
|
||
}, [flushResolutionsSoon])
|
||
|
||
const bulkDeleteSelected = useCallback(async () => {
|
||
if (bulkBusy) return
|
||
if (selectedFiles.length === 0) return
|
||
|
||
const ok = window.confirm(`${selectedFiles.length} Downloads löschen?`)
|
||
if (!ok) return
|
||
|
||
setBulkBusy(true)
|
||
try {
|
||
const res = await fetch('/api/record/delete-many', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ files: selectedFiles }),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
const data = await res.json().catch(() => null)
|
||
const results = Array.isArray(data?.results) ? data.results : []
|
||
|
||
for (const item of results) {
|
||
if (!item?.ok) continue
|
||
|
||
const file = String(item.file || '').trim()
|
||
if (!file) continue
|
||
|
||
const key =
|
||
fileToKeyRef.current.get(file) ||
|
||
file
|
||
|
||
animateRemove(key, file)
|
||
}
|
||
|
||
const deletedCount = Number(data?.deleted ?? results.filter((x: any) => x?.ok).length ?? 0)
|
||
|
||
clearSelection()
|
||
emitCountHint(-deletedCount)
|
||
|
||
if (deletedCount > 0) {
|
||
notify.success?.('Löschen erfolgreich', `${deletedCount} Downloads gelöscht.`)
|
||
}
|
||
} catch (e: any) {
|
||
notify.error('Bulk-Löschen fehlgeschlagen', String(e?.message || e))
|
||
} finally {
|
||
setBulkBusy(false)
|
||
}
|
||
}, [
|
||
bulkBusy,
|
||
selectedFiles,
|
||
animateRemove,
|
||
clearSelection,
|
||
emitCountHint,
|
||
notify,
|
||
])
|
||
|
||
const bulkKeepSelected = useCallback(async () => {
|
||
if (bulkBusy) return
|
||
if (selectedFiles.length === 0) return
|
||
|
||
const ok = window.confirm(`${selectedFiles.length} Downloads behalten?`)
|
||
if (!ok) return
|
||
|
||
setBulkBusy(true)
|
||
try {
|
||
const res = await fetch('/api/record/keep-many', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ files: selectedFiles }),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
const data = await res.json().catch(() => null)
|
||
const results = Array.isArray(data?.results) ? data.results : []
|
||
|
||
for (const item of results) {
|
||
if (!item?.ok) continue
|
||
|
||
const file = String(item.file || '').trim()
|
||
if (!file) continue
|
||
|
||
const key =
|
||
fileToKeyRef.current.get(file) ||
|
||
file
|
||
|
||
animateRemove(key, file)
|
||
}
|
||
|
||
const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0)
|
||
|
||
clearSelection()
|
||
emitCountHint(includeKeep ? 0 : -keptCount)
|
||
|
||
if (keptCount > 0) {
|
||
notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`)
|
||
}
|
||
} catch (e: any) {
|
||
notify.error('Bulk-Keep fehlgeschlagen', String(e?.message || e))
|
||
} finally {
|
||
setBulkBusy(false)
|
||
}
|
||
}, [
|
||
bulkBusy,
|
||
selectedFiles,
|
||
animateRemove,
|
||
clearSelection,
|
||
emitCountHint,
|
||
includeKeep,
|
||
notify,
|
||
])
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// effects
|
||
// -----------------------------------------------------------------------------
|
||
|
||
useEffect(() => {
|
||
if (!shouldLoadAllDoneJobs) {
|
||
setAllDoneJobs(null)
|
||
setAllDoneJobsLoading(false)
|
||
return
|
||
}
|
||
|
||
let cancelled = false
|
||
const reqId = ++allDoneJobsRequestRef.current
|
||
|
||
setAllDoneJobsLoading(true)
|
||
|
||
void loadAllDoneJobs()
|
||
.then((items) => {
|
||
if (cancelled) return
|
||
if (allDoneJobsRequestRef.current !== reqId) return
|
||
setAllDoneJobs(items)
|
||
})
|
||
.catch(() => {
|
||
if (cancelled) return
|
||
if (allDoneJobsRequestRef.current !== reqId) return
|
||
setAllDoneJobs([])
|
||
})
|
||
.finally(() => {
|
||
if (cancelled) return
|
||
if (allDoneJobsRequestRef.current !== reqId) return
|
||
setAllDoneJobsLoading(false)
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [shouldLoadAllDoneJobs, loadAllDoneJobs])
|
||
|
||
useEffect(() => {
|
||
if (!showOnlyCorrupt) return
|
||
|
||
for (const job of viewRows) {
|
||
const file = baseName(job.output || '')
|
||
if (!file) continue
|
||
|
||
const state = readDownloadIntegrityState((job as any)?.meta)
|
||
if (state !== 'unchecked') continue
|
||
|
||
if (integrityMetaPendingRef.current.has(file)) continue
|
||
integrityMetaPendingRef.current.add(file)
|
||
|
||
void refreshDoneMetaForFile(file).finally(() => {
|
||
integrityMetaPendingRef.current.delete(file)
|
||
})
|
||
}
|
||
}, [showOnlyCorrupt, viewRows, refreshDoneMetaForFile])
|
||
|
||
useEffect(() => {
|
||
onPageChange(1)
|
||
}, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange])
|
||
|
||
useEffect(() => {
|
||
if (view !== 'gallery') return
|
||
|
||
const wanted = galleryPageSize
|
||
|
||
if (pageSize !== wanted) {
|
||
onPageSizeChange?.(wanted)
|
||
}
|
||
}, [view, pageSize, galleryPageSize, onPageSizeChange])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY)
|
||
const parsed = Number(raw)
|
||
|
||
if (!Number.isFinite(parsed)) {
|
||
setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX)
|
||
return
|
||
}
|
||
|
||
// neuer Fall: gespeicherter Index
|
||
if (
|
||
Number.isInteger(parsed) &&
|
||
parsed >= 0 &&
|
||
parsed <= GALLERY_CARD_SCALE_PRESETS.length - 1
|
||
) {
|
||
setGalleryCardScaleIndex(clampPresetIndex(parsed))
|
||
return
|
||
}
|
||
|
||
// alter Fall: gespeicherter Scale-Wert wie 0.8 / 0.9 / 1 / 1.2
|
||
setGalleryCardScaleIndex(nearestPresetIndex(parsed))
|
||
} catch {
|
||
setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScaleIndex))
|
||
} catch {}
|
||
}, [galleryCardScaleIndex])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
||
if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
|
||
setView(saved)
|
||
} else {
|
||
setView(window.matchMedia('(max-width: 639px)').matches ? 'cards' : 'table')
|
||
}
|
||
} catch {
|
||
setView('table')
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(VIEW_KEY, view)
|
||
} catch {}
|
||
}, [view])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const raw = localStorage.getItem(MOBILE_OPTS_KEY)
|
||
setMobileOptionsOpen(raw === '1' || raw === 'true' || raw === 'yes')
|
||
} catch {
|
||
setMobileOptionsOpen(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(MOBILE_OPTS_KEY, mobileOptionsOpen ? '1' : '0')
|
||
} catch {}
|
||
}, [mobileOptionsOpen])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const raw = localStorage.getItem('finishedDownloads_pendingTags')
|
||
if (!raw) return
|
||
|
||
const arr = JSON.parse(raw)
|
||
const tags = Array.isArray(arr) ? arr.map((t) => String(t || '').trim()).filter(Boolean) : []
|
||
if (tags.length === 0) return
|
||
|
||
flushSync(() => setTagFilter(tags))
|
||
if (page !== 1) onPageChange(1)
|
||
} catch {
|
||
} finally {
|
||
try {
|
||
localStorage.removeItem('finishedDownloads_pendingTags')
|
||
} catch {}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (teaserKeyRafRef.current != null) {
|
||
cancelAnimationFrame(teaserKeyRafRef.current)
|
||
teaserKeyRafRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
resolutionsRef.current = resolutions
|
||
}, [resolutions])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (resolutionsFlushTimerRef.current != null) {
|
||
window.clearTimeout(resolutionsFlushTimerRef.current)
|
||
resolutionsFlushTimerRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
durationsRef.current = durations
|
||
}, [durations])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (durationsFlushTimerRef.current != null) {
|
||
window.clearTimeout(durationsFlushTimerRef.current)
|
||
durationsFlushTimerRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
for (const t of removeTimersRef.current.values()) {
|
||
window.clearTimeout(t)
|
||
}
|
||
removeTimersRef.current.clear()
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
for (const [key, host] of teaserHostsRef.current.entries()) {
|
||
const muted = !audibleTeaserKey || key !== audibleTeaserKey
|
||
const videos = host.querySelectorAll('video')
|
||
|
||
videos.forEach((video) => {
|
||
applyInlineVideoPolicy(video, { muted })
|
||
video.defaultMuted = muted
|
||
video.muted = muted
|
||
})
|
||
}
|
||
}, [audibleTeaserKey, view, pageRows])
|
||
|
||
const refreshPostworkState = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/tasks/status', { cache: 'no-store' })
|
||
if (!res.ok) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
if (!data) return
|
||
|
||
const generateAssets = data?.generateAssets
|
||
const gaFile = String(generateAssets?.currentFile ?? '').trim()
|
||
const gaQueueRaw = String(generateAssets?.currentQueue ?? '').trim().toLowerCase()
|
||
const gaPhaseRaw = String(generateAssets?.currentPhase ?? '').trim().toLowerCase()
|
||
const gaLabel = String(generateAssets?.currentLabel ?? '').trim()
|
||
|
||
const gaQueue: 'postwork' | 'enrich' | null =
|
||
gaQueueRaw === 'postwork'
|
||
? 'postwork'
|
||
: gaQueueRaw === 'enrich'
|
||
? 'enrich'
|
||
: null
|
||
|
||
const gaPhase =
|
||
gaQueue && gaPhaseRaw
|
||
? normalizeFinishedPhase(gaQueue, gaPhaseRaw)
|
||
: ''
|
||
|
||
const liveTs =
|
||
typeof generateAssets?.ts === 'number' && Number.isFinite(generateAssets.ts)
|
||
? generateAssets.ts
|
||
: 0
|
||
|
||
const nextGenerateAssetsLiveByFile: Record<string, FinishedPostworkBadge[]> =
|
||
(generateAssets?.running || generateAssets?.queued) && gaFile && gaQueue && gaPhase
|
||
? {
|
||
[gaFile]: [
|
||
{
|
||
file: gaFile,
|
||
assetId: fileStemFromOutput(gaFile),
|
||
queue: gaQueue,
|
||
phase: gaPhase,
|
||
state: generateAssets?.running ? 'running' : 'queued',
|
||
label: gaLabel || labelForStep(gaQueue, gaPhase),
|
||
ts: liveTs,
|
||
},
|
||
],
|
||
}
|
||
: {}
|
||
|
||
setGenerateAssetsLiveByFile((prev) => {
|
||
const patched: Record<string, FinishedPostworkBadge[]> = {}
|
||
|
||
for (const [file, badges] of Object.entries(nextGenerateAssetsLiveByFile)) {
|
||
const prevBadges = prev[file] ?? postworkByFile[file] ?? []
|
||
|
||
patched[file] = badges.map((badge) => {
|
||
const key = `${badge.queue}:${normalizeFinishedPhase(
|
||
badge.queue,
|
||
String(badge.phase ?? '').trim().toLowerCase()
|
||
)}`
|
||
|
||
const previous = prevBadges.find((p) => {
|
||
const prevKey = `${p.queue}:${normalizeFinishedPhase(
|
||
p.queue,
|
||
String(p.phase ?? '').trim().toLowerCase()
|
||
)}`
|
||
|
||
return prevKey === key
|
||
})
|
||
|
||
return preferProgressLabel(badge, previous)
|
||
})
|
||
}
|
||
|
||
return samePostworkBadgeMap(prev, patched) ? prev : patched
|
||
})
|
||
|
||
const jobs = Array.isArray(data?.regenerateAssetsJobs)
|
||
? data.regenerateAssetsJobs
|
||
: []
|
||
|
||
const syncedFiles = new Set<string>()
|
||
|
||
setPostworkByFile((prev) => {
|
||
let changed = false
|
||
const next = { ...prev }
|
||
|
||
for (const item of jobs) {
|
||
const file = String(item?.file ?? '').trim()
|
||
if (!file) continue
|
||
|
||
syncedFiles.add(file)
|
||
|
||
let nextBadges: FinishedPostworkBadge[] | undefined
|
||
|
||
const assetId =
|
||
String(item?.assetId ?? '').trim() || fileStemFromOutput(file)
|
||
|
||
const phases = Array.isArray(item?.phases) ? item.phases : []
|
||
|
||
if (phases.length > 0) {
|
||
const prevBadgesForFile = prev[file] ?? []
|
||
|
||
nextBadges = phases
|
||
.map((p: any): FinishedPostworkBadge | null => {
|
||
const queueRaw = String(p?.queue ?? '').trim().toLowerCase()
|
||
const phaseRaw = String(p?.phase ?? '').trim().toLowerCase()
|
||
const rawState = String(p?.state ?? '').trim().toLowerCase()
|
||
|
||
const state: FinishedPostworkState =
|
||
rawState === 'queued' ||
|
||
rawState === 'running' ||
|
||
rawState === 'done' ||
|
||
rawState === 'error' ||
|
||
rawState === 'missing'
|
||
? rawState
|
||
: rawState === 'cancelled'
|
||
? 'missing'
|
||
: 'missing'
|
||
|
||
if (
|
||
(queueRaw !== 'postwork' && queueRaw !== 'enrich') ||
|
||
!phaseRaw
|
||
) {
|
||
return null
|
||
}
|
||
|
||
const queue = queueRaw as 'postwork' | 'enrich'
|
||
const phase = normalizeFinishedPhase(queue, phaseRaw)
|
||
|
||
const incoming: FinishedPostworkBadge = {
|
||
file,
|
||
assetId,
|
||
queue,
|
||
phase,
|
||
state,
|
||
label:
|
||
normalizeAnalysisProgressLabel(String(p?.label ?? '').trim()) ||
|
||
labelForStep(queue, phase),
|
||
position: typeof p?.position === 'number' ? p.position : undefined,
|
||
waiting: typeof p?.waiting === 'number' ? p.waiting : undefined,
|
||
running: typeof p?.running === 'number' ? p.running : undefined,
|
||
maxParallel: typeof p?.maxParallel === 'number' ? p.maxParallel : undefined,
|
||
ts: typeof p?.ts === 'number' && Number.isFinite(p.ts) ? p.ts : 0,
|
||
}
|
||
|
||
const key = `${incoming.queue}:${normalizeFinishedPhase(
|
||
incoming.queue,
|
||
String(incoming.phase ?? '').trim().toLowerCase()
|
||
)}`
|
||
|
||
const previous = prevBadgesForFile.find((old) => {
|
||
const oldKey = `${old.queue}:${normalizeFinishedPhase(
|
||
old.queue,
|
||
String(old.phase ?? '').trim().toLowerCase()
|
||
)}`
|
||
|
||
return oldKey === key
|
||
})
|
||
|
||
return preferProgressLabel(incoming, previous)
|
||
})
|
||
.filter(Boolean) as FinishedPostworkBadge[]
|
||
} else {
|
||
const rawState = String(item?.state ?? '').trim().toLowerCase()
|
||
|
||
const state: FinishedPostworkState =
|
||
rawState === 'queued' ||
|
||
rawState === 'running' ||
|
||
rawState === 'done' ||
|
||
rawState === 'error' ||
|
||
rawState === 'missing'
|
||
? rawState
|
||
: rawState === 'cancelled'
|
||
? 'missing'
|
||
: 'missing'
|
||
|
||
if (state === 'queued' || state === 'running' || state === 'error') {
|
||
nextBadges = [
|
||
{
|
||
file,
|
||
assetId,
|
||
queue: 'postwork',
|
||
state,
|
||
phase: 'meta',
|
||
label: 'Meta',
|
||
ts: 0,
|
||
},
|
||
]
|
||
}
|
||
}
|
||
|
||
const prevBadges = prev[file]
|
||
|
||
if (nextBadges) {
|
||
if (!samePostworkBadgeList(prevBadges, nextBadges)) {
|
||
next[file] = nextBadges
|
||
changed = true
|
||
}
|
||
} else if (file in next) {
|
||
delete next[file]
|
||
changed = true
|
||
}
|
||
}
|
||
|
||
// WICHTIG: stale cleanup erst NACH dem Einsammeln aller aktuellen Dateien
|
||
for (const oldFile of regenerateTaskFilesRef.current) {
|
||
if (!syncedFiles.has(oldFile) && oldFile in next) {
|
||
delete next[oldFile]
|
||
changed = true
|
||
}
|
||
}
|
||
|
||
return changed ? next : prev
|
||
})
|
||
|
||
regenerateTaskFilesRef.current = syncedFiles
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [postworkByFile])
|
||
|
||
useEffect(() => {
|
||
void refreshPostworkState()
|
||
}, [refreshPostworkState])
|
||
|
||
useEffect(() => {
|
||
const timer = window.setInterval(() => {
|
||
void refreshPostworkState()
|
||
}, 1500)
|
||
|
||
return () => {
|
||
window.clearInterval(timer)
|
||
}
|
||
}, [refreshPostworkState])
|
||
|
||
useEffect(() => {
|
||
const onFocus = () => {
|
||
void refreshPostworkState()
|
||
}
|
||
|
||
const onVisibilityChange = () => {
|
||
if (document.visibilityState === 'visible') {
|
||
void refreshPostworkState()
|
||
}
|
||
}
|
||
|
||
const onPageShow = () => {
|
||
void refreshPostworkState()
|
||
}
|
||
|
||
window.addEventListener('focus', onFocus)
|
||
window.addEventListener('pageshow', onPageShow)
|
||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||
|
||
return () => {
|
||
window.removeEventListener('focus', onFocus)
|
||
window.removeEventListener('pageshow', onPageShow)
|
||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||
}
|
||
}, [refreshPostworkState])
|
||
|
||
useEffect(() => {
|
||
if (!canHover) {
|
||
pointerPosRef.current = null
|
||
setHoverTeaserKey(null)
|
||
return
|
||
}
|
||
|
||
let raf: number | null = null
|
||
|
||
const syncHoverFromPointer = () => {
|
||
raf = null
|
||
|
||
const pos = pointerPosRef.current
|
||
if (!pos) {
|
||
setHoverTeaserKey((prev) => (prev === null ? prev : null))
|
||
return
|
||
}
|
||
|
||
const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null
|
||
const hoverHost = el?.closest<HTMLElement>('[data-finished-hover-key]') ?? null
|
||
const nextKey = hoverHost?.dataset.finishedHoverKey ?? null
|
||
|
||
setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey))
|
||
}
|
||
|
||
const scheduleSync = () => {
|
||
if (raf != null) return
|
||
raf = requestAnimationFrame(syncHoverFromPointer)
|
||
}
|
||
|
||
const clearHover = () => {
|
||
pointerPosRef.current = null
|
||
setHoverTeaserKey((prev) => (prev === null ? prev : null))
|
||
}
|
||
|
||
const onPointerMove = (e: PointerEvent) => {
|
||
pointerPosRef.current = { x: e.clientX, y: e.clientY }
|
||
scheduleSync()
|
||
}
|
||
|
||
const onPointerOut = (e: PointerEvent) => {
|
||
if (!e.relatedTarget) {
|
||
clearHover()
|
||
}
|
||
}
|
||
|
||
const onVisibilityChange = () => {
|
||
if (document.visibilityState !== 'visible') {
|
||
clearHover()
|
||
}
|
||
}
|
||
|
||
window.addEventListener('pointermove', onPointerMove, { passive: true })
|
||
window.addEventListener('pointerout', onPointerOut, { passive: true })
|
||
window.addEventListener('blur', clearHover)
|
||
window.addEventListener('scroll', scheduleSync, true)
|
||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||
|
||
return () => {
|
||
if (raf != null) cancelAnimationFrame(raf)
|
||
|
||
window.removeEventListener('pointermove', onPointerMove)
|
||
window.removeEventListener('pointerout', onPointerOut)
|
||
window.removeEventListener('blur', clearHover)
|
||
window.removeEventListener('scroll', scheduleSync, true)
|
||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||
}
|
||
}, [canHover])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
if (!lastAction) {
|
||
localStorage.removeItem(LAST_UNDO_KEY)
|
||
return
|
||
}
|
||
|
||
const payload: PersistedUndoState = {
|
||
v: 1,
|
||
action: lastAction,
|
||
ts: Date.now(),
|
||
}
|
||
|
||
localStorage.setItem(LAST_UNDO_KEY, JSON.stringify(payload))
|
||
} catch {}
|
||
}, [lastAction])
|
||
|
||
useEffect(() => {
|
||
if (effectiveAllMode) return
|
||
if (totalItemsForPagination < 0) return
|
||
|
||
const totalPages = Math.max(1, Math.ceil(totalItemsForPagination / effectivePageSize))
|
||
|
||
if (page > totalPages) {
|
||
onPageChange(totalPages)
|
||
}
|
||
}, [
|
||
effectiveAllMode,
|
||
totalItemsForPagination,
|
||
effectivePageSize,
|
||
page,
|
||
onPageChange,
|
||
])
|
||
|
||
useEffect(() => {
|
||
const m = new Map<string, string>()
|
||
for (const j of viewRows) {
|
||
const f = baseName(j.output || '')
|
||
if (!f) continue
|
||
m.set(f, keyFor(j))
|
||
}
|
||
fileToKeyRef.current = m
|
||
}, [viewRows])
|
||
|
||
useEffect(() => {
|
||
const onPostwork = (ev: Event) => {
|
||
const detail = (ev as CustomEvent<FinishedPostworkBadge>).detail
|
||
const file = String(detail?.file ?? '').trim()
|
||
if (!file) return
|
||
|
||
const phase = String(detail.phase ?? '').trim().toLowerCase()
|
||
|
||
if (
|
||
detail.state === 'done' &&
|
||
(
|
||
phase === 'sprites' ||
|
||
phase === 'thumb' ||
|
||
phase === 'teaser' ||
|
||
phase === 'meta' ||
|
||
phase === 'analyze'
|
||
)
|
||
) {
|
||
void refreshDoneMetaForFile(file)
|
||
}
|
||
|
||
setPostworkByFile((prev) => {
|
||
const cur = Array.isArray(prev[file]) ? prev[file] : []
|
||
|
||
if (detail.state === 'missing') {
|
||
const detailQueue = detail.queue === 'enrich' ? 'enrich' : 'postwork'
|
||
const detailPhase = normalizeFinishedPhase(
|
||
detailQueue,
|
||
String(detail.phase ?? '').trim().toLowerCase()
|
||
)
|
||
|
||
const missingKey = `${detailQueue}:${detailPhase}`
|
||
|
||
const filtered = cur.filter((item) => {
|
||
const itemKey = `${item.queue}:${normalizeFinishedPhase(
|
||
item.queue,
|
||
String(item.phase ?? '').trim().toLowerCase()
|
||
)}`
|
||
|
||
return itemKey !== missingKey
|
||
})
|
||
|
||
if (samePostworkBadgeList(cur, filtered)) {
|
||
return prev
|
||
}
|
||
|
||
const next = { ...prev }
|
||
|
||
if (filtered.length === 0) delete next[file]
|
||
else next[file] = filtered
|
||
|
||
return next
|
||
}
|
||
|
||
const incoming: FinishedPostworkBadge = {
|
||
...detail,
|
||
label: String(detail?.label ?? '').trim() === file ? undefined : detail?.label,
|
||
error: String(detail?.error ?? '').trim() || undefined,
|
||
}
|
||
|
||
const key = `${incoming.queue}:${normalizeFinishedPhase(
|
||
incoming.queue,
|
||
String(incoming.phase ?? '').trim().toLowerCase()
|
||
)}`
|
||
|
||
let replaced = false
|
||
const merged = cur.map((item) => {
|
||
const itemKey = `${item.queue}:${normalizeFinishedPhase(
|
||
item.queue,
|
||
String(item.phase ?? '').trim().toLowerCase()
|
||
)}`
|
||
|
||
if (itemKey !== key) return item
|
||
|
||
replaced = true
|
||
|
||
if ((item.ts ?? 0) > (incoming.ts ?? 0)) {
|
||
return item
|
||
}
|
||
|
||
return preferProgressLabel(incoming, item)
|
||
})
|
||
|
||
const nextBadges = replaced ? merged : [...merged, incoming]
|
||
|
||
if (samePostworkBadgeList(cur, nextBadges)) {
|
||
return prev
|
||
}
|
||
|
||
return {
|
||
...prev,
|
||
[file]: nextBadges,
|
||
}
|
||
})
|
||
}
|
||
|
||
window.addEventListener('finished-downloads:postwork', onPostwork as EventListener)
|
||
return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener)
|
||
}, [refreshDoneMetaForFile])
|
||
|
||
useEffect(() => {
|
||
const onIntegrityRefresh = (ev: Event) => {
|
||
const detail = (ev as CustomEvent<{ file?: string; all?: boolean }>).detail
|
||
const file = String(detail?.file ?? '').trim()
|
||
|
||
if (file) {
|
||
const clean = baseName(file)
|
||
if (clean) {
|
||
void refreshDoneMetaForFile(clean)
|
||
}
|
||
return
|
||
}
|
||
|
||
const files = new Set(
|
||
rows
|
||
.map((job) => baseName(job.output || ''))
|
||
.filter(Boolean)
|
||
)
|
||
|
||
for (const f of files) {
|
||
void refreshDoneMetaForFile(f)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('finished-downloads:integrity-refresh', onIntegrityRefresh as EventListener)
|
||
return () => {
|
||
window.removeEventListener('finished-downloads:integrity-refresh', onIntegrityRefresh as EventListener)
|
||
}
|
||
}, [rows, refreshDoneMetaForFile])
|
||
|
||
useEffect(() => {
|
||
const onExternalDelete = (ev: Event) => {
|
||
const detail = (ev as CustomEvent<{ file: string; phase: 'start' | 'success' | 'error' }>).detail
|
||
if (!detail?.file) return
|
||
|
||
const key = fileToKeyRef.current.get(detail.file) || detail.file
|
||
|
||
if (detail.phase === 'start') {
|
||
markDeleting(key, true)
|
||
animateRemove(key, detail.file)
|
||
return
|
||
}
|
||
|
||
if (detail.phase === 'error') {
|
||
restoreRow(key, detail.file)
|
||
swipeRefs.current.get(key)?.reset()
|
||
return
|
||
}
|
||
|
||
if (detail.phase === 'success') {
|
||
markDeleting(key, false)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
||
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
||
}, [animateRemove, markDeleting, restoreRow])
|
||
|
||
useEffect(() => {
|
||
const onExternalRename = (ev: Event) => {
|
||
const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail
|
||
const oldFile = String(detail?.oldFile ?? '').trim()
|
||
const newFile = String(detail?.newFile ?? '').trim()
|
||
if (!oldFile || !newFile || oldFile === newFile) return
|
||
|
||
applyRename(oldFile, newFile)
|
||
}
|
||
|
||
window.addEventListener('finished-downloads:rename', onExternalRename as EventListener)
|
||
return () => window.removeEventListener('finished-downloads:rename', onExternalRename as EventListener)
|
||
}, [applyRename])
|
||
|
||
useEffect(() => {
|
||
const shouldClampFromVisibleRows = effectiveAllMode
|
||
|
||
if (!shouldClampFromVisibleRows) return
|
||
|
||
const totalPages = Math.max(1, Math.ceil(visibleRows.length / effectivePageSize))
|
||
|
||
if (page > totalPages) {
|
||
onPageChange(totalPages)
|
||
}
|
||
}, [
|
||
effectiveAllMode,
|
||
visibleRows.length,
|
||
page,
|
||
effectivePageSize,
|
||
onPageChange,
|
||
])
|
||
|
||
useEffect(() => {
|
||
const active =
|
||
!canHover &&
|
||
effectiveTeaserPlaybackMode !== 'still' &&
|
||
(view === 'cards' || view === 'gallery' || view === 'table')
|
||
|
||
if (!active) {
|
||
setTeaserKey(null)
|
||
teaserIORef.current?.disconnect()
|
||
teaserIORef.current = null
|
||
return
|
||
}
|
||
|
||
if (view === 'cards' && inlinePlay?.key) {
|
||
setTeaserKey(inlinePlay.key)
|
||
return
|
||
}
|
||
|
||
teaserIORef.current?.disconnect()
|
||
|
||
const io = new IntersectionObserver(
|
||
(entries) => {
|
||
let bestKey: string | null = null
|
||
let bestRatio = 0
|
||
|
||
for (const ent of entries) {
|
||
if (!ent.isIntersecting) continue
|
||
const k = elToKeyRef.current.get(ent.target)
|
||
if (!k) continue
|
||
|
||
if (ent.intersectionRatio > bestRatio) {
|
||
bestRatio = ent.intersectionRatio
|
||
bestKey = k
|
||
}
|
||
}
|
||
|
||
if (bestKey) {
|
||
pendingTeaserKeyRef.current = bestKey
|
||
|
||
if (teaserKeyRafRef.current == null) {
|
||
teaserKeyRafRef.current = requestAnimationFrame(() => {
|
||
teaserKeyRafRef.current = null
|
||
const nextKey = pendingTeaserKeyRef.current
|
||
setTeaserKey((prev) => (prev === nextKey ? prev : nextKey))
|
||
})
|
||
}
|
||
}
|
||
},
|
||
{
|
||
threshold: [0, 0.15, 0.3, 0.5, 0.7, 0.9],
|
||
rootMargin: '0px',
|
||
}
|
||
)
|
||
|
||
teaserIORef.current = io
|
||
|
||
for (const [k, el] of teaserHostsRef.current) {
|
||
elToKeyRef.current.set(el, k)
|
||
io.observe(el)
|
||
}
|
||
|
||
return () => {
|
||
io.disconnect()
|
||
if (teaserIORef.current === io) teaserIORef.current = null
|
||
}
|
||
}, [view, effectiveTeaserPlaybackMode, canHover, inlinePlay?.key])
|
||
|
||
useEffect(() => {
|
||
if (!isSmall || view !== 'cards') return
|
||
if (effectiveTeaserPlaybackMode === 'still') {
|
||
setTeaserKey(null)
|
||
return
|
||
}
|
||
|
||
const top = pageRows[0]
|
||
if (!top) {
|
||
setTeaserKey(null)
|
||
return
|
||
}
|
||
|
||
const topKey = keyFor(top)
|
||
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
|
||
}, [isSmall, view, pageRows, effectiveTeaserPlaybackMode])
|
||
|
||
useEffect(() => {
|
||
if (!isSmall) {
|
||
swipeRefs.current = new Map()
|
||
}
|
||
}, [isSmall])
|
||
|
||
useEffect(() => {
|
||
if (selectedCount === 0 && selectionActionsOpen) {
|
||
setSelectionActionsOpen(false)
|
||
}
|
||
}, [selectedCount, selectionActionsOpen])
|
||
|
||
const desktopToolbarControlsClass = [
|
||
'flex flex-col gap-3',
|
||
].join(' ')
|
||
|
||
const desktopToolbarLeftClass = [
|
||
'flex min-w-0 flex-col gap-2',
|
||
].join(' ')
|
||
|
||
const handleToggleSelectAllPage = useCallback((checked: boolean) => {
|
||
if (checked) {
|
||
selectPageRows(pageRows)
|
||
return
|
||
}
|
||
|
||
deselectPageRows(pageRows)
|
||
}, [pageRows, selectPageRows, deselectPageRows])
|
||
|
||
const renderClickablePostworkBadge = useCallback(
|
||
(badge?: FinishedPostworkSummary) =>
|
||
renderFinishedPostworkBadge(badge, {
|
||
clickable: true,
|
||
disablePopover: isSmall,
|
||
onClick: () => {
|
||
if (!badge?.file) return
|
||
if (badge.state === 'running' || badge.state === 'queued') return
|
||
void regenerateAssetsForFile(badge.file)
|
||
},
|
||
}),
|
||
[isSmall, regenerateAssetsForFile]
|
||
)
|
||
|
||
const isCompactDesktop = useMediaQuery('(min-width: 640px) and (max-width: 1399px)')
|
||
|
||
const keepToggleLabel = 'Behaltene Downloads anzeigen'
|
||
|
||
const corruptToggleLabel = 'Nur fehlerhafte Downloads'
|
||
|
||
const scrollFinishedListToTop = useCallback(() => {
|
||
if (typeof document !== 'undefined') {
|
||
const scrollArea = document.querySelector<HTMLElement>('[data-app-scroll-area="true"]')
|
||
|
||
if (scrollArea) {
|
||
scrollArea.scrollTo({ top: 0, behavior: 'auto' })
|
||
return
|
||
}
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.scrollTo({ top: 0, behavior: 'auto' })
|
||
}
|
||
}, [])
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// render
|
||
// -----------------------------------------------------------------------------
|
||
return (
|
||
<>
|
||
<div className="mx-auto grid w-full max-w-7xl gap-y-2">
|
||
{/* Toolbar links außerhalb des Tabellen-Containers */}
|
||
<aside className="relative z-40 min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:w-[288px] xl:w-[304px] lg:-translate-x-[calc(100%+1rem)] lg:self-start lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-auto lg:overflow-x-hidden">
|
||
<div
|
||
className="
|
||
rounded-xl border border-gray-200/70 bg-white/95 shadow-sm
|
||
backdrop-blur supports-[backdrop-filter]:bg-white/90
|
||
dark:border-white/10 dark:bg-gray-950/95 dark:supports-[backdrop-filter]:bg-gray-950/90
|
||
"
|
||
>
|
||
{/* Desktop */}
|
||
{/* Sidebar */}
|
||
<div className="hidden sm:block px-3 py-2.5">
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||
Downloads
|
||
</div>
|
||
|
||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||
{totalItemsForPagination} abgeschlossen
|
||
</div>
|
||
</div>
|
||
|
||
{selectedCount > 0 ? (
|
||
<Button
|
||
size="sm"
|
||
variant="soft"
|
||
disabled={bulkBusy}
|
||
onClick={() => setSelectionActionsOpen((v) => !v)}
|
||
title={
|
||
selectionActionsOpen
|
||
? 'Auswahl-Aktionen ausblenden'
|
||
: 'Auswahl-Aktionen anzeigen'
|
||
}
|
||
className={[
|
||
'shrink-0 whitespace-nowrap font-semibold',
|
||
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
||
].join(' ')}
|
||
>
|
||
{selectedCount} ausgewählt
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
|
||
<div
|
||
className={[
|
||
'overflow-hidden transition-[max-height,opacity,border-color] duration-200 ease-in-out',
|
||
selectionActionsVisible
|
||
? 'max-h-72 opacity-100 border-t border-gray-200/60 dark:border-white/10'
|
||
: 'max-h-0 opacity-0 border-t border-transparent dark:border-transparent',
|
||
].join(' ')}
|
||
aria-hidden={!selectionActionsVisible}
|
||
>
|
||
<div
|
||
className={[
|
||
'px-3 py-3 transition-transform duration-200 ease-in-out',
|
||
selectionActionsVisible
|
||
? 'translate-y-0'
|
||
: '-translate-y-1 pointer-events-none',
|
||
].join(' ')}
|
||
>
|
||
<div className="sm:hidden">
|
||
{bulkBusy ? (
|
||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-gray-600 dark:text-gray-300">
|
||
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
|
||
<span>Wird verarbeitet…</span>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="soft"
|
||
color={allPageSelected ? 'amber' : 'indigo'}
|
||
className="w-full justify-center"
|
||
leadingIcon={
|
||
allPageSelected
|
||
? <XMarkIcon className="size-4 shrink-0" />
|
||
: <CheckIcon className="size-4 shrink-0" />
|
||
}
|
||
disabled={bulkBusy}
|
||
onClick={() => {
|
||
if (allPageSelected) deselectPageRows(pageRows)
|
||
else selectPageRows(pageRows)
|
||
}}
|
||
>
|
||
{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
|
||
</Button>
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||
disabled={bulkBusy}
|
||
onClick={clearSelection}
|
||
className="w-full justify-center"
|
||
>
|
||
Auswahl aufheben
|
||
</Button>
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="soft"
|
||
color="emerald"
|
||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||
disabled={bulkBusy}
|
||
onClick={() => void bulkKeepSelected()}
|
||
className="w-full justify-center"
|
||
>
|
||
Behalten
|
||
</Button>
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="soft"
|
||
color="red"
|
||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||
disabled={bulkBusy}
|
||
onClick={() => void bulkDeleteSelected()}
|
||
className="w-full justify-center"
|
||
>
|
||
Löschen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hidden sm:grid sm:grid-cols-2 sm:gap-2">
|
||
{bulkBusy ? (
|
||
<div className="mr-2 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
|
||
<span>Wird verarbeitet…</span>
|
||
</div>
|
||
) : null}
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="soft"
|
||
color={allPageSelected ? 'amber' : 'indigo'}
|
||
leadingIcon={
|
||
allPageSelected
|
||
? <XMarkIcon className="size-4 shrink-0" />
|
||
: <CheckIcon className="size-4 shrink-0" />
|
||
}
|
||
disabled={bulkBusy}
|
||
onClick={() => {
|
||
if (allPageSelected) deselectPageRows(pageRows)
|
||
else selectPageRows(pageRows)
|
||
}}
|
||
>
|
||
{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
|
||
</Button>
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||
disabled={bulkBusy}
|
||
onClick={clearSelection}
|
||
>
|
||
Auswahl aufheben
|
||
</Button>
|
||
|
||
<div className="col-span-2 h-px bg-gray-200 dark:bg-white/10" />
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="soft"
|
||
color="emerald"
|
||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||
disabled={bulkBusy}
|
||
onClick={() => void bulkKeepSelected()}
|
||
>
|
||
Behalten
|
||
</Button>
|
||
|
||
<Button
|
||
size="sm"
|
||
variant="soft"
|
||
color="red"
|
||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||
disabled={bulkBusy}
|
||
onClick={() => void bulkDeleteSelected()}
|
||
>
|
||
Löschen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative w-full">
|
||
<TextInput
|
||
value={searchQuery}
|
||
size="sm"
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Suchen…"
|
||
className={hasSearchQuery ? 'w-full pr-9' : 'w-full'}
|
||
/>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={clearSearch}
|
||
aria-label="Suche leeren"
|
||
title="Suche leeren"
|
||
className={[
|
||
'absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md transition-colors',
|
||
hasSearchQuery
|
||
? 'text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-white/10 dark:hover:text-gray-200'
|
||
: 'pointer-events-none opacity-0',
|
||
].join(' ')}
|
||
>
|
||
<XMarkIcon className="size-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="w-full">
|
||
<ButtonGroup
|
||
value={view}
|
||
onChange={(id) => setView(id as ViewMode)}
|
||
size={isCompactDesktop ? 'sm' : 'md'}
|
||
ariaLabel="Ansicht"
|
||
items={[
|
||
{
|
||
id: 'table',
|
||
icon: <TableCellsIcon className="size-5" />,
|
||
label: isCompactDesktop ? undefined : 'Tabelle',
|
||
srLabel: 'Tabelle',
|
||
},
|
||
{
|
||
id: 'cards',
|
||
icon: <RectangleStackIcon className="size-5" />,
|
||
label: isCompactDesktop ? undefined : 'Cards',
|
||
srLabel: 'Cards',
|
||
},
|
||
{
|
||
id: 'gallery',
|
||
icon: <Squares2X2Icon className="size-5" />,
|
||
label: isCompactDesktop ? undefined : 'Galerie',
|
||
srLabel: 'Galerie',
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div className={desktopToolbarControlsClass}>
|
||
<div className={desktopToolbarLeftClass}>
|
||
<div className="grid w-full grid-cols-1 gap-2">
|
||
<div
|
||
className={[
|
||
'flex w-full min-w-0 items-center rounded-lg border border-gray-200/70 bg-white/60 dark:border-white/10 dark:bg-white/5',
|
||
'min-h-9 px-3 py-1 gap-2',
|
||
].join(' ')}
|
||
>
|
||
<LabeledSwitch
|
||
compact={true}
|
||
label={keepToggleLabel}
|
||
checked={includeKeep}
|
||
onChange={(checked) => {
|
||
if (page !== 1) onPageChange(1)
|
||
onIncludeKeepChange(checked)
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
className={[
|
||
'flex w-full min-w-0 items-center rounded-lg border border-gray-200/70 bg-white/60 dark:border-white/10 dark:bg-white/5',
|
||
isCompactDesktop ? 'h-8 px-2 gap-1.5' : 'h-9 px-3 gap-2',
|
||
].join(' ')}
|
||
>
|
||
<LabeledSwitch
|
||
compact={true}
|
||
label={corruptToggleLabel}
|
||
checked={showOnlyCorrupt}
|
||
onChange={(checked) => {
|
||
if (page !== 1) onPageChange(1)
|
||
setShowOnlyCorrupt(checked)
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<Button
|
||
size={isCompactDesktop ? 'sm' : 'md'}
|
||
variant={lastAction ? 'soft' : 'secondary'}
|
||
color="emerald"
|
||
disabled={!lastAction || undoing}
|
||
onClick={undoLastAction}
|
||
leadingIcon={<ArrowUturnLeftIcon className="size-4 shrink-0" />}
|
||
title={
|
||
!lastAction
|
||
? 'Keine Aktion zum Rückgängig machen'
|
||
: `Letzte Aktion rückgängig machen (${lastAction.kind})`
|
||
}
|
||
className="w-full justify-center"
|
||
>
|
||
<span className={isCompactDesktop ? 'sr-only' : ''}>Undo</span>
|
||
</Button>
|
||
|
||
{view !== 'table' ? (
|
||
<div className="w-full min-w-0">
|
||
<label className="sr-only" htmlFor="finished-sort">
|
||
Sortierung
|
||
</label>
|
||
<select
|
||
id="finished-sort"
|
||
value={sortMode}
|
||
onChange={(e) => {
|
||
const m = e.target.value as DoneSortMode
|
||
onSortModeChange(m)
|
||
if (page !== 1) onPageChange(1)
|
||
}}
|
||
className="
|
||
h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
|
||
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
|
||
"
|
||
>
|
||
<option value="completed_desc">Fertiggestellt am ↓</option>
|
||
<option value="completed_asc">Fertiggestellt am ↑</option>
|
||
<option value="file_asc">Modelname A→Z</option>
|
||
<option value="file_desc">Modelname Z→A</option>
|
||
<option value="duration_desc">Dauer ↓</option>
|
||
<option value="duration_asc">Dauer ↑</option>
|
||
<option value="size_desc">Größe ↓</option>
|
||
<option value="size_asc">Größe ↑</option>
|
||
</select>
|
||
</div>
|
||
) : null}
|
||
|
||
{view === 'gallery' ? (
|
||
<div className="w-full min-w-0">
|
||
<Slider
|
||
id="finished-gallery-card-scale"
|
||
label="Größe"
|
||
min={0}
|
||
max={GALLERY_CARD_SCALE_PRESETS.length - 1}
|
||
step={1}
|
||
value={galleryCardScaleIndex}
|
||
onChange={handleGalleryScalePresetChange}
|
||
onChangeEnd={handleGalleryScalePresetChange}
|
||
valueFormatter={(v) => {
|
||
const idx = clampPresetIndex(v)
|
||
return GALLERY_CARD_SCALE_PRESETS[idx].label
|
||
}}
|
||
showValueLabel
|
||
compact
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile */}
|
||
<div className="sm:hidden">
|
||
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
|
||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||
Abgeschlossene Downloads
|
||
</div>
|
||
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
|
||
{totalItemsForPagination}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||
<LoadingSpinner
|
||
size="lg"
|
||
className={[
|
||
'text-indigo-500 transition-opacity duration-150 pointer-events-none',
|
||
allDoneJobsLoading ? 'opacity-100' : 'opacity-0',
|
||
].join(' ')}
|
||
srLabel="Lade Downloads…"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-3 pt-1.5 pb-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<Button
|
||
size="md"
|
||
variant={lastAction ? 'soft' : 'secondary'}
|
||
color="emerald"
|
||
className="flex-1"
|
||
disabled={!lastAction || undoing}
|
||
onClick={undoLastAction}
|
||
leadingIcon={<ArrowUturnLeftIcon className="size-4 shrink-0" />}
|
||
title={
|
||
!lastAction
|
||
? 'Keine Aktion zum Rückgängig machen'
|
||
: `Letzte Aktion rückgängig machen (${lastAction.kind})`
|
||
}
|
||
>
|
||
Undo
|
||
</Button>
|
||
|
||
<div className="flex-1">
|
||
<ButtonGroup
|
||
value={view}
|
||
onChange={(id) => setView(id as ViewMode)}
|
||
size="sm"
|
||
ariaLabel="Ansicht"
|
||
items={[
|
||
{
|
||
id: 'table',
|
||
icon: <TableCellsIcon className="size-4" />,
|
||
label: undefined,
|
||
srLabel: 'Tabelle',
|
||
},
|
||
{
|
||
id: 'cards',
|
||
icon: <RectangleStackIcon className="size-4" />,
|
||
label: undefined,
|
||
srLabel: 'Cards',
|
||
},
|
||
{
|
||
id: 'gallery',
|
||
icon: <Squares2X2Icon className="size-4" />,
|
||
label: undefined,
|
||
srLabel: 'Galerie',
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{selectedCount > 0 ? (
|
||
<Button
|
||
size="md"
|
||
variant="soft"
|
||
disabled={bulkBusy}
|
||
onClick={() => setSelectionActionsOpen((v) => !v)}
|
||
title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahl-Aktionen anzeigen'}
|
||
className={[
|
||
'shrink-0 whitespace-nowrap px-3 font-semibold',
|
||
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
||
].join(' ')}
|
||
>
|
||
{selectedCount} ausgewählt
|
||
</Button>
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
className="relative inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white shadow-sm
|
||
hover:bg-gray-50 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-white/10"
|
||
onClick={() => setMobileOptionsOpen((v) => !v)}
|
||
aria-expanded={mobileOptionsOpen}
|
||
aria-controls="finished-mobile-options"
|
||
aria-label="Filter & Optionen"
|
||
>
|
||
<AdjustmentsHorizontalIcon className="size-5" />
|
||
{globalFilterActive || includeKeep ? (
|
||
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-indigo-500 ring-2 ring-white dark:ring-gray-950" />
|
||
) : null}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
|
||
<div className="hidden sm:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2.5">
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||
Tag-Filter
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
{renderTagFilterControls()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div
|
||
id="finished-mobile-options"
|
||
className={[
|
||
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
|
||
mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
|
||
].join(' ')}
|
||
>
|
||
<div className="border-t border-gray-200/60 dark:border-white/10 p-3">
|
||
<div className="space-y-2">
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||
<div className="flex items-center gap-2">
|
||
<TextInput
|
||
value={searchQuery}
|
||
size="sm"
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Suchen…"
|
||
/>
|
||
{(searchQuery || '').trim() !== '' ? (
|
||
<Button size="sm" variant="soft" onClick={clearSearch}>
|
||
Clear
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
Keep anzeigen
|
||
</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Behaltene Downloads in der Liste
|
||
</div>
|
||
</div>
|
||
|
||
<Switch
|
||
checked={includeKeep}
|
||
onChange={(checked) => {
|
||
if (page !== 1) onPageChange(1)
|
||
onIncludeKeepChange(checked)
|
||
}}
|
||
ariaLabel="Behaltene Downloads anzeigen"
|
||
size="default"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
Nur fehlerhafte Downloads
|
||
</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Zeigt nur auffällige Sprite-/Ende-Prüfungen
|
||
</div>
|
||
</div>
|
||
|
||
<Switch
|
||
checked={showOnlyCorrupt}
|
||
onChange={(checked) => {
|
||
if (page !== 1) onPageChange(1)
|
||
setShowOnlyCorrupt(checked)
|
||
}}
|
||
ariaLabel="Nur fehlerhafte Downloads anzeigen"
|
||
size="default"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{view !== 'table' ? (
|
||
<div className="space-y-2">
|
||
{view === 'gallery' ? (
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||
<Slider
|
||
id="finished-gallery-card-scale-mobile"
|
||
label="Kartengröße"
|
||
min={0}
|
||
max={GALLERY_CARD_SCALE_PRESETS.length - 1}
|
||
step={1}
|
||
value={galleryCardScaleIndex}
|
||
onChange={handleGalleryScalePresetChange}
|
||
onChangeEnd={handleGalleryScalePresetChange}
|
||
valueFormatter={(v) => {
|
||
const idx = clampPresetIndex(v)
|
||
return GALLERY_CARD_SCALE_PRESETS[idx].label
|
||
}}
|
||
showValueLabel
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
|
||
Sortierung
|
||
</div>
|
||
<select
|
||
id="finished-sort-mobile"
|
||
value={sortMode}
|
||
onChange={(e) => {
|
||
const m = e.target.value as DoneSortMode
|
||
onSortModeChange(m)
|
||
if (page !== 1) onPageChange(1)
|
||
}}
|
||
className="
|
||
w-full h-10 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
|
||
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
|
||
"
|
||
>
|
||
<option value="completed_desc">Fertiggestellt am ↓</option>
|
||
<option value="completed_asc">Fertiggestellt am ↑</option>
|
||
<option value="file_asc">Modelname A→Z</option>
|
||
<option value="file_desc">Modelname Z→A</option>
|
||
<option value="duration_desc">Dauer ↓</option>
|
||
<option value="duration_asc">Dauer ↑</option>
|
||
<option value="size_desc">Größe ↓</option>
|
||
<option value="size_asc">Größe ↑</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
|
||
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
|
||
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-2">
|
||
Tag-Filter
|
||
</div>
|
||
|
||
{renderTagFilterControls({ mobile: true })}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="relative z-0 min-w-0 lg:col-start-1 lg:row-start-1">
|
||
{finishedDownloadsLoading ? (
|
||
<Card grayBody>
|
||
<div className="flex items-center gap-3">
|
||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
|
||
<LoadingSpinner size="md" className="text-indigo-500" srLabel="Lade abgeschlossene Downloads…" />
|
||
</div>
|
||
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||
Abgeschlossene Downloads werden geladen…
|
||
</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Bitte kurz warten, die Liste wird vom Backend geladen.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
) : emptyFolder ? (
|
||
<Card grayBody>
|
||
<div className="flex items-center gap-3">
|
||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
|
||
<span className="text-lg">📁</span>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||
Keine abgeschlossenen Downloads
|
||
</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Im Zielordner ist aktuell nichts vorhanden.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
) : emptyByFilter ? (
|
||
<Card grayBody>
|
||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||
Keine Treffer für die aktuellen Filter.
|
||
</div>
|
||
|
||
{tagFilter.length > 0 ||
|
||
(searchQuery || '').trim() !== '' ||
|
||
showOnlyCorrupt ? (
|
||
<div className="mt-2 flex flex-wrap gap-3">
|
||
{tagFilter.length > 0 ? (
|
||
<button
|
||
type="button"
|
||
className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
|
||
onClick={clearTagFilter}
|
||
>
|
||
Tag-Filter zurücksetzen
|
||
</button>
|
||
) : null}
|
||
|
||
{(searchQuery || '').trim() !== '' ? (
|
||
<button
|
||
type="button"
|
||
className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
|
||
onClick={clearSearch}
|
||
>
|
||
Suche zurücksetzen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{showOnlyCorrupt ? (
|
||
<button
|
||
type="button"
|
||
className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
|
||
onClick={() => setShowOnlyCorrupt(false)}
|
||
>
|
||
Fehlerfilter zurücksetzen
|
||
</button>
|
||
) : null}
|
||
</Card>
|
||
) : (
|
||
<div className="pb-24 sm:pb-20">
|
||
{view === 'cards' && (
|
||
<div className={isSmall ? ['relative z-0 pt-10', cardsMobileOffsetTopClass, cardsMobileOffsetBottomClass].filter(Boolean).join(' ') : ''}>
|
||
<FinishedDownloadsCardsView
|
||
rows={cardRows}
|
||
selectionStore={selectionStore}
|
||
onToggleSelected={toggleSelected}
|
||
onToggleSelectAllPage={handleToggleSelectAllPage}
|
||
onSelectOnly={selectOnly}
|
||
isSmall={isSmall}
|
||
jobForDetails={jobForDetails}
|
||
isLoading={allDoneJobsLoading}
|
||
blurPreviews={blurPreviews}
|
||
durations={durations}
|
||
teaserState={teaserState}
|
||
inlinePlay={inlinePlay}
|
||
deletingKeys={deletingKeys}
|
||
keepingKeys={keepingKeys}
|
||
removingKeys={removingKeys}
|
||
swipeRefs={swipeRefs}
|
||
keyFor={keyFor}
|
||
baseName={baseName}
|
||
modelNameFromOutput={modelNameFromOutput}
|
||
runtimeOf={runtimeOf}
|
||
sizeBytesOf={sizeBytesOf}
|
||
formatBytes={formatBytes}
|
||
lower={lowerTag}
|
||
onOpenPlayer={openPlayerAndPauseTeasers}
|
||
openPlayer={openPlayer}
|
||
onOpenPlayerAt={openPlayerAt}
|
||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||
startInlineAt={startInlineAt}
|
||
startInline={startInline}
|
||
tryAutoplayInline={tryAutoplayInline}
|
||
registerTeaserHost={registerTeaserHost}
|
||
handleDuration={handleDuration}
|
||
handleResolution={handleResolution}
|
||
resolutionLabelOf={resolutionLabelOf}
|
||
deleteVideo={deleteVideo}
|
||
keepVideo={keepVideo}
|
||
releasePlayingFile={releasePlayingFile}
|
||
modelsByKey={modelsByKey}
|
||
onToggleHot={toggleHotVideo}
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleLike={onToggleLike}
|
||
onToggleWatch={onToggleWatch}
|
||
onSplit={onSplitJob}
|
||
onAddToDownloads={onAddToDownloads}
|
||
activeTagSet={activeTagSet}
|
||
onToggleTagFilter={toggleTagFilter}
|
||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||
assetNonce={assetNonce ?? 0}
|
||
postworkByFile={effectiveBadgeSummaryByFile}
|
||
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
||
forcePreviewMuted={forcePreviewMuted}
|
||
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
|
||
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
|
||
renderPostworkBadge={renderClickablePostworkBadge}
|
||
renderIntegrityBadge={renderIntegrityBadge}
|
||
renderRatingOverlay={renderRatingOverlay}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{view === 'table' && (
|
||
<FinishedDownloadsTableView
|
||
rows={pageRows}
|
||
isLoading={allDoneJobsLoading}
|
||
selectedKeys={selectedKeys}
|
||
selectionModeActive={selectionModeActive}
|
||
onToggleSelected={toggleSelected}
|
||
onToggleSelectAllPage={(checked) => {
|
||
if (checked) selectPageRows(pageRows)
|
||
else deselectPageRows(pageRows)
|
||
}}
|
||
jobForDetails={jobForDetails}
|
||
keyFor={keyFor}
|
||
baseName={baseName}
|
||
lower={lowerTag}
|
||
modelNameFromOutput={modelNameFromOutput}
|
||
runtimeOf={runtimeOf}
|
||
sizeBytesOf={sizeBytesOf}
|
||
formatBytes={formatBytes}
|
||
resolutions={resolutions}
|
||
durations={durations}
|
||
canHover={canHover}
|
||
teaserState={teaserState}
|
||
setHoverTeaserKey={setHoverTeaserKey}
|
||
registerTeaserHost={registerTeaserHost}
|
||
handleDuration={handleDuration}
|
||
handleResolution={handleResolution}
|
||
blurPreviews={blurPreviews}
|
||
assetNonce={assetNonce}
|
||
deletingKeys={deletingKeys}
|
||
keepingKeys={keepingKeys}
|
||
removingKeys={removingKeys}
|
||
modelsByKey={modelsByKey}
|
||
activeTagSet={activeTagSet}
|
||
onToggleTagFilter={toggleTagFilter}
|
||
onOpenPlayer={openPlayerAndPauseTeasers}
|
||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||
onSortModeChange={onSortModeChange}
|
||
page={page}
|
||
onPageChange={onPageChange}
|
||
onToggleHot={toggleHotVideo}
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleLike={onToggleLike}
|
||
onToggleWatch={onToggleWatch}
|
||
onSplit={onSplitJob}
|
||
onAddToDownloads={onAddToDownloads}
|
||
deleteVideo={deleteVideo}
|
||
keepVideo={keepVideo}
|
||
postworkByFile={effectiveBadgeSummaryByFile}
|
||
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
||
forcePreviewMuted={forcePreviewMuted}
|
||
renderPostworkBadge={renderClickablePostworkBadge}
|
||
renderIntegrityBadge={renderIntegrityBadge}
|
||
renderRatingOverlay={renderRatingOverlay}
|
||
/>
|
||
)}
|
||
|
||
{view === 'gallery' && (
|
||
<FinishedDownloadsGalleryView
|
||
rows={pageRows}
|
||
selectedKeys={selectedKeys}
|
||
selectionModeActive={selectionModeActive}
|
||
onToggleSelected={toggleSelected}
|
||
onSelectOnly={selectOnly}
|
||
isLoading={allDoneJobsLoading}
|
||
jobForDetails={jobForDetails}
|
||
blurPreviews={blurPreviews}
|
||
durations={durations}
|
||
handleDuration={handleDuration}
|
||
handleResolution={handleResolution}
|
||
resolutionLabelOf={resolutionLabelOf}
|
||
teaserState={teaserState}
|
||
keyFor={keyFor}
|
||
baseName={baseName}
|
||
modelNameFromOutput={modelNameFromOutput}
|
||
runtimeOf={runtimeOf}
|
||
sizeBytesOf={sizeBytesOf}
|
||
formatBytes={formatBytes}
|
||
deletingKeys={deletingKeys}
|
||
keepingKeys={keepingKeys}
|
||
removingKeys={removingKeys}
|
||
registerTeaserHost={registerTeaserHost}
|
||
onOpenPlayer={openPlayerAndPauseTeasers}
|
||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||
deleteVideo={deleteVideo}
|
||
keepVideo={keepVideo}
|
||
onToggleHot={toggleHotVideo}
|
||
lower={lowerTag}
|
||
modelsByKey={modelsByKey}
|
||
onToggleFavorite={onToggleFavorite}
|
||
onToggleLike={onToggleLike}
|
||
onToggleWatch={onToggleWatch}
|
||
onSplit={onSplitJob}
|
||
onAddToDownloads={onAddToDownloads}
|
||
activeTagSet={activeTagSet}
|
||
onToggleTagFilter={toggleTagFilter}
|
||
onHoverPreviewKeyChange={setHoverTeaserKey}
|
||
postworkByFile={effectiveBadgeSummaryByFile}
|
||
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
||
forcePreviewMuted={forcePreviewMuted}
|
||
cardScale={galleryCardScale}
|
||
renderPostworkBadge={renderClickablePostworkBadge}
|
||
onRecommendedPageSizeChange={handleGalleryRecommendedPageSizeChange}
|
||
assetNonce={assetNonce ?? 0}
|
||
renderIntegrityBadge={renderIntegrityBadge}
|
||
renderRatingOverlay={renderRatingOverlay}
|
||
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
|
||
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
|
||
{!mobileCardStackMode ? (
|
||
<div className="fixed inset-x-0 bottom-4 z-[70] flex justify-center px-4 pointer-events-none">
|
||
<div className="pointer-events-auto flex w-full max-w-fit flex-col items-center gap-3">
|
||
<div className="shadow-2xl">
|
||
<Pagination
|
||
page={page}
|
||
pageSize={effectivePageSize}
|
||
totalItems={totalItemsForPagination}
|
||
onPageChange={(p) => {
|
||
flushSync(() => {
|
||
setInlinePlay(null)
|
||
setTeaserKey(null)
|
||
setHoverTeaserKey(null)
|
||
})
|
||
scrollFinishedListToTop()
|
||
onPageChange(p)
|
||
}}
|
||
showSummary={false}
|
||
prevLabel="Zurück"
|
||
nextLabel="Weiter"
|
||
className="mt-0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)
|
||
}
|