4375 lines
139 KiB
TypeScript
4375 lines
139 KiB
TypeScript
// 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 {
|
||
TableCellsIcon,
|
||
RectangleStackIcon,
|
||
Squares2X2Icon,
|
||
AdjustmentsHorizontalIcon,
|
||
CheckCircleIcon,
|
||
ExclamationTriangleIcon,
|
||
SparklesIcon,
|
||
CheckIcon,
|
||
XMarkIcon,
|
||
ArchiveBoxIcon,
|
||
TrashIcon,
|
||
} 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'
|
||
|
||
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
|
||
onPageChange: (page: number) => void
|
||
assetNonce?: number
|
||
sortMode: DoneSortMode
|
||
onSortModeChange: (m: DoneSortMode) => void
|
||
loadMode?: 'paged' | 'all'
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
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:teaser':
|
||
return 'Teaser'
|
||
case 'postwork:thumb':
|
||
return 'Vorschaubild'
|
||
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:teaser') {
|
||
state = hasTeaser ? 'done' : 'missing'
|
||
} else if (stepDef.key === 'postwork:thumb') {
|
||
state = hasThumb ? '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 'assets':
|
||
return 'teaser'
|
||
case 'thumb':
|
||
return 'thumb'
|
||
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,
|
||
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'
|
||
) {
|
||
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' ? (
|
||
<SparklesIcon 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' ? (
|
||
<SparklesIcon 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' ? (
|
||
<>
|
||
<SparklesIcon 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' ? (
|
||
<>
|
||
<ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
|
||
<span className="text-red-700 dark:text-red-200">Fehler</span>
|
||
</>
|
||
) : 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,
|
||
assetNonce,
|
||
sortMode,
|
||
onSortModeChange,
|
||
modelsByKey,
|
||
loadMode = 'paged',
|
||
}: Props) {
|
||
|
||
const VIEW_KEY = 'finishedDownloads_view'
|
||
const KEEP_KEY = 'finishedDownloads_includeKeep_v2'
|
||
const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1'
|
||
|
||
const allMode = loadMode === 'all'
|
||
|
||
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
|
||
const teaserPlaybackMode: TeaserPlaybackMode = pauseTeasers ? 'still' : requestedTeaserPlaybackMode
|
||
|
||
// Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> echter Hover
|
||
// Mobile: "hover" wie "all" behandeln
|
||
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
|
||
|
||
const effectiveTeaserPlaybackMode: TeaserPlaybackMode =
|
||
!canHover && teaserPlaybackMode === 'hover'
|
||
? 'all'
|
||
: teaserPlaybackMode
|
||
|
||
const isCoarsePointer = useMediaQuery('(hover: none) and (pointer: coarse)')
|
||
const isTabletOrSmaller = useMediaQuery('(max-width: 1024px)')
|
||
|
||
const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
|
||
|
||
const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = React.useState(false)
|
||
|
||
const forcePreviewMuted =
|
||
mobileNeedsTapForAudio && teaserAudio
|
||
? !mobileTeaserAudioUnlocked
|
||
: false
|
||
|
||
const notify = useNotify()
|
||
|
||
const pointerPosRef = React.useRef<{ x: number; y: number } | null>(null)
|
||
|
||
const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
|
||
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
||
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
|
||
|
||
const teaserIORef = React.useRef<IntersectionObserver | null>(null)
|
||
const elToKeyRef = React.useRef<WeakMap<Element, string>>(new WeakMap())
|
||
|
||
// 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten
|
||
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
|
||
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
|
||
|
||
const [hiddenFiles, setHiddenFiles] = React.useState<Set<string>>(() => new Set())
|
||
|
||
const [isLoading, setIsLoading] = React.useState(false)
|
||
|
||
const refillInFlightRef = React.useRef(false)
|
||
const refillQueuedWhileInFlightRef = React.useRef(false)
|
||
|
||
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
|
||
const refillSessionRef = React.useRef(0)
|
||
|
||
const [postworkByFile, setPostworkByFile] = React.useState<Record<string, FinishedPostworkBadge[]>>({})
|
||
|
||
const [undoing, setUndoing] = React.useState(false)
|
||
|
||
const [selectedItems, setSelectedItems] = React.useState<Record<string, SelectedItem>>({})
|
||
const [bulkBusy, setBulkBusy] = React.useState(false)
|
||
|
||
// 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln
|
||
const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})
|
||
|
||
// 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken"
|
||
const [overrideDoneJobs, setOverrideDoneJobs] = React.useState<RecordJob[] | null>(null)
|
||
const [overrideDoneTotal, setOverrideDoneTotal] = React.useState<number | null>(null)
|
||
const [refillTick, setRefillTick] = React.useState(0)
|
||
const refillTimerRef = React.useRef<number | null>(null)
|
||
|
||
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
|
||
|
||
const [view, setView] = React.useState<ViewMode>('table')
|
||
const [includeKeep, setIncludeKeep] = React.useState(false)
|
||
|
||
const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
|
||
const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false)
|
||
|
||
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
|
||
|
||
// 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab)
|
||
const [tagFilter, setTagFilter] = React.useState<string[]>([])
|
||
const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])
|
||
|
||
const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
|
||
() => ({
|
||
mode: effectiveTeaserPlaybackMode,
|
||
activeKey: teaserKey,
|
||
hoverKey: hoverTeaserKey,
|
||
audioEnabled: Boolean(teaserAudio),
|
||
}),
|
||
[effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
|
||
)
|
||
|
||
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
|
||
|
||
const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems])
|
||
|
||
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
|
||
})
|
||
}, [keyFor])
|
||
|
||
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
|
||
})
|
||
}, [keyFor])
|
||
|
||
const deselectPageRows = useCallback((rows: RecordJob[]) => {
|
||
setSelectedItems((prev) => {
|
||
const next = { ...prev }
|
||
for (const j of rows) {
|
||
delete next[keyFor(j)]
|
||
}
|
||
return next
|
||
})
|
||
}, [keyFor])
|
||
|
||
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
|
||
|
||
// optional TTL (z. B. 30 min), damit kein uraltes Undo angezeigt wird
|
||
const ageMs = Date.now() - Number(parsed.ts || 0)
|
||
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 30 * 60 * 1000) {
|
||
localStorage.removeItem(LAST_UNDO_KEY)
|
||
return null
|
||
}
|
||
|
||
return parsed.action
|
||
} catch {
|
||
return null
|
||
}
|
||
})
|
||
|
||
const 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])
|
||
|
||
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 regen = data?.regenerateAssets
|
||
const file = String(regen?.file ?? '').trim()
|
||
|
||
if (!file) return
|
||
|
||
if (regen?.running) {
|
||
setPostworkByFile((prev) => ({
|
||
...prev,
|
||
[file]: [
|
||
{
|
||
file,
|
||
assetId: fileStemFromOutput(file),
|
||
queue: 'enrich',
|
||
state: 'running',
|
||
phase: 'assets',
|
||
label: 'Vorschaubild',
|
||
ts: Date.now(),
|
||
},
|
||
],
|
||
}))
|
||
return
|
||
}
|
||
|
||
if (regen?.error) {
|
||
setPostworkByFile((prev) => ({
|
||
...prev,
|
||
[file]: [
|
||
{
|
||
file,
|
||
assetId: fileStemFromOutput(file),
|
||
queue: 'enrich',
|
||
state: 'error',
|
||
phase: 'assets',
|
||
label: 'Fehler',
|
||
ts: Date.now(),
|
||
},
|
||
],
|
||
}))
|
||
return
|
||
}
|
||
|
||
const bulk = data?.generateAssets
|
||
const bulkFile = String(bulk?.currentFile ?? '').trim()
|
||
const bulkQueue =
|
||
String(bulk?.currentQueue ?? '').trim().toLowerCase() === 'enrich'
|
||
? 'enrich'
|
||
: 'postwork'
|
||
const bulkPhase = String(bulk?.currentPhase ?? '').trim().toLowerCase()
|
||
const bulkLabel = String(bulk?.currentLabel ?? '').trim()
|
||
|
||
if (bulk?.running && bulkFile && bulkPhase) {
|
||
setPostworkByFile((prev) => ({
|
||
...prev,
|
||
[bulkFile]: [
|
||
{
|
||
file: bulkFile,
|
||
assetId: fileStemFromOutput(bulkFile),
|
||
queue: bulkQueue,
|
||
state: 'running',
|
||
phase: bulkPhase,
|
||
label: bulkLabel || undefined,
|
||
ts: Date.now(),
|
||
},
|
||
],
|
||
}))
|
||
}
|
||
|
||
// optional: wenn du "done" kurz nach Reload noch sichtbar haben willst
|
||
if (regen?.text) {
|
||
setPostworkByFile((prev) => ({
|
||
...prev,
|
||
[file]: [
|
||
{
|
||
file,
|
||
assetId: fileStemFromOutput(file),
|
||
queue: 'enrich',
|
||
state: 'done',
|
||
phase: 'assets',
|
||
label: 'Fertig',
|
||
ts: Date.now(),
|
||
},
|
||
],
|
||
}))
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
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 {
|
||
// ignore
|
||
}
|
||
}, [lastAction])
|
||
|
||
const emitCountHint = React.useCallback((delta: number) => {
|
||
if (!delta || !Number.isFinite(delta)) return
|
||
|
||
countHintRef.current.pending += delta
|
||
|
||
// coalesce mehrere Aktionen sehr kurz hintereinander
|
||
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 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 jobForDetails = useCallback(
|
||
(job: RecordJob): RecordJob => enrichJobForDetails(job, modelsByKey),
|
||
[modelsByKey]
|
||
)
|
||
|
||
// 🔎 Suche (client-side, innerhalb der aktuell geladenen Seite)
|
||
const [searchQuery, setSearchQuery] = React.useState<string>('')
|
||
const deferredSearchQuery = React.useDeferredValue(searchQuery)
|
||
|
||
const searchTokens = useMemo(
|
||
() => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
|
||
[deferredSearchQuery]
|
||
)
|
||
|
||
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]
|
||
})
|
||
}, [])
|
||
|
||
// ✅ Mobile/UX: globales "all=1" erst bei sinnvoller Suche triggern
|
||
const searchActiveForGlobalFetch =
|
||
activeTagSet.size > 0 ||
|
||
searchTokens.some((t) => t.length >= 2)
|
||
|
||
const globalFilterActive = searchActiveForGlobalFetch
|
||
const effectiveAllMode = globalFilterActive || allMode
|
||
|
||
const fetchAllDoneJobs = useCallback(
|
||
async (signal?: AbortSignal) => {
|
||
// ✅ Nur sichtbares Loading zeigen, wenn wir noch keine Override-Daten haben
|
||
const shouldShowLoading = overrideDoneJobs == null
|
||
if (shouldShowLoading) setIsLoading(true)
|
||
try {
|
||
const res = await fetch(
|
||
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
|
||
{
|
||
cache: 'no-store' as any,
|
||
signal,
|
||
}
|
||
)
|
||
if (!res.ok) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
|
||
const count = Number(data?.count ?? data?.totalCount ?? items.length)
|
||
|
||
setOverrideDoneJobs(items)
|
||
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
|
||
} finally {
|
||
if (shouldShowLoading) setIsLoading(false)
|
||
}
|
||
},
|
||
[sortMode, includeKeep]
|
||
)
|
||
|
||
const clearTagFilter = useCallback(() => setTagFilter([]), [])
|
||
|
||
useEffect(() => {
|
||
// ✅ Wenn wir aus CategoriesTab kommen, liegt evtl. ein "pending" Filter in localStorage
|
||
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 {
|
||
// ignore
|
||
} finally {
|
||
try {
|
||
localStorage.removeItem('finishedDownloads_pendingTags')
|
||
} catch {}
|
||
}
|
||
}, []) // nur einmal beim Mount
|
||
|
||
const queueRefill = useCallback(() => {
|
||
// ✅ Schon geplant? Dann nicht nochmal planen.
|
||
if (refillTimerRef.current != null) return
|
||
|
||
refillTimerRef.current = window.setTimeout(() => {
|
||
refillTimerRef.current = null
|
||
setRefillTick((n) => n + 1)
|
||
}, 80)
|
||
}, [page, pageSize, sortMode, includeKeep])
|
||
|
||
useEffect(() => {
|
||
if (!effectiveAllMode) return
|
||
|
||
const ac = new AbortController()
|
||
|
||
const t = window.setTimeout(() => {
|
||
fetchAllDoneJobs(ac.signal).catch(() => {})
|
||
}, 250)
|
||
|
||
return () => {
|
||
window.clearTimeout(t)
|
||
ac.abort()
|
||
}
|
||
}, [effectiveAllMode, fetchAllDoneJobs])
|
||
|
||
// ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt
|
||
useEffect(() => {
|
||
if (refillTick === 0) return
|
||
|
||
const ac = new AbortController()
|
||
let alive = true
|
||
let finished = false // ✅ pro Effect-Instanz idempotent
|
||
|
||
// ✅ eigene Session-ID für diese Effect-Instanz
|
||
const mySession = ++refillSessionRef.current
|
||
|
||
const finishRefill = () => {
|
||
if (finished) return
|
||
finished = true
|
||
|
||
// ✅ Nur die AKTUELLE Session darf den globalen Refill-Status verändern
|
||
if (refillSessionRef.current !== mySession) {
|
||
return
|
||
}
|
||
|
||
refillInFlightRef.current = false
|
||
|
||
if (refillQueuedWhileInFlightRef.current) {
|
||
refillQueuedWhileInFlightRef.current = false
|
||
queueRefill()
|
||
}
|
||
}
|
||
|
||
// ✅ Refill läuft (nur wenn diese Session noch aktuell ist)
|
||
if (refillSessionRef.current === mySession) {
|
||
refillInFlightRef.current = true
|
||
}
|
||
|
||
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
|
||
if (effectiveAllMode) {
|
||
;(async () => {
|
||
try {
|
||
// fetchAllDoneJobs setzt isLoading selbst
|
||
await fetchAllDoneJobs(ac.signal)
|
||
if (alive) {
|
||
refillRetryRef.current = 0
|
||
}
|
||
} catch {
|
||
// ignore (Abort/Netzwerk)
|
||
} finally {
|
||
if (alive) finishRefill()
|
||
}
|
||
})()
|
||
|
||
return () => {
|
||
alive = false
|
||
ac.abort()
|
||
finishRefill()
|
||
}
|
||
}
|
||
|
||
// ✅ paged refill → hier Loading setzen
|
||
setIsLoading(true)
|
||
|
||
;(async () => {
|
||
try {
|
||
// 1) Liste + optional count in EINEM Request holen
|
||
const listRes = await fetch(
|
||
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${
|
||
includeKeep ? '&includeKeep=1' : ''
|
||
}`,
|
||
{ cache: 'no-store' as any, signal: ac.signal }
|
||
)
|
||
|
||
if (!alive || ac.signal.aborted) return
|
||
|
||
const okAll = listRes.ok
|
||
|
||
if (listRes.ok) {
|
||
const data = await listRes.json().catch(() => null)
|
||
if (!alive || ac.signal.aborted) return
|
||
|
||
const items = Array.isArray(data?.items)
|
||
? (data.items as RecordJob[])
|
||
: Array.isArray(data)
|
||
? data
|
||
: []
|
||
|
||
setOverrideDoneJobs(items)
|
||
|
||
// ✅ Count direkt aus /done lesen (withCount=1)
|
||
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
|
||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||
|
||
setOverrideDoneTotal(count)
|
||
|
||
const totalPages = Math.max(1, Math.ceil(count / pageSize))
|
||
if (page > totalPages) {
|
||
onPageChange(totalPages)
|
||
setOverrideDoneJobs(null)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (okAll) {
|
||
refillRetryRef.current = 0
|
||
} else if (alive && !ac.signal.aborted && refillRetryRef.current < 2) {
|
||
refillRetryRef.current += 1
|
||
const retryNo = refillRetryRef.current
|
||
|
||
window.setTimeout(() => {
|
||
if (!ac.signal.aborted) {
|
||
setRefillTick((n) => n + 1)
|
||
}
|
||
}, 400 * retryNo)
|
||
}
|
||
} catch {
|
||
// Abort / Fehler ignorieren
|
||
} finally {
|
||
if (alive) {
|
||
setIsLoading(false)
|
||
finishRefill()
|
||
}
|
||
}
|
||
})()
|
||
|
||
return () => {
|
||
alive = false
|
||
ac.abort()
|
||
finishRefill()
|
||
}
|
||
}, [
|
||
refillTick,
|
||
effectiveAllMode,
|
||
fetchAllDoneJobs,
|
||
page,
|
||
pageSize,
|
||
sortMode,
|
||
includeKeep,
|
||
onPageChange,
|
||
])
|
||
|
||
useEffect(() => {
|
||
// Override-Daten nur dann zurücksetzen, wenn wir wirklich wieder auf
|
||
// die normalen /done-Props zurückfallen wollen.
|
||
if (effectiveAllMode || includeKeep) return
|
||
|
||
setOverrideDoneJobs(null)
|
||
setOverrideDoneTotal(null)
|
||
}, [page, pageSize, sortMode, includeKeep, effectiveAllMode])
|
||
|
||
useEffect(() => {
|
||
if (effectiveAllMode) return
|
||
|
||
// Nur nötig, wenn wir NICHT auf die normalen Props zurückfallen.
|
||
if (!includeKeep) return
|
||
|
||
const ac = new AbortController()
|
||
let alive = true
|
||
|
||
setIsLoading(true)
|
||
|
||
;(async () => {
|
||
try {
|
||
const res = await fetch(
|
||
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1&includeKeep=1`,
|
||
{ cache: 'no-store' as any, signal: ac.signal }
|
||
)
|
||
if (!res.ok || !alive || ac.signal.aborted) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
if (!alive || ac.signal.aborted) return
|
||
|
||
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
|
||
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
|
||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
|
||
|
||
setOverrideDoneJobs(items)
|
||
setOverrideDoneTotal(count)
|
||
} finally {
|
||
if (alive) setIsLoading(false)
|
||
}
|
||
})()
|
||
|
||
return () => {
|
||
alive = false
|
||
ac.abort()
|
||
}
|
||
}, [effectiveAllMode, includeKeep, page, pageSize, sortMode])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
||
if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
|
||
setView(saved)
|
||
} else {
|
||
// Default: Mobile -> Cards, sonst Tabelle
|
||
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(KEEP_KEY)
|
||
setIncludeKeep(raw === '1' || raw === 'true' || raw === 'yes')
|
||
} catch {
|
||
setIncludeKeep(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(KEEP_KEY, includeKeep ? '1' : '0')
|
||
} catch {}
|
||
}, [includeKeep])
|
||
|
||
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])
|
||
|
||
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
|
||
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
||
|
||
// ✅ Perf: durations gesammelt flushen (verhindert viele Re-renders beim initialen Preview-Mount)
|
||
const durationsRef = React.useRef<Record<string, number>>({})
|
||
const durationsFlushTimerRef = React.useRef<number | null>(null)
|
||
|
||
// 🔹 hier sammeln wir die Videoauflösung pro Job/Datei
|
||
const [resolutions, setResolutions] = React.useState<Record<string, { w: number; h: number }>>({})
|
||
|
||
// ✅ Perf: resolutions gesammelt flushen (wie durations)
|
||
const resolutionsRef = React.useRef<Record<string, { w: number; h: number }>>({})
|
||
const resolutionsFlushTimerRef = React.useRef<number | null>(null)
|
||
|
||
const refillRetryRef = React.useRef(0)
|
||
|
||
React.useEffect(() => {
|
||
resolutionsRef.current = resolutions
|
||
}, [resolutions])
|
||
|
||
const flushResolutionsSoon = React.useCallback(() => {
|
||
if (resolutionsFlushTimerRef.current != null) return
|
||
resolutionsFlushTimerRef.current = window.setTimeout(() => {
|
||
resolutionsFlushTimerRef.current = null
|
||
setResolutions({ ...resolutionsRef.current })
|
||
}, 200)
|
||
}, [])
|
||
|
||
React.useEffect(() => {
|
||
return () => {
|
||
if (resolutionsFlushTimerRef.current != null) {
|
||
window.clearTimeout(resolutionsFlushTimerRef.current)
|
||
resolutionsFlushTimerRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
React.useEffect(() => {
|
||
durationsRef.current = durations
|
||
}, [durations])
|
||
|
||
const flushDurationsSoon = React.useCallback(() => {
|
||
if (durationsFlushTimerRef.current != null) return
|
||
durationsFlushTimerRef.current = window.setTimeout(() => {
|
||
durationsFlushTimerRef.current = null
|
||
// neue Objekt-Referenz, damit React aktualisiert
|
||
setDurations({ ...durationsRef.current })
|
||
}, 200)
|
||
}, [])
|
||
|
||
React.useEffect(() => {
|
||
return () => {
|
||
if (durationsFlushTimerRef.current != null) {
|
||
window.clearTimeout(durationsFlushTimerRef.current)
|
||
durationsFlushTimerRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null)
|
||
|
||
const previewMuted = !Boolean(teaserAudio)
|
||
|
||
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
|
||
|
||
// Inline-Preview aktivieren / remount erzwingen
|
||
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
|
||
|
||
// Nach dem Rendern das Video suchen, seeken und autoplay versuchen
|
||
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 {
|
||
// ignore
|
||
}
|
||
|
||
const p = v.play?.()
|
||
if (p && typeof (p as any).catch === 'function') {
|
||
;(p as Promise<void>).catch(() => {})
|
||
}
|
||
}
|
||
|
||
// Wenn Metadaten schon da sind -> direkt seeken
|
||
if (v.readyState >= 1) {
|
||
applySeek()
|
||
return
|
||
}
|
||
|
||
// Sonst warten bis metadata da sind
|
||
const onLoadedMetadata = () => {
|
||
v.removeEventListener('loadedmetadata', onLoadedMetadata)
|
||
applySeek()
|
||
}
|
||
|
||
v.addEventListener('loadedmetadata', onLoadedMetadata, { once: true })
|
||
|
||
// zusätzlich sofort play versuchen (hilft manchmal)
|
||
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) {
|
||
// Fallback: Player normal öffnen
|
||
openPlayer(job)
|
||
return
|
||
}
|
||
|
||
// Dauer bevorzugt aus Preview-Metadaten, sonst aus Job
|
||
const k = keyFor(job)
|
||
const durationSec =
|
||
durations[k] ??
|
||
((job as any)?.meta?.file?.durationSeconds as number | undefined) ??
|
||
0
|
||
|
||
if (!Number.isFinite(durationSec) || durationSec <= 0) {
|
||
// Wenn keine Dauer bekannt ist: trotzdem öffnen (ohne Timestamp)
|
||
openPlayer(job)
|
||
return
|
||
}
|
||
|
||
// Segment-Index -> Startsekunde
|
||
// Beispiel: 10 Segmente, Klick auf Index 0..9
|
||
const clampedIdx = Math.max(0, Math.min(idx, count - 1))
|
||
const secPerSegment = durationSec / count
|
||
const startAtSec = clampedIdx * secPerSegment
|
||
|
||
openPlayerAt(job, startAtSec)
|
||
},
|
||
[durations, keyFor, 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 [keepingKeys, setKeepingKeys] = React.useState<Set<string>>(() => new Set())
|
||
|
||
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
|
||
})
|
||
}, [])
|
||
|
||
// neben deletedKeys / deletingKeys
|
||
const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())
|
||
|
||
// ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können
|
||
const removeTimersRef = React.useRef<Map<string, number>>(new Map())
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
for (const t of removeTimersRef.current.values()) {
|
||
window.clearTimeout(t)
|
||
}
|
||
removeTimersRef.current.clear()
|
||
}
|
||
}, [])
|
||
|
||
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 }) => {
|
||
// 1) App-/Overlay-Player freigeben
|
||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||
if (opts?.close) {
|
||
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
|
||
}
|
||
|
||
// 2) Einmal auf den nächsten Frame warten (React/DOM cleanup)
|
||
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
||
|
||
// 3) Nochmals release senden (hilft bei race zwischen close/unmount)
|
||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||
|
||
// 4) Windows/Filesystem braucht manchmal einen Moment bis Handles wirklich frei sind
|
||
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 {
|
||
// vor JEDEM Versuch freigeben (nicht nur einmal)
|
||
await releasePlayingFile(file, { close: opts?.close ?? true })
|
||
|
||
// kurzer Tick extra (DOM/video cleanup, OS handle release)
|
||
if (attempt > 1) {
|
||
await sleep(baseDelayMs * attempt)
|
||
} else {
|
||
await sleep(80)
|
||
}
|
||
|
||
return await run()
|
||
} catch (e) {
|
||
lastErr = e
|
||
|
||
// nur bei "Datei in Verwendung" retryen
|
||
if (!looksLikeFileInUseError(e) || attempt >= attempts) {
|
||
throw e
|
||
}
|
||
|
||
// nächster Versuch
|
||
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) {
|
||
// Optimistik zurückrollen
|
||
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) => {
|
||
// Fall 1: externer Handler (App) liefert { undoToken }
|
||
if (onDeleteJob) {
|
||
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
||
if (undoToken) {
|
||
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
|
||
} else {
|
||
setLastAction(null)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Fall 2: lokaler API-Call (liefert from + undoToken)
|
||
const from = (result?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
|
||
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
||
|
||
if (undoToken) {
|
||
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
|
||
} else {
|
||
setLastAction(null)
|
||
}
|
||
|
||
queueRefill()
|
||
emitCountHint(-1)
|
||
},
|
||
})
|
||
|
||
return res.ok
|
||
},
|
||
[
|
||
baseName,
|
||
keyFor,
|
||
deletingKeys,
|
||
markDeleting,
|
||
onDeleteJob,
|
||
withFileReleaseRetry,
|
||
runFileMutation,
|
||
queueRefill,
|
||
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 })
|
||
|
||
queueRefill()
|
||
emitCountHint(includeKeep ? 0 : -1)
|
||
},
|
||
})
|
||
|
||
return res.ok
|
||
},
|
||
[
|
||
baseName,
|
||
keyFor,
|
||
markKeeping,
|
||
keepingKeys,
|
||
deletingKeys,
|
||
withFileReleaseRetry,
|
||
runFileMutation,
|
||
queueRefill,
|
||
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)
|
||
// Fallbacks (falls alte lastAction ohne rowKey existiert)
|
||
unhideRow(lastAction.originalFile, lastAction.originalFile)
|
||
unhideRow(restoredFile, restoredFile)
|
||
|
||
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
|
||
emitCountHint(visibleDelta)
|
||
queueRefill()
|
||
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)
|
||
// Fallbacks (für ältere Actions / Sonderfälle)
|
||
unhideRow(lastAction.originalFile, lastAction.originalFile)
|
||
unhideRow(restoredFile, restoredFile)
|
||
|
||
emitCountHint(+1)
|
||
queueRefill()
|
||
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)
|
||
}
|
||
|
||
queueRefill()
|
||
setLastAction(null)
|
||
return
|
||
}
|
||
} catch (e: any) {
|
||
notify.error('Undo fehlgeschlagen', String(e?.message || e))
|
||
} finally {
|
||
setUndoing(false)
|
||
}
|
||
}, [
|
||
lastAction,
|
||
undoing,
|
||
notify,
|
||
queueRefill,
|
||
includeKeep,
|
||
emitCountHint,
|
||
applyRename,
|
||
])
|
||
|
||
const [hotBusyKeys, setHotBusyKeys] = React.useState<Set<string>>(() => new Set())
|
||
|
||
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 () => {
|
||
// Optimistik sofort anwenden
|
||
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 })
|
||
|
||
if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
|
||
queueRefill()
|
||
}
|
||
},
|
||
onError: async () => {
|
||
// Rename-Optimistik rollback
|
||
clearRenamePair(oldFile, optimisticNew)
|
||
setLastAction(null)
|
||
},
|
||
})
|
||
},
|
||
[
|
||
baseName,
|
||
keyFor,
|
||
hotBusyKeys,
|
||
markHotBusy,
|
||
runFileMutation,
|
||
applyRename,
|
||
clearRenamePair,
|
||
onToggleHot,
|
||
withFileReleaseRetry,
|
||
queueRefill,
|
||
sortMode,
|
||
]
|
||
)
|
||
|
||
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: 'enrich',
|
||
state: 'running',
|
||
phase: 'assets',
|
||
label: 'Wird verarbeitet…',
|
||
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) {
|
||
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 applyRenamedOutput = 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 doneJobsPage = overrideDoneJobs ?? doneJobs
|
||
const doneTotalPage = overrideDoneTotal ?? doneTotal
|
||
|
||
const rows = useMemo(() => {
|
||
const map = new Map<string, RecordJob>()
|
||
const doneBaseKeys = new Set<string>()
|
||
|
||
// Basis: Files aus dem Done-Ordner
|
||
for (const j of doneJobsPage) {
|
||
const jj = applyRenamedOutput(j)
|
||
const k = keyFor(jj)
|
||
|
||
map.set(k, jj)
|
||
doneBaseKeys.add(k)
|
||
}
|
||
|
||
// Jobs aus /list drübermergen, damit aktuelle Live-Felder
|
||
// (phase, postWork, progress, duration, size, etc.) sichtbar bleiben.
|
||
for (const j of jobs) {
|
||
const jj = applyRenamedOutput(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
|
||
}
|
||
|
||
// Fallback: gleicher Dateiname, aber evtl. anderer key/ID
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
const list = 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
|
||
|
||
// .trash niemals anzeigen
|
||
if (isTrashOutput(j.output)) return false
|
||
|
||
const anyJ = j as any
|
||
const status = String(anyJ?.status ?? '').trim().toLowerCase()
|
||
const endedAt = anyJ?.endedAt
|
||
|
||
// ✅ Wichtig:
|
||
// Sobald die Datei aus dem Done-Ordner kommt, soll sie im Finished-Tab bleiben –
|
||
// auch wenn per SSE/Job-Merge noch phase/postWork=running/queued gesetzt ist.
|
||
if (doneBaseKeys.has(k)) return true
|
||
|
||
// Fallback: terminale Jobs ebenfalls anzeigen
|
||
if (
|
||
status === 'finished' ||
|
||
status === 'failed' ||
|
||
status === 'stopped'
|
||
) {
|
||
return true
|
||
}
|
||
|
||
// Optionaler Fallback für bereits beendete Jobs, falls Status mal verzögert ist
|
||
if (endedAt) return true
|
||
|
||
return false
|
||
})
|
||
.map(({ j }) => j)
|
||
|
||
return list
|
||
}, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutput, keyFor, baseName])
|
||
|
||
|
||
|
||
const effectivePostworkSummaryByFile = useMemo<Record<string, FinishedPostworkSummary>>(() => {
|
||
const next: Record<string, FinishedPostworkSummary> = {}
|
||
|
||
// 1) Erst Live-Zustände sammeln
|
||
for (const [file, badges] of Object.entries(postworkByFile)) {
|
||
const summary = summarizePostworkSteps(file, badges)
|
||
if (summary) {
|
||
next[file] = summary
|
||
}
|
||
}
|
||
|
||
// 2) Dann für jede sichtbare Datei Meta auswerten und ggf. Live-Zustand überschreiben
|
||
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 fileToKeyRef = React.useRef<Map<string, string>>(new Map())
|
||
|
||
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,
|
||
}
|
||
|
||
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)
|
||
|
||
// ✅ Optimistik: überall gleich -> animiert raus
|
||
animateRemove(key, detail.file)
|
||
|
||
return
|
||
}
|
||
|
||
if (detail.phase === 'error') {
|
||
// ✅ alles zurückrollen -> wieder sichtbar
|
||
restoreRow(key, detail.file)
|
||
|
||
// ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht)
|
||
swipeRefs.current.get(key)?.reset()
|
||
return
|
||
}
|
||
|
||
if (detail.phase === 'success') {
|
||
markDeleting(key, false)
|
||
return
|
||
}
|
||
}
|
||
|
||
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
||
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
|
||
}, [animateRemove, markDeleting, queueRefill, restoreRow])
|
||
|
||
useEffect(() => {
|
||
const onReload = () => {
|
||
// Nur reagieren, wenn FinishedDownloads wirklich sichtbar/mounted ist
|
||
if (document.hidden) return
|
||
|
||
if (refillInFlightRef.current) {
|
||
if (!refillQueuedWhileInFlightRef.current) {
|
||
refillQueuedWhileInFlightRef.current = true
|
||
}
|
||
return
|
||
}
|
||
|
||
if (refillTimerRef.current != null) return
|
||
|
||
queueRefill()
|
||
}
|
||
|
||
window.addEventListener('finished-downloads:reload', onReload as EventListener)
|
||
return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener)
|
||
}, [queueRefill])
|
||
|
||
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
|
||
|
||
// ✅ nutzt eure bestehende Logik inkl. Aufräumen + durations-move
|
||
applyRename(oldFile, newFile)
|
||
}
|
||
|
||
window.addEventListener('finished-downloads:rename', onExternalRename as EventListener)
|
||
return () => window.removeEventListener('finished-downloads:rename', onExternalRename as EventListener)
|
||
}, [applyRename])
|
||
|
||
const visibleRows = useMemo(() => {
|
||
const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
|
||
|
||
// 🔎 Suche (AND über Tokens)
|
||
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
|
||
|
||
// AND-Filter: alle ausgewählten Tags müssen vorhanden sein
|
||
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) return visibleRows
|
||
const start = (page - 1) * pageSize
|
||
const end = start + pageSize
|
||
return visibleRows.slice(Math.max(0, start), Math.max(0, end))
|
||
}, [effectiveAllMode, visibleRows, page, pageSize])
|
||
|
||
const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
|
||
const emptyByFilter = globalFilterActive && visibleRows.length === 0
|
||
|
||
// Optional zusätzlich: wenn allMode und wirklich gar nix da:
|
||
const emptyAll = allMode && visibleRows.length === 0
|
||
|
||
const showLoadingCard = isLoading && (emptyFolder || emptyAll || emptyByFilter)
|
||
|
||
useEffect(() => {
|
||
if (!globalFilterActive) return
|
||
const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize))
|
||
if (page > totalPages) onPageChange(totalPages)
|
||
}, [globalFilterActive, visibleRows.length, page, pageSize, onPageChange])
|
||
|
||
// 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
|
||
useEffect(() => {
|
||
const active =
|
||
!canHover &&
|
||
effectiveTeaserPlaybackMode !== 'still' &&
|
||
(view === 'cards' || view === 'gallery' || view === 'table')
|
||
|
||
if (!active) {
|
||
setTeaserKey(null)
|
||
teaserIORef.current?.disconnect()
|
||
teaserIORef.current = null
|
||
return
|
||
}
|
||
|
||
// Cards: Inline-Player soll “sticky” bleiben
|
||
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) {
|
||
setTeaserKey((prev) => (prev === bestKey ? prev : bestKey))
|
||
}
|
||
},
|
||
{
|
||
// eher “welche Card ist wirklich sichtbar”
|
||
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])
|
||
|
||
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
||
const runtimeOf = (job: RecordJob): string => {
|
||
const k = keyFor(job)
|
||
|
||
// ✅ 1) echte Videodauer (Preview-Metadaten oder Backend durationSeconds)
|
||
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)
|
||
}
|
||
|
||
// 2) Fallback
|
||
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 '—'
|
||
}
|
||
|
||
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)
|
||
}
|
||
},
|
||
[]
|
||
)
|
||
|
||
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
|
||
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()
|
||
queueRefill()
|
||
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,
|
||
queueRefill,
|
||
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()
|
||
queueRefill()
|
||
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,
|
||
queueRefill,
|
||
emitCountHint,
|
||
includeKeep,
|
||
notify,
|
||
])
|
||
|
||
// ✅ Hooks immer zuerst – unabhängig von rows
|
||
const isSmall = useMediaQuery('(max-width: 639px)')
|
||
|
||
// ✅ Mobile-Offsets für Cards-Ansicht (zentral steuerbar)
|
||
const cardsMobileOffsetTopClass = 'mt-10'
|
||
const cardsMobileOffsetBottomClass = 'mb-2' // bei Bedarf z. B. 'mb-4'
|
||
|
||
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, keyFor, effectiveTeaserPlaybackMode])
|
||
|
||
useEffect(() => {
|
||
if (!isSmall) {
|
||
// dein Cleanup (z.B. swipeRefs reset) wie gehabt
|
||
swipeRefs.current = new Map()
|
||
}
|
||
}, [isSmall])
|
||
|
||
useEffect(() => {
|
||
// Nicht während eines Seitenwechsels / Refills zurückspringen.
|
||
if (isLoading) return
|
||
|
||
if (emptyFolder && page !== 1) onPageChange(1)
|
||
}, [emptyFolder, isLoading, page, onPageChange])
|
||
|
||
const selectedCount = selectedFiles.length
|
||
const allPageSelected =
|
||
pageRows.length > 0 &&
|
||
pageRows.every((j) => selectedKeys.has(keyFor(j)))
|
||
|
||
useEffect(() => {
|
||
if (selectedCount === 0 && selectionActionsOpen) {
|
||
setSelectionActionsOpen(false)
|
||
}
|
||
}, [selectedCount, selectionActionsOpen])
|
||
|
||
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">
|
||
{/* Zeile 1: Titel */}
|
||
<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',
|
||
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||
].join(' ')}
|
||
srLabel="Lade Downloads…"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Zeile 2: Controls */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||
<input
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Suchen…"
|
||
className="
|
||
h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
|
||
"
|
||
/>
|
||
{(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)
|
||
setIncludeKeep(checked)
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{view !== 'table' ? (
|
||
<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 A→Z</option>
|
||
<option value="file_desc">Modelname Z→A</option>
|
||
<option value="duration_desc">Dauer ↓</option>
|
||
<option value="duration_asc">Dauer ↑</option>
|
||
<option value="size_desc">Größe ↓</option>
|
||
<option value="size_asc">Größe ↑</option>
|
||
</select>
|
||
</div>
|
||
) : null}
|
||
|
||
<Button
|
||
size="md"
|
||
variant="soft"
|
||
disabled={!lastAction || undoing}
|
||
onClick={undoLastAction}
|
||
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',
|
||
isLoading ? 'opacity-100' : '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="soft"
|
||
className="flex-1"
|
||
disabled={!lastAction || undoing}
|
||
onClick={undoLastAction}
|
||
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>
|
||
|
||
{/* Auswahl-Aktionen */}
|
||
{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(' ')}
|
||
>
|
||
{/* Mobile */}
|
||
<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>
|
||
|
||
{/* Desktop */}
|
||
<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}
|
||
|
||
{/* Desktop: aktive Tag-Filter anzeigen */}
|
||
{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}
|
||
|
||
{/* Mobile Optionen */}
|
||
<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">
|
||
<input
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Suchen…"
|
||
className="
|
||
h-10 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
|
||
"
|
||
/>
|
||
{(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)
|
||
setIncludeKeep(checked)
|
||
}}
|
||
ariaLabel="Behaltene Downloads anzeigen"
|
||
size="default"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{view !== 'table' ? (
|
||
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
|
||
Sortierung
|
||
</div>
|
||
<select
|
||
id="finished-sort-mobile"
|
||
value={sortMode}
|
||
onChange={(e) => {
|
||
const m = e.target.value as DoneSortMode
|
||
onSortModeChange(m)
|
||
if (page !== 1) onPageChange(1)
|
||
}}
|
||
className="
|
||
w-full h-10 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
|
||
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
|
||
"
|
||
>
|
||
<option value="completed_desc">Fertiggestellt am ↓</option>
|
||
<option value="completed_asc">Fertiggestellt am ↑</option>
|
||
<option value="file_asc">Modelname A→Z</option>
|
||
<option value="file_desc">Modelname Z→A</option>
|
||
<option value="duration_desc">Dauer ↓</option>
|
||
<option value="duration_asc">Dauer ↑</option>
|
||
<option value="size_desc">Größe ↓</option>
|
||
<option value="size_asc">Größe ↑</option>
|
||
</select>
|
||
</div>
|
||
) : 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>
|
||
|
||
{showLoadingCard ? (
|
||
<Card grayBody>
|
||
<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-white">
|
||
Lade Downloads…
|
||
</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Bitte warte einen Moment.
|
||
</div>
|
||
</div>
|
||
|
||
<LoadingSpinner
|
||
size="lg"
|
||
className="text-indigo-500"
|
||
srLabel="Lade Downloads…"
|
||
/>
|
||
</div>
|
||
</Card>
|
||
) : (emptyFolder || emptyAll) ? (
|
||
<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={isLoading}
|
||
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={isLoading}
|
||
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}
|
||
selectedKeys={selectedKeys}
|
||
onToggleSelected={toggleSelected}
|
||
onToggleSelectAllPage={(checked) => {
|
||
if (checked) selectPageRows(pageRows)
|
||
else deselectPageRows(pageRows)
|
||
}}
|
||
isLoading={isLoading}
|
||
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}
|
||
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={pageSize}
|
||
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>
|
||
</>
|
||
)
|
||
|
||
}
|