nsfwapp/frontend/src/components/ui/FinishedDownloads.tsx
2026-04-07 07:20:08 +02:00

4313 lines
136 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend\src\components\ui\FinishedDownloads.tsx
'use client'
import * as React from 'react'
import { useMemo, useEffect, useCallback } from 'react'
import Card from './Card'
import type {
ViewMode,
SelectedItem,
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 TagBadge from './TagBadge'
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'
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 } | Promise<void | { undoToken?: 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 | Promise<void>
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
}
const norm = (p: string) => (p || '').replaceAll('\\', '/')
const baseName = (p: string) => {
const n = norm(p)
const parts = n.split('/')
return parts[parts.length - 1] || ''
}
const keyFor = (j: RecordJob) => {
// ✅ Primär: stabile Job-ID (bleibt gleich bei Rename/HOT)
const id = (j as any)?.id
if (id != null && String(id).trim() !== '') return String(id)
// Fallback (sollte selten sein)
const f = baseName(j.output || '')
return f || 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')
}
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
function formatBytes(bytes?: number | null): string {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
return `${v.toFixed(digits)} ${units[i]}`
}
function useMediaQuery(query: string) {
const [matches, setMatches] = React.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)
return {
...job,
sourceUrl: job.sourceUrl || href,
}
}
const modelNameFromOutput = (output?: string) => {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
const lower = (s: string) => (s || '').trim().toLowerCase()
// Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt)
const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
// stable dedupe (case-insensitive), aber original casing behalten
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
// wie in ModelsTab: alphabetisch (case-insensitive)
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return out
}
// 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] = React.useState(false)
const timeoutRef = React.useRef<number | null>(null)
React.useEffect(() => {
return () => {
if (timeoutRef.current != null) {
window.clearTimeout(timeoutRef.current)
}
}
}, [])
const handleClick = async (e: React.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('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 : {}
merged.analysis = {
...incomingAnalysis,
...existingAnalysis,
ai:
isPlainObject(existingAnalysis.ai) && Object.keys(existingAnalysis.ai).length > 0
? {
...(isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : {}),
...(isPlainObject(existingAnalysis.ai) ? existingAnalysis.ai : {}),
}
: isPlainObject(incomingAnalysis.ai)
? incomingAnalysis.ai
: 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('teilweise 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'
}
}
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 'KI-Analyse'
default:
return p || (queue === 'enrich' ? 'KI-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?.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') ||
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
? `Teilweise 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 isAggregateAssetsPhase(
queue: 'postwork' | 'enrich',
phase?: string
): boolean {
return queue === 'enrich' && String(phase ?? '').trim().toLowerCase() === 'assets'
}
function normalizeFinishedPhase(
queue: 'postwork' | 'enrich',
phase?: string
): string {
const p = String(phase ?? '').trim().toLowerCase()
if (queue === 'enrich') {
if (p === 'analyze') return 'analyze'
return p || 'analyze'
}
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>()
let aggregateAssetsBadge: FinishedPostworkBadge | null = null
for (const badge of realBadges) {
const queue = badge.queue
const rawPhase = String(badge.phase ?? '').trim().toLowerCase()
// ✅ Sammelstatus "enrich:assets" NICHT als eigene Extra-Zeile anzeigen
// sondern später auf die bekannten Schritte verteilen.
if (isAggregateAssetsPhase(queue, rawPhase)) {
if (!aggregateAssetsBadge || (aggregateAssetsBadge.ts ?? 0) <= (badge.ts ?? 0)) {
aggregateAssetsBadge = badge
}
continue
}
const phase = normalizeFinishedPhase(queue, rawPhase)
const key = `${queue}:${phase}`
const prev = byKey.get(key)
if (prev && prev.ts > badge.ts) continue
byKey.set(key, {
key,
queue,
phase,
label: 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,
})
}
// ✅ 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,
})
}
// ✅ Dann ggf. den Sammelstatus "assets" auf alle noch nicht fertigen Schritte abbilden
if (aggregateAssetsBadge) {
for (const stepDef of expectedSteps) {
const cur = byKey.get(stepDef.key)
if (!cur) continue
// Bereits fertige Schritte beibehalten
if (cur.state === 'done') continue
byKey.set(stepDef.key, {
...cur,
state: aggregateAssetsBadge.state,
ts: Math.max(cur.ts || 0, aggregateAssetsBadge.ts || 0),
position: aggregateAssetsBadge.position,
waiting: aggregateAssetsBadge.waiting,
running: aggregateAssetsBadge.running,
maxParallel: aggregateAssetsBadge.maxParallel,
})
}
}
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
? `Teilweise 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 || (step.ts || 0) >= (prev.ts || 0)) {
stepMap.set(step.key, 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
? `Teilweise 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,
}
}
export function renderFinishedPostworkBadge(
summary?: FinishedPostworkSummary,
opts?: {
onClick?: () => void
clickable?: boolean
disablePopover?: boolean
}
): React.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 && (
<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">
{summary.state === 'missing' ? 'Postwork-Phasen' : 'Postwork-Status'}
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400 truncate">
{summary.file}
</div>
</div>
<div className="px-3 py-2 space-y-2">
{summary.steps.map((step) => (
<div key={step.key} className="flex items-center justify-between gap-3">
<div className="min-w-0 text-[12px] text-gray-800 dark:text-gray-200">
{step.label}
</div>
<div className="shrink-0 flex items-center gap-1.5 text-[11px]">
{step.state === 'running' ? (
<>
<LoadingSpinner size="sm" className="shrink-0" srLabel={step.label} />
<span className="text-indigo-700 dark:text-indigo-200">Läuft</span>
</>
) : step.state === 'queued' ? (
<>
<ClockIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
<span className="text-amber-700 dark:text-amber-200">
{typeof step.position === 'number' && step.position > 0
? `Wartet #${step.position}`
: 'Wartet'}
</span>
</>
) : step.state === 'done' ? (
<>
<CheckCircleIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
<span className="text-emerald-700 dark:text-emerald-200">Fertig</span>
</>
) : step.state === 'error' ? (
<div className="flex items-center gap-1.5">
<ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
<span
className="text-red-700 dark:text-red-200"
title={step.error || 'Unbekannter Fehler'}
>
Fehler
</span>
{step.error ? <CopyErrorButton text={step.error} /> : null}
</div>
) : step.state === 'missing' ? (
<>
<span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
<span className="text-gray-500 dark:text-gray-400">Kein Status</span>
</>
) : (
<span className="text-gray-500 dark:text-gray-400"></span>
)}
</div>
</div>
))}
</div>
</div>
)
}
>
{badgeNode}
</HoverPopover>
)
}
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,
}: 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 =
pauseTeasers ? 'still' : 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 previewMuted = !Boolean(teaserAudio)
const cardsMobileOffsetTopClass = 'mt-10'
const cardsMobileOffsetBottomClass = 'mb-2'
// -----------------------------------------------------------------------------
// state
// -----------------------------------------------------------------------------
const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = React.useState(false)
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
const [keepingKeys, setKeepingKeys] = React.useState<Set<string>>(() => new Set())
const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())
const [hiddenFiles, setHiddenFiles] = React.useState<Set<string>>(() => new Set())
const [undoing, setUndoing] = React.useState(false)
const [bulkBusy, setBulkBusy] = React.useState(false)
const [postworkByFile, setPostworkByFile] =
React.useState<Record<string, FinishedPostworkBadge[]>>({})
const [selectedItems, setSelectedItems] =
React.useState<Record<string, SelectedItem>>({})
const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})
const [view, setView] = React.useState<ViewMode>('table')
const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false)
const [galleryCardScale, setGalleryCardScale] = React.useState<number>(1)
const [galleryCardScaleLive, setGalleryCardScaleLive] = React.useState<number>(1)
const [galleryColumnCount, setGalleryColumnCount] = React.useState<number>(1)
const [tagFilter, setTagFilter] = React.useState<string[]>([])
const [searchQuery, setSearchQuery] = React.useState<string>('')
const [durations, setDurations] = React.useState<Record<string, number>>({})
const [resolutions, setResolutions] =
React.useState<Record<string, { w: number; h: number }>>({})
const [inlinePlay, setInlinePlay] =
React.useState<{ key: string; nonce: number } | null>(null)
const [hotBusyKeys, setHotBusyKeys] = React.useState<Set<string>>(() => new Set())
const [lastAction, setLastAction] = React.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
}
})
// -----------------------------------------------------------------------------
// refs
// -----------------------------------------------------------------------------
const pointerPosRef = React.useRef<{ x: number; y: number } | null>(null)
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
const teaserIORef = React.useRef<IntersectionObserver | null>(null)
const elToKeyRef = React.useRef<WeakMap<Element, string>>(new WeakMap())
const teaserKeyRafRef = React.useRef<number | null>(null)
const pendingTeaserKeyRef = React.useRef<string | null>(null)
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
const scaleLiveRafRef = React.useRef<number | null>(null)
const scaleLivePendingRef = React.useRef<number>(1)
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
const fileToKeyRef = React.useRef<Map<string, string>>(new Map())
const durationsRef = React.useRef<Record<string, number>>({})
const durationsFlushTimerRef = React.useRef<number | null>(null)
const resolutionsRef = React.useRef<Record<string, { w: number; h: number }>>({})
const resolutionsFlushTimerRef = React.useRef<number | null>(null)
const removeTimersRef = React.useRef<Map<string, number>>(new Map())
// -----------------------------------------------------------------------------
// memoized derived data
// -----------------------------------------------------------------------------
const forcePreviewMuted =
mobileNeedsTapForAudio && teaserAudio
? !mobileTeaserAudioUnlocked
: false
const deferredSearchQuery = React.useDeferredValue(searchQuery)
const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])
const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems])
const teaserState = useMemo<FinishedDownloadsTeaserState>(
() => ({
mode: effectiveTeaserPlaybackMode,
activeKey: teaserKey,
hoverKey: hoverTeaserKey,
audioEnabled: Boolean(teaserAudio),
}),
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
)
const modelTags = useMemo(() => {
const tagsByModelKey: Record<string, string[]> = {}
const tagSetByModelKey: Record<string, Set<string>> = {}
for (const [k, flags] of Object.entries(modelsByKey ?? {})) {
const key = lower(k)
const arr = parseTags(flags?.tags)
tagsByModelKey[key] = arr
tagSetByModelKey[key] = new Set(arr.map(lower))
}
return { tagsByModelKey, tagSetByModelKey }
}, [modelsByKey])
const searchTokens = useMemo(
() => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
[deferredSearchQuery]
)
const searchActiveForGlobalFetch =
activeTagSet.size > 0 || searchTokens.some((t) => t.length >= 2)
const globalFilterActive = searchActiveForGlobalFetch
const effectiveAllMode = globalFilterActive || loadMode === 'all'
const galleryPageSize = Math.max(2, galleryColumnCount * 2)
const effectivePageSize =
view === 'gallery'
? galleryPageSize
: pageSize
const doneJobsPage = doneJobs
const doneTotalPage = 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) {
const jj = applyRenamedOutputLocal(j)
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])
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
}
}
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, rows])
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 = lower(model)
const tags = modelTags.tagsByModelKey[modelKey] ?? []
const hay = lower([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
for (const t of searchTokens) {
if (!hay.includes(t)) return false
}
return true
})
: base
if (activeTagSet.size === 0) return searched
return searched.filter((j) => {
const modelKey = lower(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
})
}, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens])
const selectedFiles = useMemo(() => {
return Object.values(selectedItems)
.map((x) => String(x.file || '').trim())
.filter(Boolean)
}, [selectedItems])
const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage
const pageRows = useMemo(() => {
if (effectiveAllMode) {
const start = (page - 1) * effectivePageSize
const end = start + effectivePageSize
return visibleRows.slice(start, end)
}
if (view === 'gallery') {
return visibleRows.slice(0, effectivePageSize)
}
return visibleRows
}, [visibleRows, page, effectivePageSize, effectiveAllMode, view])
const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
const emptyByFilter = globalFilterActive && visibleRows.length === 0
const selectedCount = selectedFiles.length
const allPageSelected =
pageRows.length > 0 &&
pageRows.every((j) => selectedKeys.has(keyFor(j)))
// -----------------------------------------------------------------------------
// callbacks
// -----------------------------------------------------------------------------
const handleGalleryScaleLiveChange = useCallback((next: number) => {
const clamped = Math.max(0.8, Math.min(1.35, next))
scaleLivePendingRef.current = clamped
if (scaleLiveRafRef.current != null) return
scaleLiveRafRef.current = requestAnimationFrame(() => {
scaleLiveRafRef.current = null
setGalleryCardScaleLive(scaleLivePendingRef.current)
})
}, [])
const handleGalleryScaleCommit = useCallback((next: number) => {
const clamped = Math.max(0.8, Math.min(1.35, next))
if (scaleLiveRafRef.current != null) {
cancelAnimationFrame(scaleLiveRafRef.current)
scaleLiveRafRef.current = null
}
scaleLivePendingRef.current = clamped
setGalleryCardScaleLive(clamped)
setGalleryCardScale(clamped)
}, [])
const toggleSelected = useCallback((job: RecordJob) => {
const k = keyFor(job)
const file = baseName(job.output || '')
if (!file) return
setSelectedItems((prev) => {
const next = { ...prev }
if (next[k]) {
delete next[k]
} else {
next[k] = { key: k, file }
}
return next
})
}, [])
const clearSelection = useCallback(() => {
setSelectedItems({})
}, [])
const selectPageRows = useCallback((rows: RecordJob[]) => {
setSelectedItems((prev) => {
const next = { ...prev }
for (const j of rows) {
const k = keyFor(j)
const file = baseName(j.output || '')
if (!file) continue
next[k] = { key: k, file }
}
return next
})
}, [])
const deselectPageRows = useCallback((rows: RecordJob[]) => {
setSelectedItems((prev) => {
const next = { ...prev }
for (const j of rows) {
delete next[keyFor(j)]
}
return next
})
}, [])
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 = lower(tag)
setTagFilter((prev) => {
const has = prev.some((t) => lower(t) === k)
return has ? prev.filter((t) => lower(t) !== k) : [...prev, tag]
})
}, [])
const clearTagFilter = useCallback(() => setTagFilter([]), [])
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 (!v) return false
applyInlineVideoPolicy(v, { muted: previewMuted })
const p = v.play?.()
if (p && typeof (p as any).catch === 'function') {
(p as Promise<void>).catch(() => {})
}
return true
}, [previewMuted])
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
}
applyInlineVideoPolicy(v, { muted: previewMuted })
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))
}, [previewMuted])
const openPlayer = useCallback((job: RecordJob) => {
setInlinePlay(null)
onOpenPlayer(job)
}, [onOpenPlayer])
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
setInlinePlay(null)
onOpenPlayer(job, s)
}, [onOpenPlayer])
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 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 ?? 4)
const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)
let lastErr: unknown
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) || attempt >= attempts) {
throw e
}
continue
}
}
throw lastErr instanceof Error
? lastErr
: new Error(String(lastErr ?? 'Unbekannter Fehler'))
},
[releasePlayingFile]
)
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) => {
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else {
setLastAction(null)
}
emitCountHint(-1)
}
})
return res.ok
},
[
deletingKeys,
markDeleting,
onDeleteJob,
withFileReleaseRetry,
runFileMutation,
emitCountHint,
]
)
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) => {
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,
]
)
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
})
setSelectedItems((prev) => {
let changed = false
const next: Record<string, SelectedItem> = {}
for (const [k, item] of Object.entries(prev)) {
if (item.file === oldFile) {
next[k] = { ...item, file: newFile }
changed = true
} else {
next[k] = item
}
}
return changed ? next : prev
})
}, [])
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 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(+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)
setLastAction(null)
},
})
},
[
hotBusyKeys,
markHotBusy,
runFileMutation,
applyRename,
clearRenamePair,
onToggleHot,
withFileReleaseRetry,
]
)
const regenerateAssetsForFile = useCallback(async (file: string) => {
const cleanFile = String(file || '').trim()
if (!cleanFile) return false
setPostworkByFile((prev) => ({
...prev,
[cleanFile]: [
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'postwork',
state: 'queued',
phase: 'meta',
label: 'Meta',
ts: Date.now(),
},
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'postwork',
state: 'queued',
phase: 'thumb',
label: 'Vorschaubild',
ts: Date.now(),
},
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'postwork',
state: 'queued',
phase: 'teaser',
label: 'Teaser',
ts: Date.now(),
},
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'postwork',
state: 'queued',
phase: 'sprites',
label: 'Sprites',
ts: Date.now(),
},
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'enrich',
state: 'queued',
phase: 'analyze',
label: 'KI-Analyse',
ts: Date.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),
})
setPostworkByFile((prev) => ({
...prev,
[cleanFile]: [
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'enrich',
state: 'error',
phase: 'assets',
label: 'Fehler',
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 registerTeaserHost = useCallback(
(key: string) => (el: HTMLDivElement | null) => {
const prev = teaserHostsRef.current.get(key)
if (prev && teaserIORef.current) 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)
}
},
[]
)
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 =
Object.values(selectedItems).find((x) => x.file === file)?.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,
selectedItems,
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 =
Object.values(selectedItems).find((x) => x.file === file)?.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,
selectedItems,
animateRemove,
clearSelection,
emitCountHint,
includeKeep,
notify,
])
// -----------------------------------------------------------------------------
// effects
// -----------------------------------------------------------------------------
useEffect(() => {
if (view !== 'gallery') return
const wanted = Math.max(2, galleryColumnCount * 2)
if (pageSize !== wanted) {
onPageSizeChange?.(wanted)
}
}, [view, galleryColumnCount, pageSize, onPageSizeChange])
useEffect(() => {
try {
const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY)
const parsed = Number(raw)
const initial = Number.isFinite(parsed)
? Math.max(0.8, Math.min(1.35, parsed))
: 1
setGalleryCardScale(initial)
setGalleryCardScaleLive(initial)
scaleLivePendingRef.current = initial
} catch {
setGalleryCardScale(1)
setGalleryCardScaleLive(1)
scaleLivePendingRef.current = 1
}
}, [])
useEffect(() => {
try {
localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScale))
} catch {}
}, [galleryCardScale])
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 (scaleLiveRafRef.current != null) {
cancelAnimationFrame(scaleLiveRafRef.current)
scaleLiveRafRef.current = null
}
}
}, [])
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(() => {
if (!pauseTeasers) return
setTeaserKey(null)
setHoverTeaserKey(null)
setInlinePlay(null)
}, [pauseTeasers])
useEffect(() => {
let alive = true
const loadInitialPostworkState = async () => {
try {
const res = await fetch('/api/tasks/status', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!alive || !data) return
const jobs = Array.isArray(data?.regenerateAssetsJobs)
? data.regenerateAssetsJobs
: []
if (jobs.length > 0) {
setPostworkByFile((prev) => {
const next = { ...prev }
for (const item of jobs) {
const file = String(item?.file ?? '').trim()
if (!file) continue
const assetId =
String(item?.assetId ?? '').trim() || fileStemFromOutput(file)
const phases = Array.isArray(item?.phases) ? item.phases : []
if (phases.length > 0) {
const restored = 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
}
return {
file,
assetId,
queue: queueRaw as 'postwork' | 'enrich',
phase: phaseRaw,
state,
label:
String(p?.label ?? '').trim() ||
labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw),
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
: Date.now(),
}
})
.filter(Boolean) as FinishedPostworkBadge[]
if (restored.length > 0) {
next[file] = restored
continue
}
}
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') {
continue
}
next[file] = [
{
file,
assetId,
queue: 'postwork',
state,
phase: 'meta',
label: 'Meta',
ts: Date.now(),
},
{
file,
assetId,
queue: 'postwork',
state,
phase: 'thumb',
label: 'Vorschaubild',
ts: Date.now(),
},
{
file,
assetId,
queue: 'postwork',
state,
phase: 'teaser',
label: 'Teaser',
ts: Date.now(),
},
{
file,
assetId,
queue: 'postwork',
state,
phase: 'sprites',
label: 'Sprites',
ts: Date.now(),
},
{
file,
assetId,
queue: 'enrich',
state,
phase: 'analyze',
label: 'KI-Analyse',
ts: Date.now(),
},
]
}
return next
})
}
} catch {}
}
void loadInitialPostworkState()
return () => {
alive = false
}
}, [])
useEffect(() => {
const onPointerMove = (e: PointerEvent) => {
pointerPosRef.current = { x: e.clientX, y: e.clientY }
}
window.addEventListener('pointermove', onPointerMove, { passive: true })
return () => window.removeEventListener('pointermove', onPointerMove)
}, [])
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
setPostworkByFile((prev) => {
const next = { ...prev }
if (detail.state === 'missing') {
const cur = Array.isArray(prev[file]) ? prev[file] : []
const missingKey = `${detail.queue}:${String(detail.phase ?? '').trim().toLowerCase() || 'postwork'}`
const filtered = cur.filter((item) => {
const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}`
return itemKey !== missingKey
})
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 cur = Array.isArray(prev[file]) ? prev[file] : []
const key = `${incoming.queue}:${String(incoming.phase ?? '').trim().toLowerCase() || 'postwork'}`
let replaced = false
const merged = cur.map((item) => {
const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}`
if (itemKey !== key) return item
replaced = true
return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming
})
next[file] = replaced ? merged : [...merged, incoming]
return next
})
}
window.addEventListener('finished-downloads:postwork', onPostwork as EventListener)
return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener)
}, [])
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])
// -----------------------------------------------------------------------------
// render
// -----------------------------------------------------------------------------
return (
<>
{/* Toolbar (sticky) */}
<div className="sticky top-[56px] z-20">
<div
className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
"
>
{/* Desktop */}
<div className="hidden sm:block px-3 py-2.5">
<div className="flex items-center gap-2 pb-2">
<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 opacity-0 pointer-events-none',
].join(' ')}
srLabel="Lade Downloads…"
/>
</div>
</div>
<div className="flex items-center gap-3">
<div className="min-w-0 flex flex-1 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}>
Leeren
</Button>
) : null}
</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 font-semibold',
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
].join(' ')}
>
{selectedCount} ausgewählt
</Button>
) : null}
<div className="ml-auto flex shrink-0 items-center gap-2">
<div className="flex h-9 items-center gap-2 rounded-lg border border-gray-200/70 bg-white/60 px-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
label="Behaltene Downloads anzeigen"
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
onIncludeKeepChange(checked)
}}
/>
</div>
{view !== 'table' ? (
<div className="flex items-center gap-3">
{view === 'gallery' ? (
<div className="w-[180px]">
<Slider
id="finished-gallery-card-scale"
label="Größe"
min={0.8}
max={1.35}
value={galleryCardScaleLive}
onChange={handleGalleryScaleLiveChange}
onChangeEnd={handleGalleryScaleCommit}
compact
softStep
/>
</div>
) : null}
<div>
<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 rounded-md border border-gray-200 bg-white px-2 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 AZ</option>
<option value="file_desc">Modelname ZA</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}
<Button
size="md"
variant="secondary"
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>
<ButtonGroup
value={view}
onChange={(id) => setView(id as ViewMode)}
size="md"
ariaLabel="Ansicht"
items={[
{
id: 'table',
icon: <TableCellsIcon className="size-5" />,
label: 'Tabelle',
srLabel: 'Tabelle',
},
{
id: 'cards',
icon: <RectangleStackIcon className="size-5" />,
label: 'Cards',
srLabel: 'Cards',
},
{
id: 'gallery',
icon: <Squares2X2Icon className="size-5" />,
label: 'Galerie',
srLabel: 'Galerie',
},
]}
/>
</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 opacity-0 pointer-events-none',
].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="secondary"
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>
{selectedCount > 0 ? (
<div
className={[
'overflow-hidden border-t border-gray-200/60 dark:border-white/10 transition-all duration-200 ease-in-out',
selectionActionsOpen ? 'max-h-72 opacity-100' : 'max-h-0 opacity-0',
].join(' ')}
aria-hidden={!selectionActionsOpen}
>
<div
className={[
'px-3 py-3 transition-transform duration-200 ease-in-out',
selectionActionsOpen ? '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'}
leadingIcon={
allPageSelected
? <XMarkIcon className="size-4 shrink-0" />
: <CheckIcon className="size-4 shrink-0" />
}
disabled={bulkBusy}
onClick={() => {
if (allPageSelected) deselectPageRows(pageRows)
else selectPageRows(pageRows)
}}
className="w-full justify-center"
>
{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:flex sm:flex-wrap sm:items-center 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="h-5 w-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>
) : null}
{tagFilter.length > 0 ? (
<div className="hidden sm:flex items-center gap-2 border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</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 p-2 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>
{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.8}
max={1.35}
value={galleryCardScaleLive}
onChange={handleGalleryScaleLiveChange}
onChangeEnd={handleGalleryScaleCommit}
softStep
showValueLabel={false}
/>
</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 AZ</option>
<option value="file_desc">Modelname ZA</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>
{tagFilter.length > 0 ? (
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
{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() !== '' ? (
<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}
</Card>
) : (
<div className="pb-24 sm:pb-20">
{view === 'cards' && (
<div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
<FinishedDownloadsCardsView
rows={pageRows}
selectedKeys={selectedKeys}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
isSmall={isSmall}
jobForDetails={jobForDetails}
isLoading={false}
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={lower}
onOpenPlayer={onOpenPlayer}
openPlayer={openPlayer}
onOpenPlayerAt={openPlayerAt}
handleScrubberClickIndex={handleScrubberClickIndex}
startInlineAt={startInlineAt}
startInline={startInline}
tryAutoplayInline={tryAutoplayInline}
registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration}
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={effectivePostworkSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
renderPostworkBadge={(badge) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
})
}
/>
</div>
)}
{view === 'table' && (
<FinishedDownloadsTableView
rows={pageRows}
isLoading={false}
selectedKeys={selectedKeys}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
jobForDetails={jobForDetails}
keyFor={keyFor}
baseName={baseName}
lower={lower}
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={onOpenPlayer}
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={effectivePostworkSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
renderPostworkBadge={(badge) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
})
}
/>
)}
{view === 'gallery' && (
<FinishedDownloadsGalleryView
rows={pageRows}
onColumnCountChange={setGalleryColumnCount}
selectedKeys={selectedKeys}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
isLoading={false}
jobForDetails={jobForDetails}
blurPreviews={blurPreviews}
durations={durations}
handleDuration={handleDuration}
teaserState={teaserState}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
deletedKeys={deletedKeys}
registerTeaserHost={registerTeaserHost}
onOpenPlayer={onOpenPlayer}
handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={toggleHotVideo}
lower={lower}
modelsByKey={modelsByKey}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplitJob}
onAddToDownloads={onAddToDownloads}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
postworkByFile={effectivePostworkSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
cardScale={galleryCardScaleLive}
renderPostworkBadge={(badge) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
})
}
/>
)}
</div>
)}
<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)
})
window.scrollTo({ top: 0, behavior: 'auto' })
onPageChange(p)
}}
showSummary={false}
prevLabel="Zurück"
nextLabel="Weiter"
className="mt-0"
/>
</div>
</div>
</div>
</>
)
}