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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-IS5yelG1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ByYRHYVi.css">
<script type="module" crossorigin src="/assets/index-Czq-AJKF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CAKbyWZn.css">
</head>
<body>
<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) ----
useEffect(() => {
if (!playerJob) {
@ -1028,6 +1048,10 @@ export default function App() {
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320)
window.setTimeout(() => {
void refreshDoneNow()
}, 350)
} catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
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))
}, 320)
if (selectedTab !== 'finished') void refreshDoneNow()
void refreshDoneNow()
} catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
notify.error('Keep fehlgeschlagen', e?.message ?? String(e))

View File

@ -14,8 +14,6 @@ import {
RectangleStackIcon,
Squares2X2Icon,
AdjustmentsHorizontalIcon,
ChevronDownIcon,
ChevronUpIcon,
} from '@heroicons/react/24/outline'
import { type SwipeCardHandle } from './SwipeCard'
import { flushSync } from 'react-dom'
@ -30,6 +28,7 @@ import Button from './Button'
import { useNotify } from './notify'
import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
import LabeledSwitch from './LabeledSwitch'
import Switch from './Switch'
type SortMode =
@ -388,9 +387,11 @@ export default function FinishedDownloads({
// Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1)
if (globalFilterActive) return
// ✅ Overrides nur zurücksetzen, wenn sich die "Query" ändert,
// nicht wenn App optimistisch doneJobs filtert.
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
}, [doneJobs, doneTotal, globalFilterActive])
}, [page, pageSize, sortMode, includeKeep, globalFilterActive])
useEffect(() => {
if (!includeKeep) {
@ -604,6 +605,10 @@ export default function FinishedDownloads({
}
animateRemove(key)
// ✅ Tab-Count sofort korrigieren (App hört drauf)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } }))
return true
} catch (e: any) {
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
animateRemove(key)
// ✅ Tab-Count sofort korrigieren (App hört drauf)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } }))
return true
} catch (e: any) {
notify.error('Keep fehlgeschlagen', String(e?.message || e))
@ -656,13 +665,51 @@ export default function FinishedDownloads({
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 {
await releasePlayingFile(file, { close: true })
// ✅ Wenn du extern einen Handler hast, kannst du den nutzen
// (Wenn du KEINEN hast: läuft der Fallback unten)
const oldFile = file
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) {
await onToggleHot(job)
queueRefill()
return
}
@ -674,26 +721,17 @@ export default function FinishedDownloads({
}
const data = (await res.json().catch(() => null)) as any
const oldFile = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : file
const newFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : ''
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : file
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : ''
if (newFile) {
// Optimistisch umbenennen (nicht aufs nächste Polling warten)
setRenamedFiles((prev) => ({ ...prev, [oldFile]: newFile }))
applyServerTruth(apiOld, apiNew, optimisticNew)
// Dauer-Key mitziehen (optional)
setDurations((prev) => {
const v = prev[oldFile]
if (typeof v !== 'number') return prev
const { [oldFile]: _omit, ...rest } = prev
return { ...rest, [newFile]: v }
})
}
queueRefill()
} catch (e: any) {
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
}
},
[baseName, releasePlayingFile, onToggleHot, notify]
[baseName, releasePlayingFile, onToggleHot, notify, queueRefill]
)
const runtimeSecondsForSort = useCallback((job: RecordJob) => {
@ -1255,9 +1293,9 @@ export default function FinishedDownloads({
"
>
{/* Header row */}
<div className="flex items-center justify-between gap-2 p-3">
{/* Mobile title + count badge */}
<div className="sm:hidden flex items-center gap-2 min-w-0">
<div className="flex items-center gap-3 p-3">
{/* Left: Title + Count */}
<div className="hidden sm:flex items-center gap-2 min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene Downloads
</div>
@ -1266,18 +1304,51 @@ export default function FinishedDownloads({
</span>
</div>
{/* Desktop title */}
<div className="hidden sm:flex items-center gap-2 min-w-0">
{/* Mobile title (bleibt wie gehabt, aber kompakter) */}
<div className="sm:hidden flex items-center gap-2 min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene Downloads
</div>
<span className="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}
</span>
</div>
<div className="shrink-0 flex items-center gap-2">
{/* Desktop: Sort rechts neben View-Buttons */}
{/* Right: Controls */}
<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' && (
<div className="hidden sm:block">
<label className="sr-only" htmlFor="finished-sort">
@ -1310,199 +1381,184 @@ export default function FinishedDownloads({
</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 */}
<ButtonGroup
value={view}
onChange={(id) => setView(id as ViewMode)}
size="md"
size={isSmall ? 'sm' : 'md'}
ariaLabel="Ansicht"
items={[
{
id: 'table',
icon: <TableCellsIcon className="size-5" />,
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',
},
{ id: 'table', icon: <TableCellsIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Tabelle', srLabel: 'Tabelle' },
{ id: 'cards', icon: <RectangleStackIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Cards', srLabel: 'Cards' },
{ id: 'gallery', icon: <Squares2X2Icon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Galerie', srLabel: 'Galerie' },
]}
/>
{/* Mobile: Optionen Button (unverändert) */}
<button
type="button"
className="sm:hidden relative inline-flex items-center justify-center rounded-md border border-gray-200 bg-white p-2 shadow-sm
hover:bg-gray-50 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-white/10"
onClick={() => setMobileOptionsOpen((v) => !v)}
aria-expanded={mobileOptionsOpen}
aria-controls="finished-mobile-options"
aria-label="Filter & Optionen"
>
<AdjustmentsHorizontalIcon className="size-5" />
{/* kleiner Punkt wenn Filter aktiv */}
{globalFilterActive || includeKeep ? (
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-indigo-500 ring-2 ring-white dark:ring-gray-950" />
) : null}
</button>
</div>
</div>
{/* Desktop: aktive Tag-Filter anzeigen */}
{tagFilter.length > 0 ? (
<div className="hidden sm:flex items-center gap-2 border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</div>
</div>
) : null}
{/* Mobile Optionen (einklappbar): Suche + Keep + Sort */}
<div
id="finished-mobile-options"
className={[
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
mobileOptionsOpen ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0',
mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
].join(' ')}
>
{/* “Sheet”-Body */}
<div className="border-t border-gray-200/60 dark:border-white/10 p-3">
<div className="space-y-2">
{/* Suche */}
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="flex items-center gap-2">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className="
h-10 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
"
/>
{(searchQuery || '').trim() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}>
Clear
</Button>
) : null}
</div>
</div>
{/* Mobile Suche */}
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2">
<div className="flex items-center gap-2">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className="
w-full h-9 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>
{/* Keep als Setting Row */}
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Keep anzeigen
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Behaltene Downloads in der Liste
</div>
</div>
<Switch
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"
/>
</div>
</div>
{/* Sort */}
{view !== 'table' ? (
<div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
Sortierung
</div>
<select
id="finished-sort-mobile"
value={sortMode}
onChange={(e) => {
const m = e.target.value as SortMode
onSortModeChange(m)
if (page !== 1) onPageChange(1)
}}
className="
w-full h-10 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
"
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="model_asc">Modelname AZ</option>
<option value="model_desc">Modelname ZA</option>
<option value="file_asc">Dateiname AZ</option>
<option value="file_desc">Dateiname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
<option value="size_asc">Größe </option>
</select>
</div>
) : null}
</div>
</div>
{/* Mobile: Keep Toggle */}
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
Behaltene Downloads anzeigen
</span>
{/* Tag-Filter */}
{tagFilter.length > 0 ? (
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">Tag-Filter</span>
<Switch
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"
/>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
{/* Mobile Sort (eigene Sektion, schöner Abstand automatisch durch Trenner/Padding) */}
{view !== 'table' && (
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2">
<label className="sr-only" htmlFor="finished-sort-mobile">
Sortierung
</label>
<select
id="finished-sort-mobile"
value={sortMode}
onChange={(e) => {
const m = e.target.value as SortMode
onSortModeChange(m)
if (page !== 1) onPageChange(1)
}}
className="
w-full h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
"
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="model_asc">Modelname AZ</option>
<option value="model_desc">Modelname ZA</option>
<option value="file_asc">Dateiname AZ</option>
<option value="file_desc">Dateiname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
<option value="size_asc">Größe </option>
</select>
</div>
)}
{/* Tag-Filter (als Sektion IN der Card, nicht nochmal eine eigene Box-in-Box) */}
{tagFilter.length > 0 ? (
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">Tag-Filter</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size='sm'
variant='soft'
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</div>
</div>
</div>
</div>
) : null}
) : null}
</div>
</div>
</div>