React + Vite + TypeScript SPA covering the full ABS feature set (library browsing, item detail, metadata/cover editing, podcasts, player with session sync, admin: users/libraries/scanner/server settings). Dev uses a dynamic CORS proxy; production is served by server/index.mjs (static + reverse proxy to ABS_URL). Includes systemd unit and installer under deploy/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
154 lines
5.5 KiB
TypeScript
154 lines
5.5 KiB
TypeScript
import { useState } from 'react'
|
|
import { Plus, Pencil, Trash2, ScanLine, Folder, BookOpen, Headphones } from 'lucide-react'
|
|
import { PageHeader } from '@/components/ui/PageHeader'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { EmptyState } from '@/components/ui/States'
|
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
|
import { Spinner } from '@/components/ui/Spinner'
|
|
import { LibraryModal } from '@/components/admin/LibraryModal'
|
|
import { getLibraries, deleteLibrary, scanLibrary } from '@/api/libraries'
|
|
import { apiErrorMessage } from '@/api/client'
|
|
import { toast } from '@/store/toastStore'
|
|
import { useLibraryStore } from '@/store/libraryStore'
|
|
import type { Library } from '@/types/abs'
|
|
|
|
export default function AdminLibraries() {
|
|
const libraries = useLibraryStore((s) => s.libraries)
|
|
const setLibraries = useLibraryStore((s) => s.setLibraries)
|
|
const [modal, setModal] = useState<{ open: boolean; library: Library | null }>({ open: false, library: null })
|
|
const [toDelete, setToDelete] = useState<Library | null>(null)
|
|
const [scanning, setScanning] = useState<Set<string>>(new Set())
|
|
|
|
async function reload() {
|
|
try {
|
|
setLibraries(await getLibraries())
|
|
} catch {
|
|
/* keep current */
|
|
}
|
|
}
|
|
|
|
async function startScan(lib: Library, force: boolean) {
|
|
setScanning((s) => new Set(s).add(lib.id))
|
|
try {
|
|
await scanLibrary(lib.id, force)
|
|
toast.success(`Scan gestartet: ${lib.name}`)
|
|
} catch (err) {
|
|
toast.error(apiErrorMessage(err, 'Scan konnte nicht gestartet werden.'))
|
|
} finally {
|
|
// Live progress requires the ABS socket; clear the indicator after a moment.
|
|
setTimeout(() => {
|
|
setScanning((s) => {
|
|
const next = new Set(s)
|
|
next.delete(lib.id)
|
|
return next
|
|
})
|
|
}, 4000)
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!toDelete) return
|
|
try {
|
|
await deleteLibrary(toDelete.id)
|
|
toast.success('Bibliothek gelöscht.')
|
|
await reload()
|
|
} catch (err) {
|
|
toast.error(apiErrorMessage(err, 'Löschen fehlgeschlagen.'))
|
|
} finally {
|
|
setToDelete(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Bibliotheken"
|
|
subtitle="Bibliotheks-Verwaltung"
|
|
actions={
|
|
<Button onClick={() => setModal({ open: true, library: null })}>
|
|
<Plus size={16} /> Bibliothek
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{libraries.length === 0 ? (
|
|
<EmptyState title="Keine Bibliotheken" hint="Lege eine an, um zu starten." />
|
|
) : (
|
|
<div className="grid gap-3">
|
|
{libraries.map((lib) => (
|
|
<div
|
|
key={lib.id}
|
|
className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4 sm:flex-row sm:items-center"
|
|
>
|
|
<div className="grid h-11 w-11 shrink-0 place-items-center rounded bg-accent-soft text-accent">
|
|
{lib.mediaType === 'podcast' ? <Headphones size={20} /> : <BookOpen size={20} />}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className="font-medium text-text">{lib.name}</p>
|
|
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-muted">
|
|
{lib.mediaType === 'podcast' ? 'Podcasts' : 'Hörbücher'}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-muted">
|
|
{lib.folders.map((f) => (
|
|
<span key={f.id} className="inline-flex items-center gap-1">
|
|
<Folder size={12} /> {f.fullPath}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 gap-1">
|
|
<Button
|
|
variant="subtle"
|
|
size="sm"
|
|
onClick={() => startScan(lib, false)}
|
|
disabled={scanning.has(lib.id)}
|
|
>
|
|
{scanning.has(lib.id) ? <Spinner size={14} /> : <ScanLine size={15} />} Scan
|
|
</Button>
|
|
<button
|
|
onClick={() => setModal({ open: true, library: lib })}
|
|
aria-label="Bearbeiten"
|
|
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-surface-2 hover:text-text"
|
|
>
|
|
<Pencil size={15} />
|
|
</button>
|
|
<button
|
|
onClick={() => setToDelete(lib)}
|
|
aria-label="Löschen"
|
|
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<LibraryModal
|
|
open={modal.open}
|
|
library={modal.library}
|
|
onClose={() => setModal({ open: false, library: null })}
|
|
onSaved={reload}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={!!toDelete}
|
|
title="Bibliothek löschen?"
|
|
danger
|
|
confirmLabel="Löschen"
|
|
message={
|
|
<>
|
|
<strong className="text-text">{toDelete?.name}</strong> und alle zugehörigen Items +
|
|
Fortschritte werden entfernt. Diese Aktion kann nicht rückgängig gemacht werden.
|
|
</>
|
|
}
|
|
onConfirm={confirmDelete}
|
|
onCancel={() => setToDelete(null)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|