// 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 blurPreviews?: boolean teaserPlayback?: TeaserPlaybackMode teaserAudio?: boolean onOpenPlayer: (job: RecordJob) => void onDeleteJob?: ( job: RecordJob ) => void | { undoToken?: string } | Promise onToggleHot?: ( job: RecordJob ) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise 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() 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>(new Map()) const [teaserKey, setTeaserKey] = React.useState(null) const [hoverTeaserKey, setHoverTeaserKey] = React.useState(null) const teaserIORef = React.useRef(null) const elToKeyRef = React.useRef>(new WeakMap()) // 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten const [deletedKeys, setDeletedKeys] = React.useState>(() => new Set()) const [deletingKeys, setDeletingKeys] = React.useState>(() => 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(() => { 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>({}) // 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken" const [overrideDoneJobs, setOverrideDoneJobs] = React.useState(null) const [overrideDoneTotal, setOverrideDoneTotal] = React.useState(null) const [refillTick, setRefillTick] = React.useState(0) const refillTimerRef = React.useRef(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('table') const [includeKeep, setIncludeKeep] = React.useState(false) const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false) const swipeRefs = React.useRef>(new Map()) // 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab) const [tagFilter, setTagFilter] = React.useState([]) const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter]) const modelTags = useMemo(() => { const tagsByModelKey: Record = {} const tagSetByModelKey: Record> = {} 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('') 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>({}) // ✅ Perf: durations gesammelt flushen (verhindert viele Re-renders beim initialen Preview-Mount) const durationsRef = React.useRef>({}) const durationsFlushTimerRef = React.useRef(null) // 🔹 hier sammeln wir die Videoauflösung pro Job/Datei const [resolutions, setResolutions] = React.useState>({}) // ✅ Perf: resolutions gesammelt flushen (wie durations) const resolutionsRef = React.useRef>({}) const resolutionsFlushTimerRef = React.useRef(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).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>(() => 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>(() => new Set()) // ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können const removeTimersRef = React.useRef>(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 => { 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 = { ...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 = { ...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() // 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>(new Map()) useEffect(() => { const m = new Map() 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) */}
{/* Header row */}
{/* Left: Title + Count */}
Abgeschlossene Downloads
{totalItemsForPagination}
{/* Mobile title (bleibt wie gehabt, aber kompakter) */}
Abgeschlossene Downloads
{totalItemsForPagination}
{/* Right: Controls */}
{isLoading ? ( ) : null} {/* Desktop: Suche soll den Platz füllen */}
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() !== '' ? ( ) : null}
{/* Desktop: Keep Toggle */}
{ if (page !== 1) onPageChange(1) setIncludeKeep(checked) queueRefill() }} />
{/* Desktop: Sort (nur wenn nicht Tabelle) */} {view !== 'table' && (
)} {/* Views */} setView(id as ViewMode)} size={isSmall ? 'sm' : 'md'} ariaLabel="Ansicht" items={[ { id: 'table', icon: , label: isSmall ? undefined : 'Tabelle', srLabel: 'Tabelle' }, { id: 'cards', icon: , label: isSmall ? undefined : 'Cards', srLabel: 'Cards' }, { id: 'gallery', icon: , label: isSmall ? undefined : 'Galerie', srLabel: 'Galerie' }, ]} /> {/* Mobile: Optionen Button (unverändert) */}
{/* Desktop: aktive Tag-Filter anzeigen */} {tagFilter.length > 0 ? (
Tag-Filter
{tagFilter.map((t) => ( ))}
) : null} {/* Mobile Optionen (einklappbar): Suche + Keep + Sort */}
{/* “Sheet”-Body */}
{/* Suche */}
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() !== '' ? ( ) : null}
{/* Keep – als Setting Row */}
Keep anzeigen
Behaltene Downloads in der Liste
{ if (page !== 1) onPageChange(1) setIncludeKeep(checked) queueRefill() }} ariaLabel="Behaltene Downloads anzeigen" size="default" />
{/* Sort */} {view !== 'table' ? (
Sortierung
) : null}
{/* Tag-Filter */} {tagFilter.length > 0 ? (
Tag-Filter
{tagFilter.map((t) => ( ))}
) : null}
{showLoadingCard ? (
Lade Downloads…
Bitte warte einen Moment.
) : (emptyFolder || emptyAll) ? (
📁
Keine abgeschlossenen Downloads
Im Zielordner ist aktuell nichts vorhanden.
) : emptyByFilter ? (
Keine Treffer für die aktuellen Filter.
{tagFilter.length > 0 || (searchQuery || '').trim() !== '' ? (
{tagFilter.length > 0 ? ( ) : null} {(searchQuery || '').trim() !== '' ? ( ) : null}
) : null}
) : ( <> {view === 'cards' && (
)} {view === 'table' && ( )} {view === 'gallery' && ( )} { 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" /> )} ) }