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,82 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
export interface MenuAction {
label: string
icon?: React.ReactNode
onSelect: () => void
danger?: boolean
hidden?: boolean
}
interface Props {
x: number
y: number
actions: MenuAction[]
onClose: () => void
}
/** Cursor-positioned action menu (right-click / long-press), clamped to the viewport. */
export function ContextMenu({ x, y, actions, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ x, y })
useEffect(() => {
function onDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
window.addEventListener('scroll', onClose, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onClose, true)
}
}, [onClose])
useEffect(() => {
const el = ref.current
if (!el) return
const { width, height } = el.getBoundingClientRect()
setPos({
x: Math.min(x, window.innerWidth - width - 8),
y: Math.min(y, window.innerHeight - height - 8),
})
}, [x, y])
const visible = actions.filter((a) => !a.hidden)
return createPortal(
<div
ref={ref}
style={{ top: pos.y, left: pos.x }}
className="fixed z-50 min-w-[190px] overflow-hidden rounded-lg border border-border bg-surface py-1 shadow-lift animate-fade-in"
role="menu"
>
{visible.map((a, i) => (
<button
key={i}
role="menuitem"
onClick={() => {
a.onSelect()
onClose()
}}
className={
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors ' +
(a.danger
? 'text-destructive hover:bg-destructive/10'
: 'text-text hover:bg-surface-2')
}
>
{a.icon && <span className="shrink-0 text-text-muted">{a.icon}</span>}
{a.label}
</button>
))}
</div>,
document.body,
)
}

View File

