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>
This commit is contained in:
153
src/pages/admin/AdminLibraries.tsx
Normal file
153
src/pages/admin/AdminLibraries.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user