This commit is contained in:
Linrador 2026-03-31 18:02:58 +02:00
parent f5deb65f75
commit 3052836f3b
3 changed files with 159 additions and 442 deletions

View File

@ -751,7 +751,7 @@ export default function App() {
} finally { } finally {
donePrefetchInFlightRef.current = false donePrefetchInFlightRef.current = false
} }
}, [doneSort, includeKeep]) }, [doneSort, includeKeep, donePageSize])
const loadDoneCount = useCallback(async () => { const loadDoneCount = useCallback(async () => {
const now = Date.now() const now = Date.now()
@ -794,6 +794,11 @@ export default function App() {
donePrefetchRef.current = null donePrefetchRef.current = null
const totalPages = getDoneTotalPages(doneCount, donePageSize)
if (pageToLoad < totalPages) {
void prefetchDonePage(pageToLoad + 1)
}
return return
} }
@ -822,10 +827,15 @@ export default function App() {
if (count !== doneCount) { if (count !== doneCount) {
setDoneCount(count) setDoneCount(count)
} }
const totalPages = getDoneTotalPages(count, donePageSize)
if (pageToLoad < totalPages) {
void prefetchDonePage(pageToLoad + 1)
}
} catch { } catch {
// ignore // ignore
} }
}, [donePage, doneSort, prefetchDonePage, includeKeep, doneCount]) }, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount])
useEffect(() => { useEffect(() => {
try { try {
@ -2767,6 +2777,47 @@ export default function App() {
} }
} }
const fillDonePageFromPrefetch = useCallback((opts?: { removedFile?: string }) => {
const removedFile = String(opts?.removedFile ?? '').trim()
setDoneJobs((prev) => {
const filtered = removedFile
? prev.filter((j) => baseName(j.output || '') !== removedFile)
: [...prev]
const need = Math.max(0, donePageSize - filtered.length)
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const next: RecordJob[] = [...filtered]
const used = new Set(
next.map((x) => String(x.id || baseName(x.output || '')).trim())
)
while (next.length < donePageSize && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
donePrefetchRef.current = {
...buf,
items: buf.items,
ts: buf.ts,
}
return next
})
}, [donePage, donePageSize, doneSort])
const handleDeleteJobWithUndo = useCallback( const handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string }> => { async (job: RecordJob): Promise<void | { undoToken?: string }> => {
const file = baseName(job.output || '') const file = baseName(job.output || '')
@ -2783,45 +2834,14 @@ export default function App() {
), ),
onSuccess: async () => { onSuccess: async () => {
window.setTimeout(() => { window.setTimeout(() => {
// ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch) fillDonePageFromPrefetch({ removedFile: file })
setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
const need = donePageSize - filtered.length
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
while (next.length < donePageSize && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
return next
})
// ✅ Count sofort optimistisch runter
setDoneCount((c) => Math.max(0, c - 1)) setDoneCount((c) => Math.max(0, c - 1))
// ✅ Running-/Player-State bereinigen (falls offen)
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Prefetch wieder nachfüllen aber nur wenn es noch eine nächste Seite gibt
const nextDoneCount = Math.max(0, doneCount - 1) const nextDoneCount = Math.max(0, doneCount - 1)
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount) const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize)
if (donePage < totalPagesAfterDelete) { if (donePage < totalPagesAfterDelete) {
void prefetchDonePage(donePage + 1) void prefetchDonePage(donePage + 1)
@ -2841,7 +2861,15 @@ export default function App() {
return return
} }
}, },
[runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage, doneCount] [
runFinishedFileAction,
notify,
fillDonePageFromPrefetch,
donePage,
prefetchDonePage,
doneCount,
loadDoneCount,
]
) )
const handleKeepJob = useCallback( const handleKeepJob = useCallback(
@ -2856,45 +2884,14 @@ export default function App() {
run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }), run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }),
onSuccess: async () => { onSuccess: async () => {
window.setTimeout(() => { window.setTimeout(() => {
// ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch) fillDonePageFromPrefetch({ removedFile: file })
setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
const need = donePageSize - filtered.length
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
while (next.length < donePageSize && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
return next
})
// ✅ Entfernt aus Running + Player
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Count runter
setDoneCount((c) => Math.max(0, c - 1)) setDoneCount((c) => Math.max(0, c - 1))
// ✅ Prefetch wieder nachfüllen aber nur wenn es noch eine nächste Seite gibt
const nextDoneCount = Math.max(0, doneCount - 1) const nextDoneCount = Math.max(0, doneCount - 1)
const totalPagesAfterKeep = getDoneTotalPages(nextDoneCount) const totalPagesAfterKeep = getDoneTotalPages(nextDoneCount, donePageSize)
if (donePage < totalPagesAfterKeep) { if (donePage < totalPagesAfterKeep) {
void prefetchDonePage(donePage + 1) void prefetchDonePage(donePage + 1)
@ -2914,8 +2911,8 @@ export default function App() {
[ [
runFinishedFileAction, runFinishedFileAction,
notify, notify,
fillDonePageFromPrefetch,
donePage, donePage,
doneSort,
prefetchDonePage, prefetchDonePage,
loadDoneCount, loadDoneCount,
doneCount, doneCount,
@ -3310,18 +3307,21 @@ export default function App() {
}, ms) }, ms)
} }
const kick = () => void checkClipboard() const kick = () => {
window.addEventListener('hover', kick) void checkClipboard()
document.addEventListener('visibilitychange', kick) }
schedule(0) window.addEventListener('focus', kick)
document.addEventListener('visibilitychange', kick)
return () => { schedule(0)
cancelled = true
if (timer) window.clearTimeout(timer) return () => {
window.removeEventListener('hover', kick) cancelled = true
document.removeEventListener('visibilitychange', kick) if (timer) window.clearTimeout(timer)
} window.removeEventListener('focus', kick)
document.removeEventListener('visibilitychange', kick)
}
}, [autoAddEnabled, autoStartEnabled, enqueueStart]) }, [autoAddEnabled, autoStartEnabled, enqueueStart])
if (!authChecked) { if (!authChecked) {

View File

@ -3,6 +3,7 @@
'use client' 'use client'
import { useMemo, useState, useCallback, useEffect, useRef } from 'react' import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import Table, { type Column } from './Table' import Table, { type Column } from './Table'
import Card from './Card' import Card from './Card'
import Button from './Button' import Button from './Button'
@ -563,6 +564,46 @@ function DiskEmergencyBadge() {
) )
} }
function LazyMountWhenVisible({
children,
placeholder,
rootMargin = '300px',
}: {
children: ReactNode
placeholder: ReactNode
rootMargin?: string
}) {
const hostRef = useRef<HTMLDivElement | null>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
if (visible) return
const el = hostRef.current
if (!el) return
const io = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry?.isIntersecting) {
setVisible(true)
io.disconnect()
}
},
{ rootMargin, threshold: 0.01 }
)
io.observe(el)
return () => io.disconnect()
}, [visible, rootMargin])
return (
<div ref={hostRef} className="w-full h-full">
{visible ? children : placeholder}
</div>
)
}
export default function Downloads({ export default function Downloads({
jobs, jobs,
pending = [], pending = [],
@ -952,24 +993,30 @@ export default function Downloads({
const j = r.job const j = r.job
return ( return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md"> <div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
<ModelPreview <LazyMountWhenVisible
jobId={j.id} placeholder={
blur={blurPreviews} <div className="h-full w-full bg-gray-100 dark:bg-white/10" />
roomStatus={effectiveRoomStatusOfJob( }
j, >
roomStatusByModelKey, <ModelPreview
modelsByKey, jobId={j.id}
growingByJobId blur={blurPreviews}
)} roomStatus={effectiveRoomStatusOfJob(
alignStartAt={j.startedAt} j,
alignEndAt={previewAlignEndAtOfJob(j)} roomStatusByModelKey,
alignEveryMs={10_000} modelsByKey,
fastRetryMs={1000} growingByJobId
fastRetryMax={25} )}
fastRetryWindowMs={60_000} alignStartAt={j.startedAt}
thumbsCandidates={jobThumbsJPGCandidates(j)} alignEndAt={previewAlignEndAtOfJob(j)}
className="w-full h-full" alignEveryMs={10_000}
/> fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsJPGCandidates(j)}
className="w-full h-full"
/>
</LazyMountWhenVisible>
</div> </div>
) )
} }

View File

@ -1289,7 +1289,6 @@ export default function FinishedDownloads({
const GALLERY_CARD_SCALE_KEY = 'finishedDownloads_galleryCardScale_v1' const GALLERY_CARD_SCALE_KEY = 'finishedDownloads_galleryCardScale_v1'
const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1' const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'
const allMode = loadMode === 'all'
const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover' const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
const teaserPlaybackMode: TeaserPlaybackMode = const teaserPlaybackMode: TeaserPlaybackMode =
pauseTeasers ? 'still' : requestedTeaserPlaybackMode pauseTeasers ? 'still' : requestedTeaserPlaybackMode
@ -1327,7 +1326,6 @@ export default function FinishedDownloads({
const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set()) const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())
const [hiddenFiles, setHiddenFiles] = React.useState<Set<string>>(() => new Set()) const [hiddenFiles, setHiddenFiles] = React.useState<Set<string>>(() => new Set())
const [isLoading, setIsLoading] = React.useState(false)
const [undoing, setUndoing] = React.useState(false) const [undoing, setUndoing] = React.useState(false)
const [bulkBusy, setBulkBusy] = React.useState(false) const [bulkBusy, setBulkBusy] = React.useState(false)
@ -1339,11 +1337,6 @@ export default function FinishedDownloads({
const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({}) const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})
const [overrideDoneJobs, setOverrideDoneJobs] = React.useState<RecordJob[] | null>(null)
const [overrideDoneJobsKey, setOverrideDoneJobsKey] = React.useState<string | null>(null)
const [overrideDoneTotal, setOverrideDoneTotal] = React.useState<number | null>(null)
const [refillTick, setRefillTick] = React.useState(0)
const [view, setView] = React.useState<ViewMode>('table') const [view, setView] = React.useState<ViewMode>('table')
const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false) const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false) const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false)
@ -1396,12 +1389,6 @@ export default function FinishedDownloads({
const teaserKeyRafRef = React.useRef<number | null>(null) const teaserKeyRafRef = React.useRef<number | null>(null)
const pendingTeaserKeyRef = React.useRef<string | null>(null) const pendingTeaserKeyRef = React.useRef<string | null>(null)
const refillInFlightRef = React.useRef(false)
const refillQueuedWhileInFlightRef = React.useRef(false)
const refillSessionRef = React.useRef(0)
const refillTimerRef = React.useRef<number | null>(null)
const refillRetryRef = React.useRef(0)
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any }) const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
const scaleLiveRafRef = React.useRef<number | null>(null) const scaleLiveRafRef = React.useRef<number | null>(null)
@ -1418,8 +1405,6 @@ export default function FinishedDownloads({
const removeTimersRef = React.useRef<Map<string, number>>(new Map()) const removeTimersRef = React.useRef<Map<string, number>>(new Map())
const galleryHydratedKeyRef = React.useRef<string | null>(null)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// memoized derived data // memoized derived data
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -1467,39 +1452,17 @@ export default function FinishedDownloads({
activeTagSet.size > 0 || searchTokens.some((t) => t.length >= 2) activeTagSet.size > 0 || searchTokens.some((t) => t.length >= 2)
const globalFilterActive = searchActiveForGlobalFetch const globalFilterActive = searchActiveForGlobalFetch
const effectiveAllMode = globalFilterActive || allMode const effectiveAllMode = globalFilterActive || loadMode === 'all'
const galleryPageSize = Math.max(2, galleryColumnCount * 2) const galleryPageSize = Math.max(2, galleryColumnCount * 2)
const serverPageSize = pageSize
const effectivePageSize = const effectivePageSize =
view === 'gallery' view === 'gallery'
? galleryPageSize ? galleryPageSize
: pageSize : pageSize
const galleryHydrationKey = const doneJobsPage = doneJobs
view === 'gallery' && loadMode === 'paged' && !effectiveAllMode const doneTotalPage = doneTotal
? `${page}__${sortMode}__${includeKeep ? 1 : 0}__${effectivePageSize}`
: null
const hasMatchingGalleryOverride =
view === 'gallery' &&
galleryHydrationKey != null &&
overrideDoneJobsKey === galleryHydrationKey
const doneJobsPage =
effectiveAllMode
? (overrideDoneJobs ?? doneJobs)
: hasMatchingGalleryOverride && overrideDoneJobs
? overrideDoneJobs
: doneJobs
const doneTotalPage =
effectiveAllMode
? (overrideDoneTotal ?? doneTotal)
: hasMatchingGalleryOverride && overrideDoneTotal != null
? overrideDoneTotal
: doneTotal
const applyRenamedOutputLocal = useCallback( const applyRenamedOutputLocal = useCallback(
(job: RecordJob): RecordJob => { (job: RecordJob): RecordJob => {
@ -1701,9 +1664,7 @@ export default function FinishedDownloads({
const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0 const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
const emptyByFilter = globalFilterActive && visibleRows.length === 0 const emptyByFilter = globalFilterActive && visibleRows.length === 0
const emptyAll = allMode && visibleRows.length === 0 const isEmptyState = emptyFolder || emptyByFilter
const isEmptyState = emptyFolder || emptyAll || emptyByFilter
const showLoadingCard = isLoading && !isEmptyState
const selectedCount = selectedFiles.length const selectedCount = selectedFiles.length
const allPageSelected = const allPageSelected =
@ -1837,42 +1798,6 @@ export default function FinishedDownloads({
const clearTagFilter = useCallback(() => setTagFilter([]), []) const clearTagFilter = useCallback(() => setTagFilter([]), [])
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 queueRefill = useCallback(() => {
if (refillTimerRef.current != null) return
refillTimerRef.current = window.setTimeout(() => {
refillTimerRef.current = null
setRefillTick((n) => n + 1)
}, 80)
}, [])
const flushResolutionsSoon = useCallback(() => { const flushResolutionsSoon = useCallback(() => {
if (resolutionsFlushTimerRef.current != null) return if (resolutionsFlushTimerRef.current != null) return
resolutionsFlushTimerRef.current = window.setTimeout(() => { resolutionsFlushTimerRef.current = window.setTimeout(() => {
@ -2273,28 +2198,16 @@ export default function FinishedDownloads({
return (await r.json().catch(() => null)) as any return (await r.json().catch(() => null)) as any
}, },
onSuccess: async (result: any) => { onSuccess: async (result: any) => {
if (onDeleteJob) {
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else {
setLastAction(null)
}
return
}
const from = (result?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : '' const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) { if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from }) setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
} else { } else {
setLastAction(null) setLastAction(null)
} }
queueRefill()
emitCountHint(-1) emitCountHint(-1)
}, }
}) })
return res.ok return res.ok
@ -2305,7 +2218,6 @@ export default function FinishedDownloads({
onDeleteJob, onDeleteJob,
withFileReleaseRetry, withFileReleaseRetry,
runFileMutation, runFileMutation,
queueRefill,
emitCountHint, emitCountHint,
] ]
) )
@ -2355,9 +2267,8 @@ export default function FinishedDownloads({
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key }) setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
queueRefill()
emitCountHint(includeKeep ? 0 : -1) emitCountHint(includeKeep ? 0 : -1)
}, }
}) })
return res.ok return res.ok
@ -2368,7 +2279,6 @@ export default function FinishedDownloads({
deletingKeys, deletingKeys,
withFileReleaseRetry, withFileReleaseRetry,
runFileMutation, runFileMutation,
queueRefill,
emitCountHint, emitCountHint,
includeKeep, includeKeep,
] ]
@ -2468,7 +2378,6 @@ export default function FinishedDownloads({
const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1 const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
emitCountHint(visibleDelta) emitCountHint(visibleDelta)
queueRefill()
setLastAction(null) setLastAction(null)
return return
} }
@ -2490,7 +2399,6 @@ export default function FinishedDownloads({
unhideRow(restoredFile, restoredFile) unhideRow(restoredFile, restoredFile)
emitCountHint(+1) emitCountHint(+1)
queueRefill()
setLastAction(null) setLastAction(null)
return return
} }
@ -2513,7 +2421,6 @@ export default function FinishedDownloads({
applyRename(oldFile, newFile) applyRename(oldFile, newFile)
} }
queueRefill()
setLastAction(null) setLastAction(null)
return return
} }
@ -2526,7 +2433,6 @@ export default function FinishedDownloads({
lastAction, lastAction,
undoing, undoing,
notify, notify,
queueRefill,
includeKeep, includeKeep,
emitCountHint, emitCountHint,
applyRename, applyRename,
@ -2594,10 +2500,6 @@ export default function FinishedDownloads({
} }
setLastAction({ kind: 'hot', currentFile: apiNew }) setLastAction({ kind: 'hot', currentFile: apiNew })
if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
queueRefill()
}
}, },
onError: async () => { onError: async () => {
clearRenamePair(oldFile, optimisticNew) clearRenamePair(oldFile, optimisticNew)
@ -2613,8 +2515,6 @@ export default function FinishedDownloads({
clearRenamePair, clearRenamePair,
onToggleHot, onToggleHot,
withFileReleaseRetry, withFileReleaseRetry,
queueRefill,
sortMode,
] ]
) )
@ -2807,7 +2707,6 @@ export default function FinishedDownloads({
const deletedCount = Number(data?.deleted ?? results.filter((x: any) => x?.ok).length ?? 0) const deletedCount = Number(data?.deleted ?? results.filter((x: any) => x?.ok).length ?? 0)
clearSelection() clearSelection()
queueRefill()
emitCountHint(-deletedCount) emitCountHint(-deletedCount)
if (deletedCount > 0) { if (deletedCount > 0) {
@ -2824,7 +2723,6 @@ export default function FinishedDownloads({
selectedItems, selectedItems,
animateRemove, animateRemove,
clearSelection, clearSelection,
queueRefill,
emitCountHint, emitCountHint,
notify, notify,
]) ])
@ -2869,7 +2767,6 @@ export default function FinishedDownloads({
const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0) const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0)
clearSelection() clearSelection()
queueRefill()
emitCountHint(includeKeep ? 0 : -keptCount) emitCountHint(includeKeep ? 0 : -keptCount)
if (keptCount > 0) { if (keptCount > 0) {
@ -2886,7 +2783,6 @@ export default function FinishedDownloads({
selectedItems, selectedItems,
animateRemove, animateRemove,
clearSelection, clearSelection,
queueRefill,
emitCountHint, emitCountHint,
includeKeep, includeKeep,
notify, notify,
@ -2905,21 +2801,6 @@ export default function FinishedDownloads({
} }
}, [view, galleryColumnCount, pageSize, onPageSizeChange]) }, [view, galleryColumnCount, pageSize, onPageSizeChange])
useEffect(() => {
if (effectiveAllMode) return
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
setOverrideDoneJobsKey(null)
}, [effectiveAllMode])
useEffect(() => {
if (view === 'gallery') return
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
setOverrideDoneJobsKey(null)
}, [view])
useEffect(() => { useEffect(() => {
try { try {
const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY) const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY)
@ -3250,73 +3131,6 @@ export default function FinishedDownloads({
}, [lastAction]) }, [lastAction])
useEffect(() => { 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])
useEffect(() => {
if (refillTick === 0) return
const ac = new AbortController()
let alive = true
let finished = false
const mySession = ++refillSessionRef.current
const finishRefill = () => {
if (finished) return
finished = true
if (refillSessionRef.current !== mySession) return
refillInFlightRef.current = false
if (refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = false
queueRefill()
}
}
refillInFlightRef.current = true
// ✅ paged-mode hier NICHT mehr laden
if (!effectiveAllMode) {
finishRefill()
return () => {
alive = false
ac.abort()
finishRefill()
}
}
;(async () => {
try {
await fetchAllDoneJobs(ac.signal)
if (alive) refillRetryRef.current = 0
} catch {
} finally {
if (alive) finishRefill()
}
})()
return () => {
alive = false
ac.abort()
finishRefill()
}
}, [refillTick, effectiveAllMode, fetchAllDoneJobs, queueRefill])
useEffect(() => {
if (isLoading) return
if (effectiveAllMode) return if (effectiveAllMode) return
if (totalItemsForPagination < 0) return if (totalItemsForPagination < 0) return
@ -3326,7 +3140,6 @@ export default function FinishedDownloads({
onPageChange(totalPages) onPageChange(totalPages)
} }
}, [ }, [
isLoading,
effectiveAllMode, effectiveAllMode,
totalItemsForPagination, totalItemsForPagination,
effectivePageSize, effectivePageSize,
@ -3334,103 +3147,6 @@ export default function FinishedDownloads({
onPageChange, onPageChange,
]) ])
useEffect(() => {
if (loadMode !== 'paged') return
if (effectiveAllMode) return
if (view !== 'gallery') return
if (!galleryHydrationKey) return
const currentCount = hasMatchingGalleryOverride
? (overrideDoneJobs?.length ?? 0)
: doneJobs.length
const wantedCount = effectivePageSize
const totalCount = doneTotal
const pageStart = (page - 1) * wantedCount
const maxItemsOnThisPage = Math.max(0, totalCount - pageStart)
if (maxItemsOnThisPage <= 0) {
galleryHydratedKeyRef.current = galleryHydrationKey
return
}
const targetCount = Math.min(wantedCount, maxItemsOnThisPage)
if (currentCount >= targetCount) {
galleryHydratedKeyRef.current = galleryHydrationKey
return
}
if (galleryHydratedKeyRef.current === galleryHydrationKey) {
return
}
const ac = new AbortController()
let cancelled = false
setIsLoading(true)
;(async () => {
try {
const res = await fetch(
`/api/record/done?page=${page}&pageSize=${targetCount}&sort=${encodeURIComponent(sortMode)}${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res.ok || cancelled || ac.signal.aborted) return
const data = await res.json().catch(() => null)
if (cancelled || ac.signal.aborted) return
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? data
: []
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setOverrideDoneJobs(items)
setOverrideDoneTotal(count)
setOverrideDoneJobsKey(galleryHydrationKey)
galleryHydratedKeyRef.current = galleryHydrationKey
} catch (err: any) {
if (err?.name !== 'AbortError') {
console.error('gallery hydration failed', err)
}
} finally {
if (!cancelled) {
setIsLoading(false)
}
}
})()
return () => {
cancelled = true
ac.abort()
}
}, [
loadMode,
effectiveAllMode,
view,
page,
sortMode,
includeKeep,
doneJobs,
doneTotal,
effectivePageSize,
serverPageSize,
galleryHydrationKey,
])
useEffect(() => {
galleryHydratedKeyRef.current = null
}, [page, sortMode, includeKeep, effectivePageSize, view])
useEffect(() => { useEffect(() => {
const m = new Map<string, string>() const m = new Map<string, string>()
for (const j of viewRows) { for (const j of viewRows) {
@ -3516,37 +3232,12 @@ export default function FinishedDownloads({
if (detail.phase === 'success') { if (detail.phase === 'success') {
markDeleting(key, false) markDeleting(key, false)
if (refillInFlightRef.current) {
refillQueuedWhileInFlightRef.current = true
} else {
queueRefill()
}
} }
} }
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener) window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, queueRefill, restoreRow]) }, [animateRemove, markDeleting, restoreRow])
useEffect(() => {
const onReload = () => {
if (document.hidden) return
if (refillInFlightRef.current) {
if (!refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = true
}
return
}
if (refillTimerRef.current != null) return
queueRefill()
}
window.addEventListener('finished-downloads:reload', onReload as EventListener)
return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener)
}, [queueRefill])
useEffect(() => { useEffect(() => {
const onExternalRename = (ev: Event) => { const onExternalRename = (ev: Event) => {
@ -3706,8 +3397,7 @@ export default function FinishedDownloads({
<LoadingSpinner <LoadingSpinner
size="lg" size="lg"
className={[ className={[
'text-indigo-500 transition-opacity duration-150', 'text-indigo-500 transition-opacity duration-150 opacity-0 pointer-events-none',
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none',
].join(' ')} ].join(' ')}
srLabel="Lade Downloads…" srLabel="Lade Downloads…"
/> />
@ -3866,8 +3556,7 @@ export default function FinishedDownloads({
<LoadingSpinner <LoadingSpinner
size="lg" size="lg"
className={[ className={[
'text-indigo-500 transition-opacity duration-150', 'text-indigo-500 transition-opacity duration-150 opacity-0 pointer-events-none',
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none',
].join(' ')} ].join(' ')}
srLabel="Lade Downloads…" srLabel="Lade Downloads…"
/> />
@ -4253,26 +3942,7 @@ export default function FinishedDownloads({
</div> </div>
</div> </div>
{showLoadingCard ? ( {emptyFolder ? (
<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> <Card grayBody>
<div className="flex items-center gap-3"> <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"> <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">
@ -4332,7 +4002,7 @@ export default function FinishedDownloads({
}} }}
isSmall={isSmall} isSmall={isSmall}
jobForDetails={jobForDetails} jobForDetails={jobForDetails}
isLoading={isLoading} isLoading={false}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
durations={durations} durations={durations}
teaserState={teaserState} teaserState={teaserState}
@ -4394,7 +4064,7 @@ export default function FinishedDownloads({
{view === 'table' && ( {view === 'table' && (
<FinishedDownloadsTableView <FinishedDownloadsTableView
rows={pageRows} rows={pageRows}
isLoading={isLoading} isLoading={false}
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
onToggleSelected={toggleSelected} onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => { onToggleSelectAllPage={(checked) => {
@ -4465,7 +4135,7 @@ export default function FinishedDownloads({
if (checked) selectPageRows(pageRows) if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows) else deselectPageRows(pageRows)
}} }}
isLoading={isLoading} isLoading={false}
jobForDetails={jobForDetails} jobForDetails={jobForDetails}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
durations={durations} durations={durations}