2079 lines
68 KiB
TypeScript
2079 lines
68 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 { 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 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>
|
||
)}
|
||
|
||
{/* 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 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>
|
||
|
||
{/* 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"
|
||
/>
|
||
</>
|
||
)}
|
||
</>
|
||
)
|
||
|
||
}
|