This commit is contained in:
Linrador 2026-01-19 15:24:09 +01:00
parent d909d951a3
commit e3387dd6fe
9 changed files with 357 additions and 274 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-IS5yelG1.js"></script> <script type="module" crossorigin src="/assets/index-Czq-AJKF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ByYRHYVi.css"> <link rel="stylesheet" crossorigin href="/assets/index-CAKbyWZn.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -994,6 +994,26 @@ export default function App() {
} }
} }
useEffect(() => {
const onHint = (ev: Event) => {
const e = ev as CustomEvent<{ delta?: number }>
const delta = Number(e.detail?.delta ?? 0)
if (!Number.isFinite(delta) || delta === 0) {
void refreshDoneNow()
return
}
// ✅ Tabs sofort updaten (optimistisch)
setDoneCount((c) => Math.max(0, c + delta))
// ✅ danach einmal server-truth holen (Pagination + count 100% korrekt)
void refreshDoneNow()
}
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
}, [refreshDoneNow])
// ---- Player model sync (wie bei dir) ---- // ---- Player model sync (wie bei dir) ----
useEffect(() => { useEffect(() => {
if (!playerJob) { if (!playerJob) {
@ -1028,6 +1048,10 @@ export default function App() {
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))
}, 320) }, 320)
window.setTimeout(() => {
void refreshDoneNow()
}, 350)
} catch (e: any) { } catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })) window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
notify.error('Löschen fehlgeschlagen', e?.message ?? String(e)) notify.error('Löschen fehlgeschlagen', e?.message ?? String(e))
@ -1052,7 +1076,7 @@ export default function App() {
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320) }, 320)
if (selectedTab !== 'finished') void refreshDoneNow() void refreshDoneNow()
} catch (e: any) { } catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })) window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
notify.error('Keep fehlgeschlagen', e?.message ?? String(e)) notify.error('Keep fehlgeschlagen', e?.message ?? String(e))

View File

