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,199 +1381,184 @@ 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 */}
<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 */} {/* 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 gap-2"> <div className="flex items-center justify-between gap-3">
<input <div className="min-w-0">
value={searchQuery} <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
onChange={(e) => setSearchQuery(e.target.value)} Keep anzeigen
placeholder="Suchen…" </div>
className=" <div className="text-xs text-gray-600 dark:text-gray-300">
w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm Behaltene Downloads in der Liste
focus:outline-none focus:ring-2 focus:ring-indigo-500 </div>
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark] </div>
"
/> <Switch
{(searchQuery || '').trim() !== '' ? ( checked={includeKeep}
<Button size="sm" variant="soft" onClick={clearSearch}> onChange={(checked) => {
Clear if (page !== 1) onPageChange(1)
</Button> 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} ) : null}
</div> </div>
</div> </div>
{/* Mobile: Keep Toggle */} {/* Tag-Filter */}
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2"> {tagFilter.length > 0 ? (
<div className="flex items-center justify-between gap-3"> <div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200"> <div className="flex flex-wrap items-center gap-2">
Behaltene Downloads anzeigen <span className="text-xs font-medium text-gray-600 dark:text-gray-300">Tag-Filter</span>
</span>
<Switch <div className="flex flex-wrap items-center gap-1.5">
checked={includeKeep} {tagFilter.map((t) => (
onChange={(checked) => { <TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
if (page !== 1) onPageChange(1) ))}
setIncludeKeep(checked)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"
/>
</div>
</div>
</div>
{/* Mobile Sort (eigene Sektion, schöner Abstand automatisch durch Trenner/Padding) */} <Button
{view !== 'table' && ( className="
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2"> ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
<label className="sr-only" htmlFor="finished-sort-mobile"> text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
Sortierung "
</label> size="sm"
<select variant="soft"
id="finished-sort-mobile" onClick={clearTagFilter}
value={sortMode} >
onChange={(e) => { Zurücksetzen
const m = e.target.value as SortMode </Button>
onSortModeChange(m) </div>
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>
</div> </div>
</div> </div>
</div> ) : null}
) : null} </div>
</div> </div>
</div> </div>