Files
shelfless/src/pages/admin/AdminLibraries.tsx
Scarriffle 83d8b7b99d Initial commit: Shelfless – alternative Audiobookshelf frontend
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>
2026-06-02 20:23:04 +02:00

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)}
/>
</>
)
}