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:
136
src/components/admin/LibraryModal.tsx
Normal file
136
src/components/admin/LibraryModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
src/components/admin/UserModal.tsx
Normal file
177
src/components/admin/UserModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user