updated
This commit is contained in:
parent
d909d951a3
commit
e3387dd6fe
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
backend/web/dist/assets/index-ByYRHYVi.css
vendored
1
backend/web/dist/assets/index-ByYRHYVi.css
vendored
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-CAKbyWZn.css
vendored
Normal file
1
backend/web/dist/assets/index-CAKbyWZn.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 A→Z</option>
|
||||
<option value="model_desc">Modelname Z→A</option>
|
||||
<option value="file_asc">Dateiname A→Z</option>
|
||||
<option value="file_desc">Dateiname Z→A</option>
|
||||
<option value="duration_desc">Dauer ↓</option>
|
||||
<option value="duration_asc">Dauer ↑</option>
|
||||
<option value="size_desc">Größe ↓</option>
|
||||
<option value="size_asc">Größe ↑</option>
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 A→Z</option>
|
||||
<option value="model_desc">Modelname Z→A</option>
|
||||
<option value="file_asc">Dateiname A→Z</option>
|
||||
<option value="file_desc">Dateiname Z→A</option>
|
||||
<option value="duration_desc">Dauer ↓</option>
|
||||
<option value="duration_asc">Dauer ↑</option>
|
||||
<option value="size_desc">Größe ↓</option>
|
||||
<option value="size_asc">Größe ↑</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user