nsfwapp/frontend/src/components/ui/FinishedDownloads.tsx
2026-02-23 20:01:24 +01:00

2079 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// frontend\src\components\ui\FinishedDownloads.tsx
'use client'
import * as React from 'react'
import { useMemo, useEffect, useCallback } from 'react'
import Card from './Card'
import type { RecordJob } from '../../types'
import ButtonGroup from './ButtonGroup'
import {
TableCellsIcon,
RectangleStackIcon,
Squares2X2Icon,
AdjustmentsHorizontalIcon,
} 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'
type SortMode =
| 'completed_desc'
| 'completed_asc'
| 'file_asc'
| 'file_desc'
| 'duration_desc'
| 'duration_asc'
| 'size_desc'
| 'size_asc'
type TeaserPlaybackMode = 'still' | 'hover' | 'all'
type Props = {
jobs: RecordJob[]
doneJobs: RecordJob[]
modelsByKey: Record<string, StoredModelFlags>
blurPreviews?: boolean
teaserPlayback?: TeaserPlaybackMode
teaserAudio?: boolean
onOpenPlayer: (job: RecordJob) => 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>
doneTotal: number
page: number
pageSize: number
onPageChange: (page: number) => void
assetNonce?: number
sortMode: SortMode
onSortModeChange: (m: SortMode) => 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
}
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
}
type StoredModelFlags = {
id: string
modelKey: string
favorite?: boolean
liked?: boolean | null
watching?: boolean | null
tags?: string
}
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
}
export default function FinishedDownloads({
jobs,
doneJobs,
blurPreviews,
teaserPlayback,
teaserAudio,
onOpenPlayer,
onDeleteJob,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
doneTotal,
page,
pageSize,
onPageChange,
assetNonce,
sortMode,
onSortModeChange,
modelsByKey,
loadMode = 'paged',
}: Props) {
const allMode = loadMode === 'all'
const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
// Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> Teaser nur bei Hover, Mobile -> im Viewport
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
const notify = useNotify()
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 [isLoading, setIsLoading] = React.useState(false)
const refillInFlightRef = React.useRef(false)
type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
| { kind: 'hot'; currentFile: string }
type PersistedUndoState = {
v: 1
action: UndoAction
ts: number
}
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
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 [undoing, setUndoing] = React.useState(false)
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])
// 🔥 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 queueRefill = useCallback(() => {
if (refillTimerRef.current) window.clearTimeout(refillTimerRef.current)
// kurz debouncen, damit bei mehreren Aktionen nicht zig Fetches laufen
refillTimerRef.current = window.setTimeout(() => setRefillTick((n) => n + 1), 80)
}, [])
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
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)
}, [])
type ViewMode = 'table' | 'cards' | 'gallery'
const VIEW_KEY = 'finishedDownloads_view'
const KEEP_KEY = 'finishedDownloads_includeKeep_v2'
const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1'
const [view, setView] = React.useState<ViewMode>('table')
const [includeKeep, setIncludeKeep] = React.useState(false)
const [mobileOptionsOpen, setMobileOptionsOpen] = 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 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])
// 🔎 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]
})
}, [])
const globalFilterActive = searchTokens.length > 0 || activeTagSet.size > 0
const effectiveAllMode = globalFilterActive || allMode
const fetchAllDoneJobs = useCallback(
async (signal?: AbortSignal) => {
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 {
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
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
const finishRefill = () => {
refillInFlightRef.current = false
}
// ✅ Refill läuft
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 + count holen
const [listRes, metaRes] = await Promise.all([
fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any, signal: ac.signal }
),
fetch(
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
{ cache: 'no-store' as any, signal: ac.signal }
),
])
if (!alive || ac.signal.aborted) return
let okAll = listRes.ok && metaRes.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)
}
if (metaRes.ok) {
const meta = await metaRes.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const countRaw = Number(meta?.count ?? 0)
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(() => {
if (effectiveAllMode) return
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
}, [page, pageSize, sortMode, includeKeep, effectiveAllMode])
useEffect(() => {
if (!includeKeep) {
// zurück auf "nur /done/" (Props)
if (!globalFilterActive) {
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
}
return
}
// includeKeep = true:
// - wenn Filter aktiv -> fetchAllDoneJobs macht das bereits (mit includeKeep)
if (globalFilterActive) return
const ac = new AbortController()
;(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) 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)
} catch {}
})()
return () => ac.abort()
}, [includeKeep, globalFilterActive, 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 openPlayer = useCallback((job: RecordJob) => {
setInlinePlay(null)
onOpenPlayer(job)
}, [onOpenPlayer])
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) => {
// Timer stoppen (falls die "commit delete"-Phase noch aussteht)
cancelRemoveTimer(key)
// wieder sichtbar machen
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
})
},
[cancelRemoveTimer]
)
const animateRemove = useCallback(
(key: string) => {
markRemoving(key, true)
cancelRemoveTimer(key)
const t = window.setTimeout(() => {
removeTimersRef.current.delete(key)
markDeleted(key)
markRemoving(key, false)
}, 320)
removeTimersRef.current.set(key, t)
},
[markDeleted, markRemoving, cancelRemoveTimer]
)
const releasePlayingFile = useCallback(
async (file: string, opts?: { close?: boolean }) => {
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
if (opts?.close) {
window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
}
await new Promise((r) => window.setTimeout(r, 250))
},
[]
)
const deleteVideo = useCallback(
async (job: RecordJob): Promise<boolean> => {
const file = baseName(job.output || '')
const key = keyFor(job)
if (!file) {
notify.error('Löschen nicht möglich', 'Kein Dateiname gefunden kann nicht löschen.')
return false
}
if (deletingKeys.has(key)) return false
markDeleting(key, true)
try {
await releasePlayingFile(file, { close: true })
// ✅ Wenn App-Handler vorhanden: den benutzen
// (WICHTIG für Undo: onDeleteJob sollte idealerweise {undoToken} zurückgeben)
if (onDeleteJob) {
const r = await onDeleteJob(job)
const undoToken = (r as any)?.undoToken
if (typeof undoToken === 'string' && undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else {
setLastAction(null)
// optional: nicht als "error" melden, eher info/warn
// notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.')
}
// ✅ OPTIMISTIK + Pagination refill + count hint
animateRemove(key)
queueRefill()
emitCountHint(-1)
// animateRemove queued already queueRefill(), aber extra ist ok:
// queueRefill()
return true
}
// Fallback: Backend direkt
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
// ✅ Backend liefert undoToken (Trash)
const data = (await res.json().catch(() => null)) as any
const from = (data?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
if (undoToken) setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
else setLastAction(null)
animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(-1)
return true
} catch (e: any) {
// ✅ falls irgendwo (z.B. via External-Event) schon optimistisch entfernt wurde: zurückrollen
restoreRow(key)
notify.error('Löschen fehlgeschlagen: ', file)
return false
} finally {
markDeleting(key, false)
}
},
[
deletingKeys,
markDeleting,
releasePlayingFile,
onDeleteJob,
animateRemove,
notify,
restoreRow,
queueRefill,
emitCountHint,
]
)
const keepVideo = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
const key = keyFor(job)
if (!file) {
notify.error('Keep nicht möglich', 'Kein Dateiname gefunden kann nicht behalten.')
return false
}
if (keepingKeys.has(key) || deletingKeys.has(key)) return false
markKeeping(key, true)
try {
await releasePlayingFile(file, { close: true })
const res = await fetch(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
// ✅ Backend liefert ggf. newFile (uniqueDestPath)
const data = (await res.json().catch(() => null)) as any
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
// ✅ Undo-Info merken
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
// ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
animateRemove(key)
queueRefill()
// ✅ Tab-Count sofort korrigieren (App hört drauf)
emitCountHint(includeKeep ? 0 : -1)
return true
} catch (e: any) {
notify.error('Keep fehlgeschlagen', file)
return false
} finally {
markKeeping(key, false)
}
},
[
keepingKeys,
deletingKeys,
markKeeping,
releasePlayingFile,
animateRemove,
notify,
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
})
}, [])
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 unhideByToken = (token: string) => {
setDeletedKeys((prev) => {
const next = new Set(prev)
next.delete(token)
return next
})
setDeletingKeys((prev) => {
const next = new Set(prev)
next.delete(token)
return next
})
setKeepingKeys((prev) => {
const next = new Set(prev)
next.delete(token)
return next
})
setRemovingKeys((prev) => {
const next = new Set(prev)
next.delete(token)
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) unhideByToken(lastAction.rowKey)
// Fallbacks (falls alte lastAction ohne rowKey existiert)
unhideByToken(lastAction.originalFile)
unhideByToken(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) unhideByToken(lastAction.rowKey)
// Fallbacks (für ältere Actions / Sonderfälle)
unhideByToken(lastAction.originalFile)
unhideByToken(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 toggleHotVideo = useCallback(
async (job: RecordJob) => {
const currentFile = baseName(job.output || '')
if (!currentFile) {
notify.error('HOT nicht möglich', 'Kein Dateiname gefunden kann nicht HOT togglen.')
return
}
// genau "HOT " Prefix
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
// Server-Truth anwenden (inkl. duration-key move via applyRename)
const applyServerTruth = (apiOld: string, apiNew: string) => {
if (!apiOld || !apiNew || apiOld === apiNew) return
applyRename(apiOld, apiNew)
}
const oldFile = currentFile
const optimisticNew = toggledName(oldFile)
// Optimistik sofort anwenden (UI snappy)
applyRename(oldFile, optimisticNew)
try {
await releasePlayingFile(oldFile, { close: true })
// ✅ 1) Wenn du einen externen Handler hast:
// -> ideal: er gibt {oldFile,newFile} zurück (optional)
if (onToggleHot) {
const r = (await onToggleHot(job)) as any
// Wenn Handler Server-Truth liefert, übernehmen, sonst Optimistik behalten
const apiOld = typeof r?.oldFile === 'string' ? r.oldFile : ''
const apiNew = typeof r?.newFile === 'string' ? r.newFile : ''
if (apiOld && apiNew) applyServerTruth(apiOld, apiNew)
// ✅ Undo erst jetzt setzen (nach Erfolg)
setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew })
if (sortMode === 'file_asc' || sortMode === 'file_desc') {
queueRefill()
}
return
}
// ✅ 2) Fallback: Backend direkt (wichtig: oldFile verwenden!)
const res = await fetch(
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
{ 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 apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
// Server-Truth über Optimistik drüberziehen (falls der Server anders entschieden hat)
if (apiOld !== apiNew) applyServerTruth(apiOld, apiNew)
// ✅ Undo nach Erfolg
setLastAction({ kind: 'hot', currentFile: apiNew })
queueRefill()
} catch (e: any) {
// ❌ Rollback, weil Optimistik schon angewendet wurde
clearRenamePair(oldFile, optimisticNew)
// und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist)
setLastAction(null)
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
}
},
[notify, applyRename, clearRenamePair, releasePlayingFile, onToggleHot, queueRefill, sortMode]
)
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>()
// Basis: Files aus dem Done-Ordner
for (const j of doneJobsPage) {
const jj = applyRenamedOutput(j)
map.set(keyFor(jj), jj)
}
// Jobs aus /list drübermergen
for (const j of jobs) {
const jj = applyRenamedOutput(j)
const k = keyFor(jj)
if (map.has(k)) map.set(k, { ...map.get(k)!, ...jj })
}
const list = Array.from(map.values()).filter((j) => {
if (deletedKeys.has(keyFor(j))) return false
// ✅ .trash niemals anzeigen
if (isTrashOutput(j.output)) return false
return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
})
return list
}, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput])
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 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)
return
}
if (detail.phase === 'error') {
// ✅ alles zurückrollen -> wieder sichtbar
restoreRow(key)
// ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht)
swipeRefs.current.get(key)?.reset()
return
}
if (detail.phase === 'success') {
// delete final bestätigt
markDeleting(key, false)
queueRefill()
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 = () => {
if (refillInFlightRef.current) return
queueRefill()
}
window.addEventListener('finished-downloads:reload', onReload as any)
return () => window.removeEventListener('finished-downloads:reload', onReload as any)
}, [queueRefill /* oder fetchAllDoneJobs */])
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 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 =
teaserPlaybackMode === 'hover' &&
!canHover &&
(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, teaserPlaybackMode, 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 sec = durations[k] ?? (job as any)?.durationSeconds
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 {
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])
// ✅ Hooks immer zuerst unabhängig von rows
const isSmall = useMediaQuery('(max-width: 639px)')
useEffect(() => {
if (!isSmall) return
if (view !== 'cards') return
const top = pageRows[0]
if (!top) {
setTeaserKey(null)
return
}
const topKey = keyFor(top)
setTeaserKey((prev) => (prev === topKey ? prev : topKey))
}, [isSmall, view, pageRows, keyFor])
useEffect(() => {
if (!isSmall) {
// dein Cleanup (z.B. swipeRefs reset) wie gehabt
swipeRefs.current = new Map()
}
}, [isSmall])
useEffect(() => {
if (emptyFolder && page !== 1) onPageChange(1)
}, [emptyFolder, page, onPageChange])
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
"
>
{/* Header row */}
<div className="flex items-center gap-3 p-3">
{/* Left: Title + Count */}
<div className="hidden sm:flex items-center gap-2 min-w-0">
<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>
{/* Mobile title (bleibt wie gehabt, aber kompakter) */}
<div className="sm:hidden flex items-center gap-2 min-w-0 flex-1">
<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>
{/* Right: Controls */}
<div className="flex items-center gap-2 ml-auto shrink-0">
{isLoading ? (
<LoadingSpinner size="lg" className="text-indigo-500" srLabel="Lade Downloads…" />
) : null}
{/* Desktop: Suche soll den Platz füllen */}
<div className="hidden sm:flex items-center gap-2 min-w-0 flex-1">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className="
h-9 w-full max-w-[420px] 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>
{/* Desktop: Keep Toggle */}
<div className="hidden sm:block">
<LabeledSwitch
label="Behaltene Downloads anzeigen"
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
/>
</div>
{/* Desktop: Sort (nur wenn nicht Tabelle) */}
{view !== 'table' && (
<div className="hidden sm:block">
<label className="sr-only" htmlFor="finished-sort">
Sortierung
</label>
<select
id="finished-sort"
value={sortMode}
onChange={(e) => {
const m = e.target.value as SortMode
onSortModeChange(m)
if (page !== 1) onPageChange(1)
}}
className="
h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
"
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="file_asc">Modelname AZ</option>
<option value="file_desc">Modelname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
<option value="size_asc">Größe </option>
</select>
</div>
)}
{/* Views */}
<Button
size={isSmall ? 'sm' : 'md'}
variant="soft"
className={isSmall ? 'h-9' : 'h-10'}
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={isSmall ? 'sm' : 'md'}
ariaLabel="Ansicht"
items={[
{ id: 'table', icon: <TableCellsIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Tabelle', srLabel: 'Tabelle' },
{ id: 'cards', icon: <RectangleStackIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Cards', srLabel: 'Cards' },
{ id: 'gallery', icon: <Squares2X2Icon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Galerie', srLabel: 'Galerie' },
]}
/>
{/* Mobile: Optionen Button (unverändert) */}
<button
type="button"
className="sm:hidden relative inline-flex items-center justify-center rounded-md border border-gray-200 bg-white p-2 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" />
{/* kleiner Punkt wenn Filter aktiv */}
{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>
{/* 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 (einklappbar): Suche + Keep + Sort */}
<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(' ')}
>
{/* “Sheet”-Body */}
<div className="border-t border-gray-200/60 dark:border-white/10 p-3">
<div className="space-y-2">
{/* Suche */}
<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>
{/* Keep als Setting Row */}
<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)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"
/>
</div>
</div>
{/* Sort */}
{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 SortMode
onSortModeChange(m)
if (page !== 1) onPageChange(1)
}}
className="
w-full h-10 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
"
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="file_asc">Modelname AZ</option>
<option value="file_desc">Modelname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
<option value="size_asc">Größe </option>
</select>
</div>
) : null}
</div>
</div>
{/* Tag-Filter */}
{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>
) : (
<>
{view === 'cards' && (
<div className={isSmall ? 'mt-8' : ''}>
<FinishedDownloadsCardsView
rows={pageRows}
isSmall={isSmall}
isLoading={isLoading}
blurPreviews={blurPreviews}
durations={durations}
teaserKey={teaserKey}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
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}
startInline={startInline}
tryAutoplayInline={tryAutoplayInline}
registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
releasePlayingFile={releasePlayingFile}
modelsByKey={modelsByKey}
onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0}
/>
</div>
)}
{view === 'table' && (
<FinishedDownloadsTableView
rows={pageRows}
isLoading={isLoading}
keyFor={keyFor}
baseName={baseName}
lower={lower}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
resolutions={resolutions}
durations={durations}
canHover={canHover}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
setHoverTeaserKey={setHoverTeaserKey}
teaserPlayback={teaserPlaybackMode}
teaserKey={teaserKey}
registerTeaserHost={registerTeaserHost}
handleDuration={handleDuration}
handleResolution={handleResolution}
blurPreviews={blurPreviews}
assetNonce={assetNonce}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onOpenPlayer={onOpenPlayer}
onSortModeChange={onSortModeChange}
page={page}
onPageChange={onPageChange}
onToggleHot={toggleHotVideo}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
/>
)}
{view === 'gallery' && (
<FinishedDownloadsGalleryView
rows={pageRows}
isLoading={isLoading}
blurPreviews={blurPreviews}
durations={durations}
handleDuration={handleDuration}
teaserKey={teaserKey}
teaserPlayback={teaserPlaybackMode}
teaserAudio={teaserAudio}
hoverTeaserKey={hoverTeaserKey}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
deletedKeys={deletedKeys}
registerTeaserHost={registerTeaserHost}
onOpenPlayer={onOpenPlayer}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={toggleHotVideo}
lower={lower}
modelsByKey={modelsByKey}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey}
/>
)}
<Pagination
page={page}
pageSize={pageSize}
totalItems={totalItemsForPagination}
onPageChange={(p) => {
flushSync(() => {
setInlinePlay(null)
setTeaserKey(null)
setHoverTeaserKey(null)
})
for (const j of pageRows) {
const f = baseName(j.output || '')
if (!f) continue
window.dispatchEvent(new CustomEvent('player:release', { detail: { file: f } }))
window.dispatchEvent(new CustomEvent('player:close', { detail: { file: f } }))
}
window.scrollTo({ top: 0, behavior: 'auto' })
onPageChange(p)
}}
showSummary={false}
prevLabel="Zurück"
nextLabel="Weiter"
className="mt-4"
/>
</>
)}
</>
)
}