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:
Scarriffle
2026-06-02 20:23:04 +02:00
commit 83d8b7b99d
93 changed files with 9790 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
import { useState } from 'react'
import { Plus, X, Folder } from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { createLibrary, updateLibrary } from '@/api/libraries'
import { apiErrorMessage } from '@/api/client'
import { toast } from '@/store/toastStore'
import type { Library, MediaType } from '@/types/abs'
interface Props {
open: boolean
library: Library | null // null = create
onClose: () => void
onSaved: () => void
}
export function LibraryModal({ open, library, onClose, onSaved }: Props) {
const isEdit = !!library
const [name, setName] = useState(library?.name ?? '')
const [mediaType, setMediaType] = useState<MediaType>(library?.mediaType ?? 'book')
const [folders, setFolders] = useState<string[]>(library?.folders.map((f) => f.fullPath) ?? [])
const [folderInput, setFolderInput] = useState('')
const [saving, setSaving] = useState(false)
function addFolder() {
const p = folderInput.trim()
if (p && !folders.includes(p)) {
setFolders((f) => [...f, p])
setFolderInput('')
}
}
async function save() {
if (!name.trim()) {
toast.error('Name erforderlich.')
return
}
if (folders.length === 0) {
toast.error('Mindestens ein Ordner erforderlich.')
return
}
setSaving(true)
try {
const payload = { name: name.trim(), folders: folders.map((fullPath) => ({ fullPath })) }
if (isEdit && library) {
await updateLibrary(library.id, payload)
toast.success('Bibliothek aktualisiert.')
} else {
await createLibrary({ ...payload, mediaType })
toast.success('Bibliothek erstellt.')
}
onSaved()
onClose()
} catch (err) {
toast.error(apiErrorMessage(err, 'Speichern fehlgeschlagen.'))
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? 'Bibliothek bearbeiten' : 'Bibliothek erstellen'}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={saving}>
Abbrechen
</Button>
<Button onClick={save} loading={saving}>
Speichern
</Button>
</>
}
>
<div className="space-y-4">
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
{!isEdit && (
<Select
label="Typ"
value={mediaType}
onChange={(e) => setMediaType(e.target.value as MediaType)}
options={[
{ value: 'book', label: 'Hörbücher' },
{ value: 'podcast', label: 'Podcasts' },
]}
/>
)}
<div>
<p className="mb-1.5 text-sm font-medium text-text">Ordner</p>
<div className="flex gap-2">
<Input
placeholder="/audiobooks"
value={folderInput}
onChange={(e) => setFolderInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addFolder()
}
}}
/>
<Button variant="subtle" onClick={addFolder} disabled={!folderInput.trim()}>
<Plus size={16} /> Hinzufügen
</Button>
</div>
{folders.length > 0 && (
<ul className="mt-2 space-y-1">
{folders.map((f) => (
<li
key={f}
className="flex items-center gap-2 rounded border border-border bg-surface-2 px-3 py-2 text-sm"
>
<Folder size={15} className="shrink-0 text-text-muted" />
<span className="flex-1 truncate">{f}</span>
<button
onClick={() => setFolders((cur) => cur.filter((x) => x !== f))}
aria-label="Ordner entfernen"
className="text-text-muted hover:text-destructive"
>
<X size={15} />
</button>
</li>
))}
</ul>
)}
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,177 @@
import { useState } from 'react'
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Checkbox } from '@/components/ui/Checkbox'
import { createUser, updateUser } from '@/api/users'
import { apiErrorMessage } from '@/api/client'
import { toast } from '@/store/toastStore'
import type { AbsUser, Library, UserPermissions, UserType } from '@/types/abs'
interface Props {
open: boolean
user: AbsUser | null // null = create
libraries: Library[]
onClose: () => void
onSaved: () => void
}
const PERMISSION_FIELDS: { key: keyof UserPermissions; label: string }[] = [
{ key: 'download', label: 'Download' },
{ key: 'update', label: 'Bearbeiten' },
{ key: 'delete', label: 'Löschen' },
{ key: 'upload', label: 'Hochladen' },
{ key: 'accessExplicitContent', label: 'Explizite Inhalte' },
]
const defaultPerms: UserPermissions = {
download: true,
update: false,
delete: false,
upload: false,
accessAllLibraries: true,
accessAllTags: true,
accessExplicitContent: false,
}
export function UserModal({ open, user, libraries, onClose, onSaved }: Props) {
const isEdit = !!user
const isRoot = user?.type === 'root'
const [username, setUsername] = useState(user?.username ?? '')
const [password, setPassword] = useState('')
const [email, setEmail] = useState(user?.email ?? '')
const [type, setType] = useState<UserType>(user?.type ?? 'user')
const [isActive, setIsActive] = useState(user?.isActive ?? true)
const [perms, setPerms] = useState<UserPermissions>({ ...defaultPerms, ...user?.permissions })
const [libAccess, setLibAccess] = useState<string[]>(user?.librariesAccessible ?? [])
const [saving, setSaving] = useState(false)
function setPerm(key: keyof UserPermissions, value: boolean) {
setPerms((p) => ({ ...p, [key]: value }))
}
function toggleLib(id: string) {
setLibAccess((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]))
}
async function save() {
if (!username.trim()) {
toast.error('Benutzername erforderlich.')
return
}
if (!isEdit && !password) {
toast.error('Passwort erforderlich.')
return
}
setSaving(true)
try {
const payload = {
username: username.trim(),
email: email || null,
type,
isActive,
permissions: perms,
librariesAccessible: perms.accessAllLibraries ? [] : libAccess,
}
if (isEdit && user) {
await updateUser(user.id, { ...payload, ...(password ? { password } : {}) })
toast.success('Benutzer aktualisiert.')
} else {
await createUser({ ...payload, password })
toast.success('Benutzer erstellt.')
}
onSaved()
onClose()
} catch (err) {
toast.error(apiErrorMessage(err, 'Speichern fehlgeschlagen.'))
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? 'Benutzer bearbeiten' : 'Benutzer erstellen'}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={saving}>
Abbrechen
</Button>
<Button onClick={save} loading={saving}>
Speichern
</Button>
</>
}
>
<div className="space-y-4">
<Input label="Benutzername" value={username} onChange={(e) => setUsername(e.target.value)} disabled={isRoot} />
<Input
label={isEdit ? 'Neues Passwort (leer = unverändert)' : 'Passwort'}
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<div className="grid grid-cols-2 gap-4">
<Input label="E-Mail" type="email" value={email ?? ''} onChange={(e) => setEmail(e.target.value)} />
<Select
label="Typ"
value={type}
disabled={isRoot}
onChange={(e) => setType(e.target.value as UserType)}
options={
isRoot
? [{ value: 'root', label: 'Root' }]
: [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'Benutzer' },
{ value: 'guest', label: 'Gast' },
]
}
/>
</div>
<Checkbox label="Konto aktiv" checked={isActive} onChange={setIsActive} disabled={isRoot} />
<div>
<p className="mb-2 text-sm font-medium text-text">Berechtigungen</p>
<div className="grid grid-cols-2 gap-2">
{PERMISSION_FIELDS.map((f) => (
<Checkbox
key={f.key}
label={f.label}
checked={!!perms[f.key]}
onChange={(v) => setPerm(f.key, v)}
disabled={isRoot}
/>
))}
</div>
</div>
<div>
<Checkbox
label="Zugriff auf alle Bibliotheken"
checked={!!perms.accessAllLibraries}
onChange={(v) => setPerm('accessAllLibraries', v)}
disabled={isRoot}
/>
{!perms.accessAllLibraries && (
<div className="mt-2 grid grid-cols-2 gap-2 rounded border border-border p-3">
{libraries.map((lib) => (
<Checkbox
key={lib.id}
label={lib.name}
checked={libAccess.includes(lib.id)}
onChange={() => toggleLib(lib.id)}
/>
))}
</div>
)}
</div>
</div>
</Modal>
)
}