Backend: - Matching-Orchestrator mit deutschen Serien-Patterns (drei ???, TKKG, ...) - Vollständige MusicBrainz-Integration (Tracklist → Kapitel, Cover Art Archive) - OpenLibrary + Google Books als Fallback-Quellen - Auto-Accept (≥0.75) vs zu_prüfen (0.5-0.75) vs kein Match - Manuelles Matching: GET /api/items/:id/match/search, POST apply - RSS-Feed-Manager: feedparser, iTunes Search, periodisches Update - APScheduler für Podcast-Feed-Updates (konfigurierbares Intervall) - Podcast-Router: Feed-URL setzen, Episoden, Feed-Suche - HLS: FFmpeg läuft als Background-Task, wartet auf ersten Segment - main.py: APScheduler + neue Router eingebunden Frontend (React + Vite + Tailwind + HLS.js): - Login-Seite mit Fehlerbehandlung - Library-Seite: Grid/Listen-Ansicht, Suche, Tag-Filter, Pagination, Scan - BookCard: Cover, Fortschrittsbalken, zu_prüfen Badge, Quick-Play - BookDetail: Metadaten, Matching-Panel, Kapitel-Liste, Lesezeichen - AudioPlayer: HLS.js, Kapitel-Marker auf Fortschrittsbalken, Speed, Sleep-Timer, Lesezeichen, Keyboard-Shortcuts (Space/Arrows) - MiniPlayer: persistent an Fußzeile, expandierbar - PodcastDetail: Feed-URL, iTunes-Suche, Episoden-Liste - Admin-Panel: Benutzer/Bibliotheken/Einstellungen verwalten - App.tsx: React Router, Auth-Guard, Player-Overlay Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
|
import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X } from 'lucide-react'
|
|
import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me'
|
|
import { getLibraries, scanLibrary, createLibrary, deleteLibrary } from '../api/libraries'
|
|
|
|
type Tab = 'users' | 'libraries' | 'settings'
|
|
|
|
export default function Admin() {
|
|
const [tab, setTab] = useState<Tab>('users')
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<h1 className="text-2xl font-bold text-white mb-6">Administration</h1>
|
|
<div className="flex gap-1 mb-6 bg-surface rounded-xl p-1 w-fit">
|
|
{(['users', 'libraries', 'settings'] as Tab[]).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t)}
|
|
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
|
|
tab === t ? 'bg-primary text-black font-medium' : 'text-gray-400 hover:text-white'
|
|
}`}
|
|
>
|
|
{t === 'users' ? 'Benutzer' : t === 'libraries' ? 'Bibliotheken' : 'Einstellungen'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'users' && <UsersPanel />}
|
|
{tab === 'libraries' && <LibrariesPanel />}
|
|
{tab === 'settings' && <SettingsPanel />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function UsersPanel() {
|
|
const [users, setUsers] = useState<any[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [form, setForm] = useState({ username: '', password: '', isAdmin: false })
|
|
|
|
useEffect(() => { getUsers().then(setUsers).finally(() => setLoading(false)) }, [])
|
|
|
|
const handleCreate = async () => {
|
|
await createUser(form)
|
|
const updated = await getUsers()
|
|
setUsers(updated)
|
|
setShowCreate(false)
|
|
setForm({ username: '', password: '', isAdmin: false })
|
|
}
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Benutzer wirklich löschen?')) return
|
|
await deleteUser(id)
|
|
setUsers(users.filter((u) => u.id !== id))
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-gray-400 text-sm">{users.length} Benutzer</p>
|
|
<button
|
|
onClick={() => setShowCreate(!showCreate)}
|
|
className="flex items-center gap-2 bg-primary text-black px-3 py-2 rounded-lg text-sm font-medium"
|
|
>
|
|
<Plus size={14} /> Neu
|
|
</button>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<div className="bg-surface border border-white/10 rounded-xl p-4 mb-4 space-y-3">
|
|
<h3 className="text-sm font-semibold text-white">Neuer Benutzer</h3>
|
|
<input
|
|
type="text" placeholder="Benutzername"
|
|
value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })}
|
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="password" placeholder="Passwort"
|
|
value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
|
<input type="checkbox" checked={form.isAdmin} onChange={(e) => setForm({ ...form, isAdmin: e.target.checked })} />
|
|
Admin-Rechte
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleCreate} disabled={!form.username || !form.password}
|
|
className="bg-primary text-black px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
|
Anlegen
|
|
</button>
|
|
<button onClick={() => setShowCreate(false)} className="text-gray-400 px-4 py-2 rounded-lg text-sm hover:text-white">
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? <Loader2 className="text-primary animate-spin" size={24} /> : (
|
|
<div className="space-y-1">
|
|
{users.map((u: any) => (
|
|
<div key={u.id} className="flex items-center gap-4 bg-surface px-4 py-3 rounded-lg">
|
|
<div className="flex-1">
|
|
<p className="text-sm text-white">{u.username}</p>
|
|
<p className="text-xs text-gray-500">{u.email || 'Keine E-Mail'} · {u.isAdmin ? 'Admin' : 'Benutzer'}</p>
|
|
</div>
|
|
<button onClick={() => handleDelete(u.id)} className="text-gray-500 hover:text-red-400 p-1">
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LibrariesPanel() {
|
|
const [libraries, setLibraries] = useState<any[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [scanning, setScanning] = useState<string | null>(null)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [form, setForm] = useState({ name: '', path: '', mediaType: 'book' })
|
|
|
|
useEffect(() => { getLibraries().then(setLibraries).finally(() => setLoading(false)) }, [])
|
|
|
|
const handleScan = async (id: string) => {
|
|
setScanning(id)
|
|
await scanLibrary(id).catch(() => {})
|
|
setTimeout(() => setScanning(null), 5000)
|
|
}
|
|
|
|
const handleCreate = async () => {
|
|
await createLibrary({ name: form.name, folders: [{ fullPath: form.path }], media_type: form.mediaType })
|
|
const libs = await getLibraries()
|
|
setLibraries(libs)
|
|
setShowCreate(false)
|
|
setForm({ name: '', path: '', mediaType: 'book' })
|
|
}
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Bibliothek wirklich löschen?')) return
|
|
await deleteLibrary(id)
|
|
setLibraries(libs => libs.filter((l) => l.id !== id))
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-gray-400 text-sm">{libraries.length} Bibliotheken</p>
|
|
<button onClick={() => setShowCreate(!showCreate)}
|
|
className="flex items-center gap-2 bg-primary text-black px-3 py-2 rounded-lg text-sm font-medium">
|
|
<Plus size={14} /> Neu
|
|
</button>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<div className="bg-surface border border-white/10 rounded-xl p-4 mb-4 space-y-3">
|
|
<h3 className="text-sm font-semibold text-white">Neue Bibliothek</h3>
|
|
<input type="text" placeholder="Name (z.B. Hörbücher)"
|
|
value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
<input type="text" placeholder="Pfad (z.B. /audiofiles/hörbucher)"
|
|
value={form.path} onChange={(e) => setForm({ ...form, path: e.target.value })}
|
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
<select value={form.mediaType} onChange={(e) => setForm({ ...form, mediaType: e.target.value })}
|
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-300 focus:outline-none">
|
|
<option value="book">Hörbücher</option>
|
|
<option value="podcast">Podcasts</option>
|
|
</select>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleCreate} disabled={!form.name || !form.path}
|
|
className="bg-primary text-black px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
|
Anlegen
|
|
</button>
|
|
<button onClick={() => setShowCreate(false)} className="text-gray-400 px-4 py-2 rounded-lg text-sm hover:text-white">
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? <Loader2 className="text-primary animate-spin" size={24} /> : (
|
|
<div className="space-y-2">
|
|
{libraries.map((lib: any) => (
|
|
<div key={lib.id} className="flex items-center gap-4 bg-surface px-4 py-3 rounded-lg">
|
|
<div className="flex-1">
|
|
<p className="text-sm text-white">{lib.name}</p>
|
|
<p className="text-xs text-gray-500">
|
|
{lib.folders?.[0]?.fullPath || ''} · {lib.mediaType}
|
|
</p>
|
|
</div>
|
|
<button onClick={() => handleScan(lib.id)} disabled={scanning === lib.id}
|
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white bg-white/5 px-3 py-1.5 rounded-lg disabled:opacity-50">
|
|
<RefreshCw size={12} className={scanning === lib.id ? 'animate-spin' : ''} />
|
|
Scan
|
|
</button>
|
|
<button onClick={() => handleDelete(lib.id)} className="text-gray-500 hover:text-red-400 p-1">
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SettingsPanel() {
|
|
const [settings, setSettings] = useState<any>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saved, setSaved] = useState(false)
|
|
|
|
useEffect(() => { getSettings().then(setSettings).finally(() => setLoading(false)) }, [])
|
|
|
|
const save = async (key: string, value: any) => {
|
|
await updateSettings({ [key]: value })
|
|
setSettings((s: any) => ({ ...s, [key]: value }))
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 2000)
|
|
}
|
|
|
|
if (loading) return <Loader2 className="text-primary animate-spin" size={24} />
|
|
|
|
const toggle = (key: string) => (
|
|
<button
|
|
onClick={() => save(key, !settings[key])}
|
|
className={`relative w-10 h-5 rounded-full transition-colors ${settings[key] ? 'bg-primary' : 'bg-white/20'}`}
|
|
>
|
|
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-transform ${settings[key] ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
|
</button>
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-4 max-w-lg">
|
|
{saved && (
|
|
<div className="flex items-center gap-2 text-primary text-sm">
|
|
<Check size={14} /> Gespeichert
|
|
</div>
|
|
)}
|
|
{[
|
|
{ key: 'autoMatchBooks', label: 'Auto-Match Hörbücher' },
|
|
{ key: 'autoMatchPodcasts', label: 'Auto-Match Podcasts' },
|
|
].map(({ key, label }) => (
|
|
<div key={key} className="flex items-center justify-between bg-surface px-4 py-3 rounded-lg">
|
|
<p className="text-sm text-gray-300">{label}</p>
|
|
{toggle(key)}
|
|
</div>
|
|
))}
|
|
<div className="flex items-center justify-between bg-surface px-4 py-3 rounded-lg">
|
|
<p className="text-sm text-gray-300">Feed-Update Intervall (Stunden)</p>
|
|
<input
|
|
type="number" min={1} max={168}
|
|
value={settings.podcastUpdateIntervalHours || 24}
|
|
onChange={(e) => save('podcastUpdateIntervalHours', parseInt(e.target.value))}
|
|
className="w-16 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-sm text-white text-center focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|