- AudioPlayer: findLast → reverse().find() (ES2022 Kompatibilität) - ChapterList: findLastIndex → manuelles for-loop + implizit any behoben - Library: searchDebounce Variable 't' undefined → korrekte Initialisierung Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
184 lines
7.0 KiB
TypeScript
184 lines
7.0 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react'
|
|
import { useParams } from 'react-router-dom'
|
|
import { Search, RefreshCw, Grid, List, Loader2 } from 'lucide-react'
|
|
import { getLibraryItems, scanLibrary } from '../api/libraries'
|
|
import { getMe } from '../api/me'
|
|
import BookCard from '../components/library/BookCard'
|
|
|
|
const PAGE_SIZE = 48
|
|
|
|
export default function Library() {
|
|
const { libraryId } = useParams<{ libraryId: string }>()
|
|
const [items, setItems] = useState<any[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [page, setPage] = useState(0)
|
|
const [search, setSearch] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [scanning, setScanning] = useState(false)
|
|
const [progressMap, setProgressMap] = useState<Record<string, any>>({})
|
|
const [view, setView] = useState<'grid' | 'list'>('grid')
|
|
const [filterTag, setFilterTag] = useState('')
|
|
|
|
const load = useCallback(async () => {
|
|
if (!libraryId) return
|
|
setLoading(true)
|
|
try {
|
|
const data = await getLibraryItems(libraryId, { page, limit: PAGE_SIZE, search: search || undefined })
|
|
setItems(data.results || [])
|
|
setTotal(data.total || 0)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [libraryId, page, search])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
useEffect(() => {
|
|
getMe().then((me) => {
|
|
const map: Record<string, any> = {}
|
|
for (const p of me.mediaProgress || []) map[p.libraryItemId] = p
|
|
setProgressMap(map)
|
|
}).catch(() => {})
|
|
}, [])
|
|
|
|
const handleScan = async () => {
|
|
if (!libraryId) return
|
|
setScanning(true)
|
|
await scanLibrary(libraryId).catch(() => {})
|
|
setTimeout(() => { setScanning(false); load() }, 3000)
|
|
}
|
|
|
|
const searchDebounce = useCallback(
|
|
(() => {
|
|
let t: ReturnType<typeof setTimeout> | undefined
|
|
return (v: string) => { clearTimeout(t); t = setTimeout(() => { setSearch(v); setPage(0) }, 300) }
|
|
})(),
|
|
[]
|
|
)
|
|
|
|
const displayed = filterTag
|
|
? items.filter((i) => (i.media?.tags || []).includes(filterTag))
|
|
: items
|
|
|
|
const allTags = [...new Set(items.flatMap((i) => i.media?.tags || []))]
|
|
|
|
// Inject progress into items
|
|
const enriched = displayed.map((i) => ({ ...i, _progress: progressMap[i.id] }))
|
|
|
|
return (
|
|
<div className="p-6">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
|
<div className="relative flex-1 min-w-48">
|
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="Suchen..."
|
|
className="w-full bg-surface border border-white/10 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
|
|
onChange={(e) => searchDebounce(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{allTags.length > 0 && (
|
|
<select
|
|
value={filterTag}
|
|
onChange={(e) => setFilterTag(e.target.value)}
|
|
className="bg-surface border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none focus:ring-1 focus:ring-primary"
|
|
>
|
|
<option value="">Alle Tags</option>
|
|
{allTags.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
)}
|
|
|
|
<button
|
|
onClick={handleScan}
|
|
disabled={scanning}
|
|
className="flex items-center gap-2 bg-surface border border-white/10 px-3 py-2 rounded-lg text-sm text-gray-300 hover:text-white hover:bg-white/5 disabled:opacity-50"
|
|
>
|
|
<RefreshCw size={14} className={scanning ? 'animate-spin' : ''} />
|
|
Scan
|
|
</button>
|
|
|
|
<div className="flex bg-surface border border-white/10 rounded-lg overflow-hidden">
|
|
<button
|
|
className={`p-2 ${view === 'grid' ? 'bg-white/10 text-white' : 'text-gray-400'}`}
|
|
onClick={() => setView('grid')}
|
|
><Grid size={16} /></button>
|
|
<button
|
|
className={`p-2 ${view === 'list' ? 'bg-white/10 text-white' : 'text-gray-400'}`}
|
|
onClick={() => setView('list')}
|
|
><List size={16} /></button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<p className="text-sm text-gray-500 mb-4">
|
|
{total} {total === 1 ? 'Eintrag' : 'Einträge'}
|
|
{filterTag && ` · Filter: ${filterTag}`}
|
|
</p>
|
|
|
|
{/* Grid */}
|
|
{loading ? (
|
|
<div className="flex justify-center py-16">
|
|
<Loader2 className="text-primary animate-spin" size={32} />
|
|
</div>
|
|
) : enriched.length === 0 ? (
|
|
<div className="text-center py-16 text-gray-500">
|
|
<p className="text-lg mb-2">Keine Einträge gefunden</p>
|
|
<p className="text-sm">Klicke auf „Scan" um die Bibliothek zu durchsuchen.</p>
|
|
</div>
|
|
) : view === 'grid' ? (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
|
{enriched.map((item) => <BookCard key={item.id} item={item} />)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{enriched.map((item) => {
|
|
const meta = item.media?.metadata || {}
|
|
const title = meta.title || item.relPath || 'Unbekannt'
|
|
const author = meta.authors?.[0]?.name || ''
|
|
const p = item._progress
|
|
return (
|
|
<div key={item.id} className="flex items-center gap-4 bg-surface hover:bg-white/5 px-4 py-3 rounded-lg cursor-pointer">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-white truncate">{title}</p>
|
|
{author && <p className="text-xs text-gray-400">{author}</p>}
|
|
</div>
|
|
{p && !p.isFinished && (
|
|
<div className="w-24 h-1 bg-white/10 rounded-full overflow-hidden flex-shrink-0">
|
|
<div className="h-full bg-primary" style={{ width: `${Math.min((p.currentTime / p.duration) * 100, 100)}%` }} />
|
|
</div>
|
|
)}
|
|
{p?.isFinished && <span className="text-xs text-primary flex-shrink-0">Fertig</span>}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{total > PAGE_SIZE && (
|
|
<div className="flex justify-center gap-2 mt-8">
|
|
<button
|
|
disabled={page === 0}
|
|
onClick={() => setPage(p => p - 1)}
|
|
className="px-4 py-2 bg-surface border border-white/10 rounded-lg text-sm text-gray-300 disabled:opacity-50 hover:bg-white/5"
|
|
>
|
|
Zurück
|
|
</button>
|
|
<span className="px-4 py-2 text-sm text-gray-400">
|
|
{page + 1} / {Math.ceil(total / PAGE_SIZE)}
|
|
</span>
|
|
<button
|
|
disabled={(page + 1) * PAGE_SIZE >= total}
|
|
onClick={() => setPage(p => p + 1)}
|
|
className="px-4 py-2 bg-surface border border-white/10 rounded-lg text-sm text-gray-300 disabled:opacity-50 hover:bg-white/5"
|
|
>
|
|
Weiter
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|