@ -14,8 +14,6 @@ import {
RectangleStackIcon, RectangleStackIcon,
Squares2X2Icon, Squares2X2Icon,
AdjustmentsHorizontalIcon, AdjustmentsHorizontalIcon,
ChevronDownIcon,
ChevronUpIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import { type SwipeCardHandle } from './SwipeCard' import { type SwipeCardHandle } from './SwipeCard'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
@ -30,6 +28,7 @@ import Button from './Button'
import { useNotify } from './notify' import { useNotify } from './notify'
import LazyMount from './LazyMount' import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import LabeledSwitch from './LabeledSwitch'
import Switch from './Switch' import Switch from './Switch'
type SortMode = type SortMode =
@ -388,9 +387,11 @@ export default function FinishedDownloads({
// Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1) // Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1)
if (globalFilterActive) return if (globalFilterActive) return
// ✅ Overrides nur zurücksetzen, wenn sich die "Query" ändert,
// nicht wenn App optimistisch doneJobs filtert.
setOverrideDoneJobs(null) setOverrideDoneJobs(null)
setOverrideDoneTotal(null) setOverrideDoneTotal(null)
}, [doneJobs, doneTotal, globalFilterActive]) }, [page, pageSize, sortMode, includeKeep, globalFilterActive])
useEffect(() => { useEffect(() => {
if (!includeKeep) { if (!includeKeep) {
@ -604,6 +605,10 @@ export default function FinishedDownloads({
} }
animateRemove(key) animateRemove(key)
// ✅ Tab-Count sofort korrigieren (App hört drauf)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } }))
return true return true
} catch (e: any) { } catch (e: any) {
notify.error('Löschen fehlgeschlagen', String(e?.message || e)) notify.error('Löschen fehlgeschlagen', String(e?.message || e))
@ -637,6 +642,10 @@ export default function FinishedDownloads({
// ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished // ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
animateRemove(key) animateRemove(key)
// ✅ Tab-Count sofort korrigieren (App hört drauf)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } }))
return true return true
} catch (e: any) { } catch (e: any) {
notify.error('Keep fehlgeschlagen', String(e?.message || e)) notify.error('Keep fehlgeschlagen', String(e?.message || e))
@ -656,13 +665,51 @@ export default function FinishedDownloads({
return return
} }
// ✅ HOT-Name berechnen (genau "HOT " Prefix)
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
// ✅ UI-optimistisch umbenennen + Dauer-Key mitziehen
const applyOptimisticRename = (oldFile: string, newFile: string) => {
if (!newFile || newFile === oldFile) return
setRenamedFiles((prev) => ({ ...prev, [oldFile]: newFile }))
setDurations((prev) => {
const v = prev[oldFile]
if (typeof v !== 'number') return prev
const { [oldFile]: _omit, ...rest } = prev
return { ...rest, [newFile]: v }
})
}
// ✅ falls Backend andere Namen liefert: Server-Truth nachziehen
const applyServerTruth = (apiOld: string, apiNew: string, optimisticNew: string) => {
if (!apiNew) return
if (apiNew === optimisticNew && apiOld === file) return
setRenamedFiles((prev) => ({ ...prev, [apiOld]: apiNew }))
setDurations((prev) => {
const v = prev[apiOld] ?? prev[optimisticNew]
if (typeof v !== 'number') return prev
const { [apiOld]: _omit1, [optimisticNew]: _omit2, ...rest } = prev as any
return { ...rest, [apiNew]: v }
})
}
try { try {
await releasePlayingFile(file, { close: true }) await releasePlayingFile(file, { close: true })
// ✅ Wenn du extern einen Handler hast, kannst du den nutzen const oldFile = file
// (Wenn du KEINEN hast: läuft der Fallback unten) const optimisticNew = toggledName(oldFile)
// ✅ Wichtig: Optimistik IMMER anwenden auch wenn ein externer onToggleHot Handler existiert
applyOptimisticRename(oldFile, optimisticNew)
// ✅ Externer Handler (App) danach Liste auffrischen
if (onToggleHot) { if (onToggleHot) {
await onToggleHot(job) await onToggleHot(job)
queueRefill()
return return
} }
@ -674,26 +721,17 @@ export default function FinishedDownloads({
} }
const data = (await res.json().catch(() => null)) as any const data = (await res.json().catch(() => null)) as any
const oldFile = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : file const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : file
const newFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : '' const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : ''
if (newFile) { applyServerTruth(apiOld, apiNew, optimisticNew)
// Optimistisch umbenennen (nicht aufs nächste Polling warten)
setRenamedFiles((prev) => ({ ...prev, [oldFile]: newFile }))
// Dauer-Key mitziehen (optional) queueRefill()
setDurations((prev) => {
const v = prev[oldFile]
if (typeof v !== 'number') return prev
const { [oldFile]: _omit, ...rest } = prev
return { ...rest, [newFile]: v }
})
}
} catch (e: any) { } catch (e: any) {
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e)) notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
} }
}, },
[baseName, releasePlayingFile, onToggleHot, notify] [baseName, releasePlayingFile, onToggleHot, notify, queueRefill]
) )
const runtimeSecondsForSort = useCallback((job: RecordJob) => { const runtimeSecondsForSort = useCallback((job: RecordJob) => {
@ -1255,9 +1293,9 @@ export default function FinishedDownloads({
" "
> >
{/* Header row */} {/* Header row */}
<div className="flex items-center justify-between gap-2 p-3"> <div className="flex items-center gap-3 p-3">
{/* Mobile title + count badge */} {/* Left: Title + Count */}
<div className="sm:hidden flex items-center gap-2 min-w-0"> <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"> <div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene Downloads Abgeschlossene Downloads
</div> </div>
@ -1266,18 +1304,51 @@ export default function FinishedDownloads({
</span> </span>
</div> </div>
{/* Desktop title */} {/* Mobile title (bleibt wie gehabt, aber kompakter) */}
<div className="hidden sm:flex items-center gap-2 min-w-0"> <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"> <div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene Downloads Abgeschlossene Downloads
</div> </div>
<span className="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"> <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} {totalItemsForPagination}
</span> </span>
</div> </div>
<div className="shrink-0 flex items-center gap-2"> {/* Right: Controls */}
{/* Desktop: Sort rechts neben View-Buttons */} <div className="flex items-center gap-2 ml-auto shrink-0">
{/* 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}>
Clear
</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' && ( {view !== 'table' && (
<div className="hidden sm:block"> <div className="hidden sm:block">
<label className="sr-only" htmlFor="finished-sort"> <label className="sr-only" htmlFor="finished-sort">
@ -1310,109 +1381,87 @@ export default function FinishedDownloads({
</div> </div>
)} )}
{/* Keep toggle (Desktop) */}
<div className="hidden sm:flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-200">Behaltene Downloads anzeigen</span>
<Switch
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="short"
className=""
/>
</div>
{/* Desktop: Suche neben Sort */}
<div className="hidden sm:flex items-center gap-2 min-w-0">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className="
h-9 w-56 md:w-72 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}>
Clear
</Button>
) : null}
</div>
{/* Mobile: Optionen ein/ausklappen */}
<button
type="button"
className="sm:hidden inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-2.5 text-xs font-semibold text-gray-900 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"
>
<AdjustmentsHorizontalIcon className="size-4" />
Optionen
{mobileOptionsOpen ? (
<ChevronUpIcon className="size-4 opacity-80" />
) : (
<ChevronDownIcon className="size-4 opacity-80" />
)}
</button>
{/* Views */} {/* Views */}
<ButtonGroup <ButtonGroup
value={view} value={view}
onChange={(id) => setView(id as ViewMode)} onChange={(id) => setView(id as ViewMode)}
size="md" size={isSmall ? 'sm' : 'md'}
ariaLabel="Ansicht" ariaLabel="Ansicht"
items={[ items={[
{ { id: 'table', icon: <TableCellsIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Tabelle', srLabel: 'Tabelle' },
id: 'table', { id: 'cards', icon: <RectangleStackIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Cards', srLabel: 'Cards' },
icon: <TableCellsIcon className="size-5" />, { id: 'gallery', icon: <Squares2X2Icon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Galerie', srLabel: 'Galerie' },
label: isSmall ? undefined : 'Tabelle',
srLabel: 'Tabelle',
},
{
id: 'cards',
icon: <RectangleStackIcon className="size-5" />,
label: isSmall ? undefined : 'Cards',
srLabel: 'Cards',
},
{
id: 'gallery',
icon: <Squares2X2Icon className="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>
</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 */} {/* Mobile Optionen (einklappbar): Suche + Keep + Sort */}
<div <div
id="finished-mobile-options" id="finished-mobile-options"
className={[ className={[
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out', 'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
mobileOptionsOpen ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0', mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
].join(' ')} ].join(' ')}
> >
{/* “Sheet”-Body */}
{/* Mobile Suche */} <div className="border-t border-gray-200/60 dark:border-white/10 p-3">
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2"> <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"> <div className="flex items-center gap-2">
<input <input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…" placeholder="Suchen…"
className=" className="
w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm 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 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] dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
" "
/> />
{(searchQuery || '').trim() !== '' ? ( {(searchQuery || '').trim() !== '' ? (
@ -1423,12 +1472,17 @@ export default function FinishedDownloads({
</div> </div>
</div> </div>
{/* Mobile: Keep Toggle */} {/* Keep als Setting Row */}
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2"> <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="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200"> <div className="min-w-0">
Behaltene Downloads anzeigen <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
</span> Keep anzeigen
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Behaltene Downloads in der Liste
</div>
</div>
<Switch <Switch
checked={includeKeep} checked={includeKeep}
@ -1442,14 +1496,13 @@ export default function FinishedDownloads({
/> />
</div> </div>
</div> </div>
</div>
{/* Mobile Sort (eigene Sektion, schöner Abstand automatisch durch Trenner/Padding) */} {/* Sort */}
{view !== 'table' && ( {view !== 'table' ? (
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2"> <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">
<label className="sr-only" htmlFor="finished-sort-mobile"> <div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
Sortierung Sortierung
</label> </div>
<select <select
id="finished-sort-mobile" id="finished-sort-mobile"
value={sortMode} value={sortMode}
@ -1459,8 +1512,8 @@ export default function FinishedDownloads({
if (page !== 1) onPageChange(1) if (page !== 1) onPageChange(1)
}} }}
className=" className="
w-full h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm 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-900 dark:text-gray-100 dark:[color-scheme:dark] 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_desc">Fertiggestellt am </option>
@ -1475,9 +1528,11 @@ export default function FinishedDownloads({
<option value="size_asc">Größe </option> <option value="size_asc">Größe </option>
</select> </select>
</div> </div>
)} ) : null}
</div>
</div>
{/* Tag-Filter (als Sektion IN der Card, nicht nochmal eine eigene Box-in-Box) */} {/* Tag-Filter */}
{tagFilter.length > 0 ? ( {tagFilter.length > 0 ? (
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2"> <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"> <div className="flex flex-wrap items-center gap-2">
@ -1493,8 +1548,8 @@ export default function FinishedDownloads({
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium 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 text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
" "
size='sm' size="sm"
variant='soft' variant="soft"
onClick={clearTagFilter} onClick={clearTagFilter}
> >
Zurücksetzen Zurücksetzen
@ -1505,6 +1560,7 @@ export default function FinishedDownloads({
) : null} ) : null}
</div> </div>
</div> </div>
</div>
{emptyFolder ? ( {emptyFolder ? (
<Card grayBody> <Card grayBody>