@@ -0,0 +1,96 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { BookOpen, Headphones, Check, Pencil } from 'lucide-react'
import { coverUrl, getAuthor, getTitle } from '@/lib/media'
import { useProgressStore } from '@/store/progressStore'
import { useCan } from '@/store/authStore'
import type { LibraryItem } from '@/types/abs'
interface Props {
item: LibraryItem
onContextMenu?: (e: React.MouseEvent, item: LibraryItem) => void
}
export function MediaCard({ item, onContextMenu }: Props) {
const [imgError, setImgError] = useState(false)
const prog = useProgressStore((s) => s.byKey[item.id])
const canEdit = useCan('update')
const navigate = useNavigate()
const isPodcast = item.mediaType === 'podcast'
const pct = prog && !prog.isFinished ? Math.round(prog.progress * 100) : 0
const finished = prog?.isFinished ?? false
const title = getTitle(item)
const author = getAuthor(item)
function editMetadata(e: React.MouseEvent) {
e.preventDefault()
e.stopPropagation()
navigate(`/item/${item.id}?tab=metadata`)
}
return (
<Link
to={`/item/${item.id}`}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, item) : undefined}
className="group block focus:outline-none"
>
<div className="relative aspect-square overflow-hidden rounded-lg border border-border bg-surface-2 shadow-card transition-transform duration-200 ease-out group-hover:-translate-y-1 group-hover:scale-[1.03] group-hover:shadow-lift group-focus-visible:ring-2 group-focus-visible:ring-accent">
{!imgError ? (
<img
src={coverUrl(item.id)}
alt=""
loading="lazy"
onError={() => setImgError(true)}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-3 text-center text-text-muted">
{isPodcast ? <Headphones size={28} /> : <BookOpen size={28} />}
<span className="line-clamp-3 text-xs">{title}</span>
</div>
)}
{/* Quick-edit (visible on hover) → straight to the metadata tab */}
{canEdit && (
<button
onClick={editMetadata}
aria-label="Metadaten bearbeiten"
className="absolute left-2 top-2 grid h-8 w-8 place-items-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur transition-opacity hover:bg-black/75 focus:opacity-100 group-hover:opacity-100"
>
<Pencil size={15} />
</button>
)}
{finished && (
<div className="absolute right-2 top-2 grid h-7 w-7 place-items-center rounded-full bg-success text-black shadow">
<Check size={16} strokeWidth={3} />
</div>
)}
{pct > 0 && (
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
<div className="mt-2 px-0.5">
<p className="line-clamp-2 text-sm font-medium leading-snug text-text">{title}</p>
{author && <p className="mt-0.5 line-clamp-1 text-xs text-text-muted">{author}</p>}
</div>
</Link>
)
}
export function MediaCardSkeleton() {
return (
<div>
<div className="skeleton aspect-square rounded-lg" />
<div className="mt-2 space-y-1.5">
<div className="skeleton h-3.5 w-4/5 rounded" />
<div className="skeleton h-3 w-2/5 rounded" />
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { MediaCard, MediaCardSkeleton } from './MediaCard'
import type { LibraryItem } from '@/types/abs'
interface Props {
items: LibraryItem[]
onContextMenu?: (e: React.MouseEvent, item: LibraryItem) => void
}
const GRID_STYLE: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '1.25rem',
}
/** Responsive auto-fill grid of media cards (Library view). */
export function MediaGrid({ items, onContextMenu }: Props) {
return (
<div style={GRID_STYLE}>
{items.map((item) => (
<MediaCard key={item.id} item={item} onContextMenu={onContextMenu} />
))}
</div>
)
}
export function MediaGridSkeleton({ count = 18 }: { count?: number }) {
return (
<div style={GRID_STYLE}>
{Array.from({ length: count }).map((_, i) => (
<MediaCardSkeleton key={i} />
))}
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { BookOpen, Headphones, Check } from 'lucide-react'
import { coverUrl, getAuthor, getTitle, getDuration } from '@/lib/media'
import { formatDuration } from '@/lib/format'
import { useProgressStore } from '@/store/progressStore'
import type { LibraryItem } from '@/types/abs'
interface Props {
item: LibraryItem
onContextMenu?: (e: React.MouseEvent, item: LibraryItem) => void
}
export function MediaListRow({ item, onContextMenu }: Props) {
const [imgError, setImgError] = useState(false)
const prog = useProgressStore((s) => s.byKey[item.id])
const isPodcast = item.mediaType === 'podcast'
const pct = prog && !prog.isFinished ? Math.round(prog.progress * 100) : 0
const finished = prog?.isFinished ?? false
const duration = getDuration(item)
return (
<Link
to={`/item/${item.id}`}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, item) : undefined}
className="group flex items-center gap-3 bg-surface px-3 py-2 transition-colors hover:bg-surface-2 focus:outline-none focus-visible:bg-surface-2"
>
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded bg-surface-2">
{!imgError ? (
<img
src={coverUrl(item.id)}
alt=""
loading="lazy"
onError={() => setImgError(true)}
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-text-muted">
{isPodcast ? <Headphones size={18} /> : <BookOpen size={18} />}
</div>
)}
{pct > 0 && (
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-text">{getTitle(item)}</p>
<p className="truncate text-xs text-text-muted">{getAuthor(item)}</p>
</div>
{finished && (
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-success text-black">
<Check size={14} strokeWidth={3} />
</span>
)}
{!isPodcast && duration > 0 && (
<span className="tnum hidden shrink-0 text-xs text-text-muted sm:block">
{formatDuration(duration)}
</span>
)}
</Link>
)
}

View File

@@ -0,0 +1,51 @@
import { Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import { MediaCard, MediaCardSkeleton } from './MediaCard'
import type { LibraryItem } from '@/types/abs'
interface Props {
title: string
items: LibraryItem[]
loading?: boolean
to?: string
emptyNote?: string
}
/** Horizontally scrollable row of media cards (Home page). */
export function MediaRow({ title, items, loading, to, emptyNote }: Props) {
if (!loading && items.length === 0 && !emptyNote) return null
return (
<section className="mb-9">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 className="font-heading text-xl font-semibold tracking-tight">{title}</h2>
{to && (
<Link
to={to}
className="flex items-center gap-0.5 text-sm text-text-muted transition-colors hover:text-text"
>
Alle <ChevronRight size={15} />
</Link>
)}
</div>
{!loading && items.length === 0 ? (
<p className="text-sm text-text-muted">{emptyNote}</p>
) : (
<div className="-mx-1 flex snap-x gap-4 overflow-x-auto px-1 pb-2 [scrollbar-width:thin]">
{loading
? Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="w-[140px] shrink-0 snap-start sm:w-[160px]">
<MediaCardSkeleton />
</div>
))
: items.map((item) => (
<div key={item.id} className="w-[140px] shrink-0 snap-start sm:w-[160px]">
<MediaCard item={item} />
</div>
))}
</div>
)}
</section>
)
}

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

View File

@@ -0,0 +1,39 @@
import { Play } from 'lucide-react'
import { formatTime } from '@/lib/format'
import { cn } from '@/lib/cn'
import type { Chapter } from '@/types/abs'
interface Props {
chapters: Chapter[]
activeStart?: number
onJump: (start: number) => void
}
export function ChapterList({ chapters, activeStart, onJump }: Props) {
if (!chapters.length) return null
return (
<div className="overflow-hidden rounded-lg border border-border">
{chapters.map((c) => {
const active = activeStart != null && activeStart === c.start
return (
<button
key={c.id}
onClick={() => onJump(c.start)}
className={cn(
'group flex w-full items-center gap-3 border-b border-border px-3 py-2.5 text-left text-sm last:border-b-0 transition-colors hover:bg-surface-2',
active && 'bg-accent-soft',
)}
>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-surface-2 text-text-muted group-hover:bg-accent group-hover:text-on-accent">
<Play size={13} />
</span>
<span className={cn('flex-1 truncate', active ? 'text-text' : 'text-text')}>
{c.title}
</span>
<span className="tnum shrink-0 text-xs text-text-muted">{formatTime(c.start)}</span>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,203 @@
import { useRef, useState } from 'react'
import { Upload, Link2, Search, Trash2, ImageOff } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Spinner } from '@/components/ui/Spinner'
import { cn } from '@/lib/cn'
import { coverUrl, getAuthor, getTitle } from '@/lib/media'
import { isSafeHttpUrl } from '@/lib/url'
import { uploadCover, setCoverByUrl, removeCover } from '@/api/items'
import { searchCovers } from '@/api/metadata'
import { apiErrorMessage } from '@/api/client'
import { toast } from '@/store/toastStore'
import { useCan } from '@/store/authStore'
import type { LibraryItem } from '@/types/abs'
export function CoverTab({ item }: { item: LibraryItem }) {
const canEdit = useCan('upload')
const [ts, setTs] = useState(() => Date.now())
const [busy, setBusy] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [urlValue, setUrlValue] = useState('')
const [results, setResults] = useState<string[] | null>(null)
const [searching, setSearching] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
function refresh() {
setTs(Date.now())
}
async function doUpload(file: File) {
if (!file.type.startsWith('image/')) {
toast.error('Bitte eine Bilddatei wählen.')
return
}
setBusy(true)
try {
await uploadCover(item.id, file)
refresh()
toast.success('Cover hochgeladen.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Upload fehlgeschlagen.'))
} finally {
setBusy(false)
}
}
async function applyUrl(url: string) {
if (!isSafeHttpUrl(url)) {
toast.error('Ungültige Bild-URL (nur http/https).')
return
}
setBusy(true)
try {
await setCoverByUrl(item.id, url)
refresh()
toast.success('Cover gesetzt.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Cover konnte nicht gesetzt werden.'))
} finally {
setBusy(false)
}
}
async function doRemove() {
setBusy(true)
try {
await removeCover(item.id)
refresh()
toast.success('Cover entfernt.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Entfernen fehlgeschlagen.'))
} finally {
setBusy(false)
}
}
async function runSearch() {
setSearching(true)
try {
const res = await searchCovers({ title: getTitle(item), author: getAuthor(item) })
setResults(res)
if (res.length === 0) toast.info('Keine Cover gefunden.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Cover-Suche fehlgeschlagen.'))
} finally {
setSearching(false)
}
}
return (
<div className="grid gap-8 md:grid-cols-[240px_1fr]">
{/* Current cover + dropzone */}
<div>
<div
onDragOver={(e) => {
if (!canEdit) return
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
if (!canEdit) return
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file) doUpload(file)
}}
className={cn(
'relative aspect-square overflow-hidden rounded-lg border bg-surface-2',
dragOver ? 'border-accent ring-2 ring-accent' : 'border-border',
)}
>
<img
src={coverUrl(item.id, { ts })}
alt=""
className="h-full w-full object-cover"
onError={(e) => (e.currentTarget.style.visibility = 'hidden')}
/>
{busy && (
<div className="absolute inset-0 grid place-items-center bg-black/50">
<Spinner size={26} />
</div>
)}
{canEdit && dragOver && (
<div className="absolute inset-0 grid place-items-center bg-black/60 text-sm text-text">
Datei ablegen
</div>
)}
</div>
</div>
{/* Controls */}
{canEdit ? (
<div className="space-y-6">
<div>
<h3 className="mb-2 text-sm font-semibold text-text">Hochladen</h3>
<input
ref={fileRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const f = e.target.files?.[0]
if (f) doUpload(f)
e.target.value = ''
}}
/>
<div className="flex flex-wrap gap-2">
<Button variant="subtle" onClick={() => fileRef.current?.click()} disabled={busy}>
<Upload size={16} /> Datei wählen
</Button>
<Button variant="ghost" onClick={doRemove} disabled={busy}>
<Trash2 size={16} /> Cover entfernen
</Button>
</div>
<p className="mt-1.5 text-xs text-text-muted">Oder eine Datei auf das Cover ziehen.</p>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-text">Per URL</h3>
<div className="flex gap-2">
<Input
placeholder="https://…/cover.jpg"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
/>
<Button variant="subtle" onClick={() => applyUrl(urlValue)} disabled={busy || !urlValue}>
<Link2 size={16} /> Setzen
</Button>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-text">Automatische Suche</h3>
<Button variant="ghost" size="sm" onClick={runSearch} loading={searching}>
<Search size={15} /> Suchen
</Button>
</div>
{results && results.length > 0 && (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
{results.map((url) => (
<button
key={url}
onClick={() => applyUrl(url)}
disabled={busy}
className="group overflow-hidden rounded border border-border transition-colors hover:border-accent"
>
<img src={url} alt="" loading="lazy" className="aspect-square w-full object-cover" />
</button>
))}
</div>
)}
</div>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-text-muted">
<ImageOff size={18} /> Keine Berechtigung zum Ändern des Covers.
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { Play, Check } from 'lucide-react'
import { formatDuration } from '@/lib/format'
import { cn } from '@/lib/cn'
import { useProgressStore, progressKey } from '@/store/progressStore'
import { usePlayerStore } from '@/store/playerStore'
import { htmlToText } from '@/lib/html'
import type { LibraryItem, Podcast, PodcastEpisode } from '@/types/abs'
interface Props {
item: LibraryItem
}
function pubLabel(ep: PodcastEpisode): string {
if (ep.publishedAt) {
try {
return new Date(ep.publishedAt).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
/* ignore */
}
}
return ep.pubDate ?? ''
}
export function EpisodeList({ item }: Props) {
const episodes = (item.media as Podcast).episodes ?? []
const byKey = useProgressStore((s) => s.byKey)
const play = usePlayerStore((s) => s.play)
const currentEpisodeId = usePlayerStore((s) => s.episodeId)
if (!episodes.length) {
return <p className="text-sm text-text-muted">Keine Episoden vorhanden.</p>
}
// newest first
const sorted = [...episodes].sort((a, b) => (b.publishedAt ?? 0) - (a.publishedAt ?? 0))
return (
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{sorted.map((ep) => {
const prog = byKey[progressKey(item.id, ep.id)]
const pct = prog && !prog.isFinished ? Math.round(prog.progress * 100) : 0
const finished = prog?.isFinished ?? false
const isCurrent = currentEpisodeId === ep.id
const duration = ep.duration ?? ep.audioFile?.duration ?? 0
const startTime = prog && !prog.isFinished ? prog.currentTime : 0
return (
<div
key={ep.id}
className={cn('flex items-start gap-3 bg-surface px-3 py-3', isCurrent && 'bg-surface-2')}
>
<button
onClick={() => play(item, ep.id, startTime)}
aria-label="Episode abspielen"
className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-full bg-surface-2 text-text transition-colors hover:bg-accent hover:text-on-accent"
>
<Play size={16} />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="flex-1 truncate text-sm font-medium text-text">{ep.title}</p>
{finished && <Check size={15} className="shrink-0 text-success" />}
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-text-muted">
{pubLabel(ep) && <span>{pubLabel(ep)}</span>}
{duration > 0 && (
<>
<span>·</span>
<span className="tnum">{formatDuration(duration)}</span>
</>
)}
{pct > 0 && (
<>
<span>·</span>
<span className="tnum text-accent">{pct}%</span>
</>
)}
</div>
{ep.description && (
<p className="mt-1.5 line-clamp-2 text-xs text-text-muted">
{htmlToText(ep.description)}
</p>
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { FileAudio } from 'lucide-react'
import { formatBytes, formatDuration } from '@/lib/format'
import type { AudioFile, Book, LibraryItem, Podcast } from '@/types/abs'
function collectAudioFiles(item: LibraryItem): AudioFile[] {
if (item.mediaType === 'book') return (item.media as Book).audioFiles ?? []
const eps = (item.media as Podcast).episodes ?? []
return eps.map((e) => e.audioFile).filter((f): f is AudioFile => !!f)
}
export function FilesTab({ item }: { item: LibraryItem }) {
const files = collectAudioFiles(item)
if (files.length === 0) {
return <p className="text-sm text-text-muted">Keine Audiodateien vorhanden.</p>
}
return (
<div className="overflow-hidden rounded-lg border border-border">
{files.map((f, i) => (
<div
key={`${f.ino}-${i}`}
className="flex items-center gap-3 border-b border-border px-3 py-2.5 last:border-b-0"
>
<FileAudio size={18} className="shrink-0 text-text-muted" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-text">{f.metadata.filename}</p>
<p className="text-xs text-text-muted">
{f.codec?.toUpperCase()} {f.bitRate ? `· ${Math.round(f.bitRate / 1000)} kbps` : ''}
</p>
</div>
<span className="tnum shrink-0 text-xs text-text-muted">{formatDuration(f.duration)}</span>
<span className="tnum hidden shrink-0 text-xs text-text-muted sm:block">
{formatBytes(f.metadata.size)}
</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,202 @@
import { useState } from 'react'
import { Save } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { cn } from '@/lib/cn'
import { updateMedia } from '@/api/items'
import { apiErrorMessage } from '@/api/client'
import { toast } from '@/store/toastStore'
import { useCan } from '@/store/authStore'
import { htmlToText } from '@/lib/html'
import type { Book, BookMetadata, LibraryItem, Podcast, PodcastMetadata } from '@/types/abs'
interface Props {
item: LibraryItem
onUpdated: (item: LibraryItem) => void
}
function splitList(value: string): string[] {
return value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string }) {
const { label, className, ...rest } = props
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-text">{label}</span>
<textarea
className={cn(
'min-h-[120px] w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text placeholder:text-text-muted/60 focus:border-accent focus:outline-none',
className,
)}
{...rest}
/>
</label>
)
}
export function MetadataTab({ item, onUpdated }: Props) {
const canEdit = useCan('update')
if (item.mediaType === 'podcast') {
return <PodcastForm item={item} onUpdated={onUpdated} canEdit={canEdit} />
}
return <BookForm item={item} onUpdated={onUpdated} canEdit={canEdit} />
}
function BookForm({ item, onUpdated, canEdit }: Props & { canEdit: boolean }) {
const m = (item.media as Book).metadata
const [form, setForm] = useState({
title: m.title ?? '',
subtitle: m.subtitle ?? '',
authors: m.authorName ?? (m.authors?.map((a) => a.name).join(', ') ?? ''),
narrators: m.narratorName ?? (m.narrators?.join(', ') ?? ''),
seriesName: m.series?.[0]?.name ?? m.seriesName ?? '',
seriesSeq: m.series?.[0]?.sequence ?? '',
genres: m.genres?.join(', ') ?? '',
publishedYear: m.publishedYear ?? '',
publisher: m.publisher ?? '',
isbn: m.isbn ?? '',
asin: m.asin ?? '',
language: m.language ?? '',
description: htmlToText(m.description),
})
const [saving, setSaving] = useState(false)
function set<K extends keyof typeof form>(k: K, v: string) {
setForm((f) => ({ ...f, [k]: v }))
}
async function save() {
setSaving(true)
try {
const metadata: Partial<BookMetadata> = {
title: form.title,
subtitle: form.subtitle || null,
authors: splitList(form.authors).map((name) => ({ name })),
narrators: splitList(form.narrators),
series: form.seriesName
? [{ name: form.seriesName, sequence: form.seriesSeq || null }]
: [],
genres: splitList(form.genres),
publishedYear: form.publishedYear || null,
publisher: form.publisher || null,
isbn: form.isbn || null,
asin: form.asin || null,
language: form.language || null,
description: form.description || null,
}
const updated = await updateMedia(item.id, { metadata })
onUpdated(updated)
toast.success('Metadaten gespeichert.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Speichern fehlgeschlagen.'))
} finally {
setSaving(false)
}
}
if (!canEdit) return <ReadOnlyMeta item={item} />
return (
<div className="max-w-2xl space-y-4">
<Input label="Titel" value={form.title} onChange={(e) => set('title', e.target.value)} />
<Input label="Untertitel" value={form.subtitle} onChange={(e) => set('subtitle', e.target.value)} />
<Input label="Autor(en)" hint="Komma-getrennt" value={form.authors} onChange={(e) => set('authors', e.target.value)} />
<Input label="Sprecher" hint="Komma-getrennt" value={form.narrators} onChange={(e) => set('narrators', e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="Reihe" value={form.seriesName} onChange={(e) => set('seriesName', e.target.value)} />
<Input label="Reihennummer" value={form.seriesSeq} onChange={(e) => set('seriesSeq', e.target.value)} />
</div>
<Input label="Genres" hint="Komma-getrennt" value={form.genres} onChange={(e) => set('genres', e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="Jahr" value={form.publishedYear} onChange={(e) => set('publishedYear', e.target.value)} />
<Input label="Verlag" value={form.publisher} onChange={(e) => set('publisher', e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-4">
<Input label="ISBN" value={form.isbn} onChange={(e) => set('isbn', e.target.value)} />
<Input label="ASIN" value={form.asin} onChange={(e) => set('asin', e.target.value)} />
<Input label="Sprache" value={form.language} onChange={(e) => set('language', e.target.value)} />
</div>
<Textarea label="Beschreibung" value={form.description} onChange={(e) => set('description', e.target.value)} />
<Button onClick={save} loading={saving}>
<Save size={16} /> Speichern
</Button>
</div>
)
}
function PodcastForm({ item, onUpdated, canEdit }: Props & { canEdit: boolean }) {
const m = (item.media as Podcast).metadata
const [form, setForm] = useState({
title: m.title ?? '',
author: m.author ?? '',
genres: m.genres?.join(', ') ?? '',
language: m.language ?? '',
feedUrl: m.feedUrl ?? '',
description: htmlToText(m.description),
})
const [saving, setSaving] = useState(false)
function set<K extends keyof typeof form>(k: K, v: string) {
setForm((f) => ({ ...f, [k]: v }))
}
async function save() {
setSaving(true)
try {
const metadata: Partial<PodcastMetadata> = {
title: form.title,
author: form.author || null,
genres: splitList(form.genres),
language: form.language || null,
description: form.description || null,
}
const updated = await updateMedia(item.id, { metadata })
onUpdated(updated)
toast.success('Metadaten gespeichert.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Speichern fehlgeschlagen.'))
} finally {
setSaving(false)
}
}
if (!canEdit) return <ReadOnlyMeta item={item} />
return (
<div className="max-w-2xl space-y-4">
<Input label="Titel" value={form.title} onChange={(e) => set('title', e.target.value)} />
<Input label="Autor" value={form.author} onChange={(e) => set('author', e.target.value)} />
<Input label="Genres" hint="Komma-getrennt" value={form.genres} onChange={(e) => set('genres', e.target.value)} />
<Input label="Sprache" value={form.language} onChange={(e) => set('language', e.target.value)} />
<Input label="RSS-Feed" value={form.feedUrl} readOnly disabled />
<Textarea label="Beschreibung" value={form.description} onChange={(e) => set('description', e.target.value)} />
<Button onClick={save} loading={saving}>
<Save size={16} /> Speichern
</Button>
</div>
)
}
function ReadOnlyMeta({ item }: { item: LibraryItem }) {
const m = item.media.metadata
const rows: [string, string][] = [
['Titel', m.title ?? '—'],
['Beschreibung', htmlToText(m.description) || '—'],
]
return (
<div className="max-w-2xl space-y-3">
<p className="text-sm text-text-muted">Keine Berechtigung zum Bearbeiten.</p>
{rows.map(([k, v]) => (
<div key={k}>
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">{k}</p>
<p className="text-sm text-text">{v}</p>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { useEffect } from 'react'
import { Outlet } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { BottomNav } from './BottomNav'
import { TopBar } from './TopBar'
import { PlayerBar } from '@/components/player/PlayerBar'
import { useLibraryStore } from '@/store/libraryStore'
import { useProgressStore } from '@/store/progressStore'
import { usePlayerStore } from '@/store/playerStore'
import { getLibraries } from '@/api/libraries'
import { apiErrorMessage } from '@/api/client'
import { cn } from '@/lib/cn'
/**
* App shell: collapsible sidebar (desktop) + content area + a reserved slot at the
* bottom for the persistent player (added in Phase 6) and the mobile bottom-nav.
* Loads the library list once after auth so the navigation can populate.
*/
export function AppLayout() {
const { loaded, loading, setLibraries, setLoading, setError } = useLibraryStore()
const loadProgress = useProgressStore((s) => s.load)
const progressLoaded = useProgressStore((s) => s.loaded)
const playerActive = usePlayerStore((s) => !!s.item)
useEffect(() => {
if (loaded || loading) return
setLoading(true)
getLibraries()
.then(setLibraries)
.catch((err) => setError(apiErrorMessage(err, 'Bibliotheken konnten nicht geladen werden.')))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!progressLoaded) loadProgress().catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="min-h-dvh bg-bg text-text">
<div className="flex">
<Sidebar />
<div className="flex min-h-dvh flex-1 flex-col">
<TopBar />
<main
className={cn(
'flex-1 px-5 pt-4 md:px-8',
playerActive ? 'pb-44 md:pb-28' : 'pb-20 md:pb-8',
)}
>
<div className="mx-auto w-full max-w-[1400px]">
<Outlet />
</div>
</main>
</div>
</div>
<PlayerBar />
<BottomNav />
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { NavLink } from 'react-router-dom'
import { Home, Library as LibraryIcon, Settings, Shield } from 'lucide-react'
import { cn } from '@/lib/cn'
import { useLibraryStore } from '@/store/libraryStore'
import { useIsAdmin } from '@/store/authStore'
interface Tab {
to: string
label: string
icon: React.ReactNode
end?: boolean
}
/** Mobile bottom navigation (≤5 items, icon + label). Replaces the sidebar < md. */
export function BottomNav() {
const firstLibrary = useLibraryStore((s) => s.libraries[0])
const isAdmin = useIsAdmin()
const tabs: Tab[] = [
{ to: '/', label: 'Home', icon: <Home size={20} />, end: true },
{
to: firstLibrary ? `/library/${firstLibrary.id}` : '/',
label: 'Medien',
icon: <LibraryIcon size={20} />,
},
{ to: '/settings', label: 'Optionen', icon: <Settings size={20} /> },
]
if (isAdmin) tabs.push({ to: '/admin/users', label: 'Admin', icon: <Shield size={20} /> })
return (
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-16 border-t border-border bg-surface/95 backdrop-blur md:hidden">
{tabs.map((tab) => (
<NavLink
key={tab.label}
to={tab.to}
end={tab.end}
className={({ isActive }) =>
cn(
'flex flex-1 flex-col items-center justify-center gap-1 text-[11px] transition-colors',
isActive ? 'text-accent' : 'text-text-muted',
)
}
>
{tab.icon}
<span>{tab.label}</span>
</NavLink>
))}
</nav>
)
}

View File

@@ -0,0 +1,129 @@
import { NavLink } from 'react-router-dom'
import {
Home,
Settings,
PanelLeftClose,
PanelLeftOpen,
Headphones,
BookOpen,
Users,
FolderCog,
ScanLine,
ServerCog,
} from 'lucide-react'
import { cn } from '@/lib/cn'
import { useSettingsStore } from '@/store/settingsStore'
import { useLibraryStore } from '@/store/libraryStore'
import { useIsAdmin } from '@/store/authStore'
interface ItemProps {
to: string
label: string
icon: React.ReactNode
collapsed: boolean
end?: boolean
}
function NavItem({ to, label, icon, collapsed, end }: ItemProps) {
return (
<NavLink
to={to}
end={end}
title={collapsed ? label : undefined}
className={({ isActive }) =>
cn(
'group relative flex items-center gap-3 rounded px-3 py-2 text-sm transition-colors',
'hover:bg-surface-2',
isActive ? 'bg-surface-2 font-medium text-text' : 'text-text-muted',
)
}
>
{({ isActive }) => (
<>
<span
className={cn(
'absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r bg-accent transition-opacity',
isActive ? 'opacity-100' : 'opacity-0',
)}
/>
<span className="shrink-0 text-current">{icon}</span>
{!collapsed && <span className="truncate">{label}</span>}
</>
)}
</NavLink>
)
}
function SectionLabel({ children, collapsed }: { children: string; collapsed: boolean }) {
if (collapsed) return <div className="my-2 border-t border-border" />
return (
<p className="px-3 pb-1 pt-4 text-[11px] font-semibold uppercase tracking-wider text-text-muted/70">
{children}
</p>
)
}
export function Sidebar() {
const collapsed = useSettingsStore((s) => s.sidebarCollapsed)
const toggle = useSettingsStore((s) => s.toggleSidebar)
const libraries = useLibraryStore((s) => s.libraries)
const isAdmin = useIsAdmin()
return (
<aside
className={cn(
'sticky top-0 hidden h-dvh shrink-0 flex-col border-r border-border bg-surface md:flex',
collapsed ? 'w-[68px]' : 'w-60',
)}
>
<div className="flex h-14 items-center gap-2 px-4">
<div className="grid h-8 w-8 shrink-0 place-items-center rounded bg-accent-soft text-accent">
<Headphones size={18} />
</div>
{!collapsed && (
<span className="font-heading text-lg font-semibold tracking-tight">Shelfless</span>
)}
</div>
<nav className="flex-1 overflow-y-auto px-2 py-2">
<NavItem to="/" end label="Home" icon={<Home size={18} />} collapsed={collapsed} />
<SectionLabel collapsed={collapsed}>Bibliotheken</SectionLabel>
{libraries.length === 0 && !collapsed && (
<p className="px-3 py-1 text-xs text-text-muted/60">Noch keine geladen</p>
)}
{libraries.map((lib) => (
<NavItem
key={lib.id}
to={`/library/${lib.id}`}
label={lib.name}
icon={lib.mediaType === 'podcast' ? <Headphones size={18} /> : <BookOpen size={18} />}
collapsed={collapsed}
/>
))}
{isAdmin && (
<>
<SectionLabel collapsed={collapsed}>Admin</SectionLabel>
<NavItem to="/admin/users" label="Benutzer" icon={<Users size={18} />} collapsed={collapsed} />
<NavItem to="/admin/libraries" label="Bibliotheken" icon={<FolderCog size={18} />} collapsed={collapsed} />
<NavItem to="/admin/scan" label="Scanner" icon={<ScanLine size={18} />} collapsed={collapsed} />
<NavItem to="/admin/settings" label="Server" icon={<ServerCog size={18} />} collapsed={collapsed} />
</>
)}
</nav>
<div className="border-t border-border px-2 py-2">
<NavItem to="/settings" label="Einstellungen" icon={<Settings size={18} />} collapsed={collapsed} />
<button
onClick={toggle}
className="mt-1 flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-text-muted transition-colors hover:bg-surface-2"
aria-label={collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
>
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
{!collapsed && <span>Einklappen</span>}
</button>
</div>
</aside>
)
}

View File

@@ -0,0 +1,39 @@
import { useNavigate } from 'react-router-dom'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { useLibraryStore } from '@/store/libraryStore'
/**
* Top bar with a global search launcher: submits into the first library's scoped search
* (the Library view reads `?q=` and runs live search there).
*/
export function TopBar() {
const [q, setQ] = useState('')
const navigate = useNavigate()
const firstLibrary = useLibraryStore((s) => s.libraries[0])
function onSubmit(e: React.FormEvent) {
e.preventDefault()
const term = q.trim()
if (term && firstLibrary) navigate(`/library/${firstLibrary.id}?q=${encodeURIComponent(term)}`)
}
return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-bg/85 px-5 backdrop-blur md:px-8">
<form onSubmit={onSubmit} className="relative w-full max-w-md">
<Search
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
/>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
type="search"
placeholder="Suchen …"
aria-label="Suchen"
className="h-9 w-full rounded border border-border bg-surface pl-9 pr-3 text-sm text-text placeholder:text-text-muted/70 focus:border-accent focus:outline-none"
/>
</form>
</header>
)
}

View File

@@ -0,0 +1,248 @@
import { useRef } from 'react'
import { Link } from 'react-router-dom'
import {
Play,
Pause,
RotateCcw,
RotateCw,
X,
ListTree,
Gauge,
Volume2,
VolumeX,
BookOpen,
Headphones,
} from 'lucide-react'
import { cn } from '@/lib/cn'
import { formatTime } from '@/lib/format'
import { coverUrl } from '@/lib/media'
import { usePlayerStore, currentChapter } from '@/store/playerStore'
import { PLAYBACK_SPEEDS } from '@/store/settingsStore'
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'
import { Spinner } from '@/components/ui/Spinner'
import { useAudioEngine } from './useAudioEngine'
export function PlayerBar() {
const audioRef = useRef<HTMLAudioElement>(null)
useAudioEngine(audioRef)
const item = usePlayerStore((s) => s.item)
const title = usePlayerStore((s) => s.title)
const author = usePlayerStore((s) => s.author)
const coverItemId = usePlayerStore((s) => s.coverItemId)
const isPlaying = usePlayerStore((s) => s.isPlaying)
const loading = usePlayerStore((s) => s.loading)
const position = usePlayerStore((s) => s.position)
const duration = usePlayerStore((s) => s.duration)
const speed = usePlayerStore((s) => s.speed)
const volume = usePlayerStore((s) => s.volume)
const chapters = usePlayerStore((s) => s.chapters)
const toggle = usePlayerStore((s) => s.togglePlay)
const skip = usePlayerStore((s) => s.skip)
const seek = usePlayerStore((s) => s.seek)
const setSpeed = usePlayerStore((s) => s.setSpeed)
const setVolume = usePlayerStore((s) => s.setVolume)
const stop = usePlayerStore((s) => s.stop)
const chapter = chapters.length ? currentChapter(chapters, position) : null
return (
<div
className={cn(
'fixed inset-x-0 bottom-16 z-40 border-t border-border bg-surface/95 backdrop-blur md:bottom-0',
'animate-slide-up shadow-bar',
!item && 'hidden',
)}
>
<audio ref={audioRef} preload="metadata" />
<div className="mx-auto flex max-w-[1600px] items-center gap-3 px-3 py-2.5 md:px-5">
{/* Now playing */}
<Link
to={item ? `/item/${item.id}` : '#'}
className="flex min-w-0 flex-1 items-center gap-3 md:w-72 md:flex-none"
>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-surface-2">
{coverItemId && (
<img
src={coverUrl(coverItemId)}
alt=""
className="h-full w-full object-cover"
onError={(e) => (e.currentTarget.style.visibility = 'hidden')}
/>
)}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-text">{title}</p>
<p className="truncate text-xs text-text-muted">{author}</p>
</div>
</Link>
{/* Center controls */}
<div className="flex flex-1 flex-col items-center gap-1.5">
<div className="flex items-center gap-2">
<button
onClick={() => skip(-30)}
aria-label="30 Sekunden zurück"
className="grid h-9 w-9 place-items-center rounded-full text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
>
<RotateCcw size={18} />
</button>
<button
onClick={toggle}
aria-label={isPlaying ? 'Pause' : 'Abspielen'}
className="grid h-11 w-11 place-items-center rounded-full bg-accent text-on-accent transition-transform hover:scale-105"
>
{loading ? <Spinner size={18} /> : isPlaying ? <Pause size={20} /> : <Play size={20} />}
</button>
<button
onClick={() => skip(30)}
aria-label="30 Sekunden vor"
className="grid h-9 w-9 place-items-center rounded-full text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
>
<RotateCw size={18} />
</button>
</div>
{/* Progress */}
<div className="hidden w-full max-w-2xl items-center gap-2 md:flex">
<span className="tnum w-12 text-right text-xs text-text-muted">{formatTime(position)}</span>
<input
type="range"
min={0}
max={Math.max(duration, 1)}
step={1}
value={Math.min(position, duration || position)}
onChange={(e) => seek(Number(e.target.value))}
aria-label="Position"
className="h-1 flex-1 cursor-pointer accent-[var(--accent)]"
/>
<span className="tnum w-12 text-xs text-text-muted">{formatTime(duration)}</span>
</div>
</div>
{/* Right controls */}
<div className="flex flex-none items-center justify-end gap-1 md:w-72">
{chapters.length > 0 && (
<Dropdown
align="right"
trigger={(open) => (
<span
className={cn(
'hidden h-8 max-w-[160px] items-center gap-1.5 rounded px-2 text-xs text-text-muted transition-colors hover:bg-surface-2 hover:text-text lg:inline-flex',
open && 'bg-surface-2',
)}
title={chapter?.title}
>
<ListTree size={15} />
<span className="truncate">{chapter?.title ?? 'Kapitel'}</span>
</span>
)}
>
{(close) => (
<div className="max-h-[50dvh] overflow-y-auto">
{chapters.map((c) => (
<DropdownItem
key={c.id}
active={chapter?.id === c.id}
onSelect={() => {
seek(c.start)
close()
}}
>
<span className="flex w-full items-center justify-between gap-3">
<span className="truncate">{c.title}</span>
<span className="tnum shrink-0 text-xs text-text-muted">
{formatTime(c.start)}
</span>
</span>
</DropdownItem>
))}
</div>
)}
</Dropdown>
)}
<Dropdown
align="right"
trigger={(open) => (
<span
className={cn(
'tnum inline-flex h-8 items-center gap-1 rounded px-2 text-xs text-text-muted transition-colors hover:bg-surface-2 hover:text-text',
open && 'bg-surface-2',
)}
>
<Gauge size={15} /> {speed}×
</span>
)}
>
{(close) =>
PLAYBACK_SPEEDS.map((s) => (
<DropdownItem
key={s}
active={speed === s}
onSelect={() => {
setSpeed(s)
close()
}}
>
<span className="tnum">{s}×</span>
</DropdownItem>
))
}
</Dropdown>
<div className="hidden items-center gap-1 lg:flex">
<button
onClick={() => setVolume(volume > 0 ? 0 : 1)}
aria-label={volume > 0 ? 'Stumm' : 'Ton an'}
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-surface-2 hover:text-text"
>
{volume > 0 ? <Volume2 size={17} /> : <VolumeX size={17} />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
aria-label="Lautstärke"
className="h-1 w-20 cursor-pointer accent-[var(--accent)]"
/>
</div>
<button
onClick={stop}
aria-label="Player schließen"
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-surface-2 hover:text-text"
>
<X size={17} />
</button>
</div>
</div>
{/* Mobile progress (thin, full width) */}
<div className="flex items-center gap-2 px-3 pb-2 md:hidden">
<span className="tnum w-10 text-right text-[10px] text-text-muted">{formatTime(position)}</span>
<input
type="range"
min={0}
max={Math.max(duration, 1)}
step={1}
value={Math.min(position, duration || position)}
onChange={(e) => seek(Number(e.target.value))}
aria-label="Position"
className="h-1 flex-1 cursor-pointer accent-[var(--accent)]"
/>
<span className="tnum w-10 text-[10px] text-text-muted">{formatTime(duration)}</span>
{item && (
<span className="text-text-muted">
{item.mediaType === 'podcast' ? <Headphones size={14} /> : <BookOpen size={14} />}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,206 @@
import { useEffect, useRef } from 'react'
import { usePlayerStore } from '@/store/playerStore'
import { useProgressStore } from '@/store/progressStore'
import { streamUrl } from '@/lib/media'
import { syncSession, closeSession } from '@/api/sessions'
import { updateProgress } from '@/api/progress'
import type { AudioTrack } from '@/types/abs'
const SYNC_INTERVAL_MS = 30_000
const PAUSE_CLOSE_MS = 5 * 60_000
function trackIndexForPosition(tracks: AudioTrack[], pos: number): number {
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i]
if (pos < t.startOffset + t.duration) return i
}
return Math.max(0, tracks.length - 1)
}
/**
* Drives a single <audio> element from the player store: multi-track playback with global
* position mapping, seek, speed/volume, 30s session sync, finish detection, and auto-close
* on long pause / tab close. Mount once (in the PlayerBar).
*/
export function useAudioEngine(audioRef: React.RefObject<HTMLAudioElement>) {
const item = usePlayerStore((s) => s.item)
const episodeId = usePlayerStore((s) => s.episodeId)
const tracks = usePlayerStore((s) => s.audioTracks)
const isPlaying = usePlayerStore((s) => s.isPlaying)
const speed = usePlayerStore((s) => s.speed)
const volume = usePlayerStore((s) => s.volume)
const seekTo = usePlayerStore((s) => s.seekTo)
const loadedTrack = useRef<number | null>(null)
const lastSyncPos = useRef(0)
const pauseTimer = useRef<number | null>(null)
// Load the right track for a global position and optionally start playback.
// Reads tracks from the store so it's safe inside once-attached event listeners.
function ensureTrack(globalPos: number, autoplay: boolean) {
const audio = audioRef.current
const curTracks = usePlayerStore.getState().audioTracks
if (!audio || curTracks.length === 0) return
const idx = trackIndexForPosition(curTracks, globalPos)
const local = Math.max(0, globalPos - curTracks[idx].startOffset)
if (loadedTrack.current !== idx) {
loadedTrack.current = idx
audio.src = streamUrl(curTracks[idx].contentUrl)
const onMeta = () => {
audio.currentTime = local
if (autoplay) audio.play().catch(() => undefined)
}
audio.addEventListener('loadedmetadata', onMeta, { once: true })
audio.load()
} else {
audio.currentTime = local
if (autoplay) audio.play().catch(() => undefined)
}
}
// New playback session: reset and load from the requested seek position.
useEffect(() => {
loadedTrack.current = null
lastSyncPos.current = usePlayerStore.getState().position
if (tracks.length > 0) {
ensureTrack(usePlayerStore.getState().position, usePlayerStore.getState().isPlaying)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item?.id, episodeId, tracks])
// Handle explicit seeks.
useEffect(() => {
if (seekTo == null) return
ensureTrack(seekTo, usePlayerStore.getState().isPlaying)
usePlayerStore.getState().clearSeek()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seekTo])
// Play / pause.
useEffect(() => {
const audio = audioRef.current
if (!audio) return
if (isPlaying) {
if (loadedTrack.current === null) ensureTrack(usePlayerStore.getState().position, true)
else audio.play().catch(() => undefined)
} else {
audio.pause()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPlaying])
// Speed & volume.
useEffect(() => {
if (audioRef.current) audioRef.current.playbackRate = speed
}, [speed, audioRef])
useEffect(() => {
if (audioRef.current) audioRef.current.volume = volume
}, [volume, audioRef])
// Persist progress to the server + local store.
function persist(finished = false) {
const st = usePlayerStore.getState()
if (!st.item || !st.session) return
const pos = finished ? st.duration : st.position
const progress = st.duration > 0 ? Math.min(1, pos / st.duration) : 0
const listened = Math.max(0, pos - lastSyncPos.current)
lastSyncPos.current = pos
syncSession(st.session.id, {
currentTime: pos,
timeListened: listened,
duration: st.duration,
}).catch(() => undefined)
if (finished) {
updateProgress(st.item.id, { isFinished: true, currentTime: pos, progress: 1 }, st.episodeId ?? undefined).catch(
() => undefined,
)
}
useProgressStore.getState().upsert({
id: `${st.item.id}${st.episodeId ? `:${st.episodeId}` : ''}`,
libraryItemId: st.item.id,
episodeId: st.episodeId,
duration: st.duration,
progress,
currentTime: pos,
isFinished: finished,
})
}
// Audio element event listeners (attached once).
useEffect(() => {
const audio = audioRef.current
if (!audio) return
function onTimeUpdate() {
const st = usePlayerStore.getState()
const idx = loadedTrack.current
if (idx == null || !st.audioTracks[idx] || !audio) return
st.setPosition(st.audioTracks[idx].startOffset + audio.currentTime)
}
function onEnded() {
const st = usePlayerStore.getState()
const idx = loadedTrack.current
if (idx == null) return
if (idx + 1 < st.audioTracks.length) {
ensureTrack(st.audioTracks[idx + 1].startOffset, true)
} else {
st.setPlaying(false)
persist(true)
}
}
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('ended', onEnded)
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate)
audio.removeEventListener('ended', onEnded)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 30s sync while playing.
useEffect(() => {
if (!isPlaying) return
const id = window.setInterval(() => persist(false), SYNC_INTERVAL_MS)
return () => window.clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPlaying])
// Auto-close session after a long pause.
useEffect(() => {
if (isPlaying) {
if (pauseTimer.current) {
window.clearTimeout(pauseTimer.current)
pauseTimer.current = null
}
return
}
if (!usePlayerStore.getState().session) return
pauseTimer.current = window.setTimeout(() => {
persist(false)
usePlayerStore.getState().stop()
}, PAUSE_CLOSE_MS)
return () => {
if (pauseTimer.current) window.clearTimeout(pauseTimer.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPlaying])
// Flush + close on tab close.
useEffect(() => {
function onHide() {
const st = usePlayerStore.getState()
if (st.session) {
persist(false)
closeSession(st.session.id).catch(() => undefined)
}
}
window.addEventListener('pagehide', onHide)
return () => window.removeEventListener('pagehide', onHide)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}

View File

@@ -0,0 +1,46 @@
import { forwardRef } from 'react'
import { cn } from '@/lib/cn'
import { Spinner } from './Spinner'
type Variant = 'primary' | 'subtle' | 'ghost' | 'danger'
type Size = 'sm' | 'md'
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
size?: Size
loading?: boolean
}
const variants: Record<Variant, string> = {
primary: 'bg-accent text-on-accent hover:brightness-110',
subtle: 'bg-surface-2 text-text hover:bg-border',
ghost: 'bg-transparent text-text-muted hover:bg-surface-2 hover:text-text',
danger: 'bg-destructive text-white hover:brightness-110',
}
const sizes: Record<Size, string> = {
sm: 'h-8 px-3 text-sm gap-1.5',
md: 'h-10 px-4 text-sm gap-2',
}
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
{ variant = 'primary', size = 'md', loading, disabled, className, children, ...rest },
ref,
) {
return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
'inline-flex select-none items-center justify-center rounded font-medium transition-[filter,background-color,color] disabled:cursor-not-allowed disabled:opacity-50',
variants[variant],
sizes[size],
className,
)}
{...rest}
>
{loading && <Spinner size={size === 'sm' ? 14 : 16} />}
{children}
</button>
)
})

View File

@@ -0,0 +1,28 @@
import { cn } from '@/lib/cn'
interface Props {
checked: boolean
onChange: (checked: boolean) => void
label: string
disabled?: boolean
}
export function Checkbox({ checked, onChange, label, disabled }: Props) {
return (
<label
className={cn(
'flex cursor-pointer select-none items-center gap-2.5 text-sm',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-[var(--accent)]"
/>
<span className="text-text">{label}</span>
</label>
)
}

View File

@@ -0,0 +1,57 @@
import { useState } from 'react'
import { Modal } from './Modal'
import { Button } from './Button'
interface Props {
open: boolean
title: string
message: React.ReactNode
confirmLabel?: string
cancelLabel?: string
danger?: boolean
onConfirm: () => void | Promise<void>
onCancel: () => void
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
danger,
onConfirm,
onCancel,
}: Props) {
const [busy, setBusy] = useState(false)
async function handleConfirm() {
setBusy(true)
try {
await onConfirm()
} finally {
setBusy(false)
}
}
return (
<Modal
open={open}
onClose={busy ? () => undefined : onCancel}
title={title}
size="sm"
footer={
<>
<Button variant="ghost" onClick={onCancel} disabled={busy}>
{cancelLabel}
</Button>
<Button variant={danger ? 'danger' : 'primary'} loading={busy} onClick={handleConfirm}>
{confirmLabel}
</Button>
</>
}
>
<div className="text-sm text-text-muted">{message}</div>
</Modal>
)
}

View File

@@ -0,0 +1,63 @@
import { useRef, useState } from 'react'
import { cn } from '@/lib/cn'
import { useClickOutside } from '@/hooks/useClickOutside'
interface DropdownProps {
trigger: (open: boolean) => React.ReactNode
children: (close: () => void) => React.ReactNode
align?: 'left' | 'right'
className?: string
}
/** Lightweight popover menu: a trigger button + an absolutely-positioned panel. */
export function Dropdown({ trigger, children, align = 'left', className }: DropdownProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useClickOutside(ref, () => setOpen(false), open)
return (
<div ref={ref} className="relative">
<button type="button" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
{trigger(open)}
</button>
{open && (
<div
className={cn(
'absolute z-40 mt-1 min-w-[200px] overflow-hidden rounded-lg border border-border bg-surface py-1 shadow-lift animate-fade-in',
align === 'right' ? 'right-0' : 'left-0',
className,
)}
role="menu"
>
{children(() => setOpen(false))}
</div>
)}
</div>
)
}
interface ItemProps {
onSelect: () => void
active?: boolean
children: React.ReactNode
danger?: boolean
icon?: React.ReactNode
}
export function DropdownItem({ onSelect, active, children, danger, icon }: ItemProps) {
return (
<button
type="button"
role="menuitem"
onClick={onSelect}
className={cn(
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
danger ? 'text-destructive hover:bg-destructive/10' : 'text-text hover:bg-surface-2',
active && !danger && 'bg-surface-2 font-medium',
)}
>
{icon && <span className="shrink-0 text-text-muted">{icon}</span>}
<span className="flex-1 truncate">{children}</span>
</button>
)
}

View File

@@ -0,0 +1,41 @@
import { forwardRef, useId } from 'react'
import { cn } from '@/lib/cn'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
hint?: string
error?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, hint, error, className, id, ...rest },
ref,
) {
const autoId = useId()
const inputId = id ?? autoId
return (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="mb-1.5 block text-sm font-medium text-text">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
aria-invalid={error ? true : undefined}
className={cn(
'h-10 w-full rounded border bg-surface-2 px-3 text-sm text-text placeholder:text-text-muted/60 focus:outline-none',
error ? 'border-destructive focus:border-destructive' : 'border-border focus:border-accent',
className,
)}
{...rest}
/>
{error ? (
<p className="mt-1.5 text-xs text-destructive">{error}</p>
) : hint ? (
<p className="mt-1.5 text-xs text-text-muted">{hint}</p>
) : null}
</div>
)
})

View File

@@ -0,0 +1,77 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { cn } from '@/lib/cn'
interface Props {
open: boolean
onClose: () => void
title?: string
children: React.ReactNode
footer?: React.ReactNode
size?: 'sm' | 'md' | 'lg'
}
const sizes = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-2xl' }
export function Modal({ open, onClose, title, children, footer, size = 'md' }: Props) {
const panelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
panelRef.current?.focus()
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
}
}, [open, onClose])
if (!open) return null
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 animate-fade-in"
onClick={onClose}
aria-hidden
/>
<div
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
'relative z-10 w-full overflow-hidden rounded-xl border border-border bg-surface shadow-lift outline-none animate-slide-up',
sizes[size],
)}
>
{title && (
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="font-heading text-lg font-semibold">{title}</h2>
<button
onClick={onClose}
aria-label="Schließen"
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-surface-2 hover:text-text"
>
<X size={18} />
</button>
</div>
)}
<div className="max-h-[70dvh] overflow-y-auto px-5 py-4">{children}</div>
{footer && (
<div className="flex justify-end gap-2 border-t border-border bg-surface px-5 py-3.5">
{footer}
</div>
)}
</div>
</div>,
document.body,
)
}

View File

@@ -0,0 +1,17 @@
interface Props {
title: string
subtitle?: string
actions?: React.ReactNode
}
export function PageHeader({ title, subtitle, actions }: Props) {
return (
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="font-heading text-3xl font-semibold tracking-tight">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-text-muted">{subtitle}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Construction } from 'lucide-react'
/** Temporary content marker for views filled in by later build phases. */
export function Placeholder({ note }: { note: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-surface/40 px-6 py-16 text-center">
<Construction className="mb-3 text-text-muted" size={28} />
<p className="text-sm text-text-muted">{note}</p>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { forwardRef, useId } from 'react'
import { cn } from '@/lib/cn'
interface Props extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string
options: { value: string; label: string }[]
}
export const Select = forwardRef<HTMLSelectElement, Props>(function Select(
{ label, options, className, id, ...rest },
ref,
) {
const autoId = useId()
const selectId = id ?? autoId
return (
<div className="w-full">
{label && (
<label htmlFor={selectId} className="mb-1.5 block text-sm font-medium text-text">
{label}
</label>
)}
<select
ref={ref}
id={selectId}
className={cn(
'h-10 w-full rounded border border-border bg-surface-2 px-3 text-sm text-text focus:border-accent focus:outline-none',
className,
)}
{...rest}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
)
})

View File

@@ -0,0 +1,6 @@
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/cn'
export function Spinner({ size = 18, className }: { size?: number; className?: string }) {
return <Loader2 size={size} className={cn('animate-spin', className)} />
}

View File

@@ -0,0 +1,40 @@
import { AlertTriangle, Inbox, RotateCw } from 'lucide-react'
import { Button } from './Button'
export function ErrorState({
message,
onRetry,
}: {
message: string
onRetry?: () => void
}) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-destructive/30 bg-destructive/5 px-6 py-12 text-center">
<AlertTriangle className="mb-3 text-destructive" size={26} />
<p className="mb-4 max-w-md text-sm text-text">{message}</p>
{onRetry && (
<Button variant="subtle" size="sm" onClick={onRetry}>
<RotateCw size={15} /> Erneut versuchen
</Button>
)}
</div>
)
}
export function EmptyState({
title,
hint,
icon,
}: {
title: string
hint?: string
icon?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-surface/40 px-6 py-16 text-center">
<div className="mb-3 text-text-muted">{icon ?? <Inbox size={28} />}</div>
<p className="text-sm font-medium text-text">{title}</p>
{hint && <p className="mt-1 max-w-md text-sm text-text-muted">{hint}</p>}
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { cn } from '@/lib/cn'
export interface TabDef {
key: string
label: string
}
interface Props {
tabs: TabDef[]
active: string
onChange: (key: string) => void
}
export function Tabs({ tabs, active, onChange }: Props) {
return (
<div role="tablist" className="flex gap-1 border-b border-border">
{tabs.map((t) => (
<button
key={t.key}
role="tab"
aria-selected={active === t.key}
onClick={() => onChange(t.key)}
className={cn(
'relative -mb-px px-4 py-2.5 text-sm font-medium transition-colors',
active === t.key
? 'text-text'
: 'text-text-muted hover:text-text',
)}
>
{t.label}
{active === t.key && (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
)}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { createPortal } from 'react-dom'
import { CheckCircle2, XCircle, Info, X } from 'lucide-react'
import { useToastStore, type ToastKind } from '@/store/toastStore'
const icons: Record<ToastKind, React.ReactNode> = {
success: <CheckCircle2 size={18} className="text-success" />,
error: <XCircle size={18} className="text-destructive" />,
info: <Info size={18} className="text-accent" />,
}
export function Toaster() {
const toasts = useToastStore((s) => s.toasts)
const dismiss = useToastStore((s) => s.dismiss)
return createPortal(
<div
className="pointer-events-none fixed bottom-4 left-1/2 z-[60] flex w-full max-w-sm -translate-x-1/2 flex-col gap-2 px-4"
aria-live="polite"
role="status"
>
{toasts.map((t) => (
<div
key={t.id}
className="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-surface-2 px-4 py-3 shadow-lift animate-slide-up"
>
<span className="shrink-0">{icons[t.kind]}</span>
<p className="flex-1 text-sm text-text">{t.message}</p>
<button
onClick={() => dismiss(t.id)}
aria-label="Schließen"
className="shrink-0 text-text-muted hover:text-text"
>
<X size={15} />
</button>
</div>
))}
</div>,
document.body,
)
}