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:
82
src/components/ContextMenu.tsx
Normal file
82
src/components/ContextMenu.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
96
src/components/MediaCard.tsx
Normal file
96
src/components/MediaCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
src/components/MediaGrid.tsx
Normal file
34
src/components/MediaGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
src/components/MediaListRow.tsx
Normal file
68
src/components/MediaListRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
src/components/MediaRow.tsx
Normal file
51
src/components/MediaRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
39
src/components/detail/ChapterList.tsx
Normal file
39
src/components/detail/ChapterList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
src/components/detail/CoverTab.tsx
Normal file
203
src/components/detail/CoverTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
src/components/detail/EpisodeList.tsx
Normal file
94
src/components/detail/EpisodeList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/components/detail/FilesTab.tsx
Normal file
40
src/components/detail/FilesTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
202
src/components/detail/MetadataTab.tsx
Normal file
202
src/components/detail/MetadataTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
src/components/layout/AppLayout.tsx
Normal file
63
src/components/layout/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/components/layout/BottomNav.tsx
Normal file
50
src/components/layout/BottomNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
src/components/layout/Sidebar.tsx
Normal file
129
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
src/components/layout/TopBar.tsx
Normal file
39
src/components/layout/TopBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
src/components/player/PlayerBar.tsx
Normal file
248
src/components/player/PlayerBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
206
src/components/player/useAudioEngine.ts
Normal file
206
src/components/player/useAudioEngine.ts
Normal 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
|
||||
}, [])
|
||||
}
|
||||
46
src/components/ui/Button.tsx
Normal file
46
src/components/ui/Button.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
28
src/components/ui/Checkbox.tsx
Normal file
28
src/components/ui/Checkbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
src/components/ui/ConfirmDialog.tsx
Normal file
57
src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
src/components/ui/Dropdown.tsx
Normal file
63
src/components/ui/Dropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
src/components/ui/Input.tsx
Normal file
41
src/components/ui/Input.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
77
src/components/ui/Modal.tsx
Normal file
77
src/components/ui/Modal.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
17
src/components/ui/PageHeader.tsx
Normal file
17
src/components/ui/PageHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/components/ui/Placeholder.tsx
Normal file
11
src/components/ui/Placeholder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
src/components/ui/Select.tsx
Normal file
39
src/components/ui/Select.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
6
src/components/ui/Spinner.tsx
Normal file
6
src/components/ui/Spinner.tsx
Normal 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)} />
|
||||
}
|
||||
40
src/components/ui/States.tsx
Normal file
40
src/components/ui/States.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/components/ui/Tabs.tsx
Normal file
38
src/components/ui/Tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/components/ui/Toaster.tsx
Normal file
40
src/components/ui/Toaster.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user