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 @@
{"sessionId":"6c7f673e-5c18-4d34-9e6e-9a12d7dafcf4","pid":12940,"procStart":"639159816679139320","acquiredAt":1780378787616}

6
.claude/settings.json Normal file
View File

@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"ui-ux-pro-max@ui-ux-pro-max-skill": true,
"claude-mem@thedotmack": true
}
}

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# No env vars are required for development.
# The dev server proxies ABS requests dynamically via /__abs/<encoded-server-url>,
# so you just enter your real Audiobookshelf URL on the setup screen.

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Env
.env
.env.*
!.env.example

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
# Shelfless
Ein vollständiges alternatives Web-Frontend für [Audiobookshelf](https://www.audiobookshelf.org/)
(ABS). ABS läuft weiter als Backend; Shelfless ersetzt dessen Oberfläche Wiedergabe,
Bibliotheken, Metadaten, Cover, Podcasts, User-Admin, Scanner und Server-Einstellungen.
## Stack
React 18 · Vite · TypeScript · Tailwind (+ CSS-Variablen) · Zustand · React Router ·
Axios · HTML5-Audio · Lucide. Kein UI-Framework.
## Entwicklung
```bash
npm install
npm run dev # http://localhost:5173
npm run build # Typecheck + Production-Build
npm run preview # gebautes Bundle lokal servieren
```
Beim ersten Start öffnet sich der Setup-Screen: ABS-Server-URL (`http://host:13378`),
Benutzername, Passwort. Der Token wird in `localStorage` gespeichert und beim Start via
`POST /api/authorize` validiert.
## Design
Dark-Theme, ein Akzent (Amber, in den Einstellungen umstellbar), Fraunces (Headings) +
Hanken Grotesk (Body). Die verbindliche Design-Referenz liegt unter
[`design-system/MASTER.md`](design-system/MASTER.md).
## Struktur
```
src/
api/ ABS-API nach Domäne (auth, libraries, items, progress, sessions, …)
components/ MediaCard/Row/Grid, player/, detail/, admin/, ui/ (Primitives)
hooks/ useDebounce, useClickOutside
lib/ media (cover/stream-URLs), format, html (sicheres htmlToText), url
pages/ Home, Library, ItemDetail, Settings, Setup, admin/*
routes/ RequireAuth, RequireAdmin
store/ auth, libraries, progress, player, settings, toast (Zustand)
types/ abs.ts (ABS-API-Typen)
```
## Hinweise zur ABS-API
Einige Pfade weichen vom ursprünglichen Briefing ab und folgen der echten ABS-API:
Login `POST /login`, Authorize `POST /api/authorize`, Wiedergabe `POST /api/items/:id/play`.
Server-Settings/Backups-Pfade ggf. je nach ABS-Version verifizieren.

60
deploy/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Deployment (Proxmox LXC, auto-start)
Shelfless runs as a small Node server that serves the built SPA **and** reverse-proxies
the ABS API. So the browser only ever talks to Shelfless (one origin → no CORS), and the
Audiobookshelf address is fixed by the deployment (`ABS_URL`) — users only log in.
## 1. Prerequisites on the container
```bash
apt update && apt install -y nodejs npm git # Node 18+ (20+ recommended)
node --version
```
## 2. Get the code & build
```bash
mkdir -p /opt/shelfless && cd /opt/shelfless
# copy the project here (git clone / scp / rsync), then:
npm ci
npm run build # produces dist/
```
## 3. Configure the ABS target
The server reads these env vars (see the systemd unit):
- `ABS_URL` — the Audiobookshelf server, e.g. `http://127.0.0.1:13378` (same container)
or `http://192.168.1.71:13378` (another host).
- `PORT` — the port Shelfless listens on (default `8080`).
- `HOST` — bind address (default `0.0.0.0`).
Quick manual test:
```bash
ABS_URL=http://127.0.0.1:13378 PORT=8080 npm start
# open http://<container-ip>:8080
```
## 4. Auto-start with systemd
```bash
cp deploy/shelfless.service /etc/systemd/system/shelfless.service
# edit WorkingDirectory / ABS_URL / PORT inside the file if needed
systemctl daemon-reload
systemctl enable --now shelfless
systemctl status shelfless # check it's running
journalctl -u shelfless -f # logs
```
Open `http://<container-ip>:8080`. The setup screen asks only for username + password
(the server URL is fixed). "Angemeldet bleiben" keeps you logged in across reloads;
otherwise you log in each session.
## Updating
```bash
cd /opt/shelfless
git pull # or copy new files
npm ci && npm run build
systemctl restart shelfless
```
## Notes
- Put it behind a reverse proxy (nginx/Caddy/Traefik) for HTTPS + a domain if you expose
it beyond the LAN.
- Proxied paths: `/api`, `/login`, `/logout`, `/public`, `/status`, `/hls`, `/feed`,
`/socket.io`. Everything else is served from `dist/` (SPA fallback to `index.html`).

75
deploy/install.sh Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Shelfless installer for a Debian/Ubuntu Proxmox LXC.
# Installs Node, fetches the repo, builds the frontend, and sets up a systemd service
# that serves the app and reverse-proxies to your Audiobookshelf server.
#
# Usage (as root on the container):
# REPO_URL=https://git.scarriffle.com/<owner>/shelfless.git \
# ABS_URL=http://127.0.0.1:13378 PORT=8080 \
# bash install.sh
#
# Re-running updates an existing install.
set -euo pipefail
REPO_URL="${REPO_URL:?Set REPO_URL to your git repo, e.g. https://git.scarriffle.com/owner/shelfless.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/shelfless}"
ABS_URL="${ABS_URL:-http://127.0.0.1:13378}"
PORT="${PORT:-8080}"
SERVICE="${SERVICE:-shelfless}"
echo "==> Installing system dependencies"
apt-get update -y
apt-get install -y git curl ca-certificates
if ! command -v node >/dev/null 2>&1; then
echo "==> Installing Node.js 20.x"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
fi
echo " node $(node --version), npm $(npm --version)"
echo "==> Fetching source into $INSTALL_DIR"
if [ -d "$INSTALL_DIR/.git" ]; then
git -C "$INSTALL_DIR" pull --ff-only
else
git clone "$REPO_URL" "$INSTALL_DIR"
fi
echo "==> Building"
cd "$INSTALL_DIR"
npm ci
npm run build
echo "==> Writing systemd unit /etc/systemd/system/$SERVICE.service"
NODE_BIN="$(command -v node)"
cat > "/etc/systemd/system/$SERVICE.service" <<EOF
[Unit]
Description=Shelfless (Audiobookshelf frontend)
After=network.target
[Service]
Type=simple
WorkingDirectory=$INSTALL_DIR
ExecStart=$NODE_BIN server/index.mjs
Restart=on-failure
RestartSec=3
Environment=NODE_ENV=production
Environment=PORT=$PORT
Environment=ABS_URL=$ABS_URL
[Install]
WantedBy=multi-user.target
EOF
echo "==> Enabling and starting service"
systemctl daemon-reload
systemctl enable --now "$SERVICE"
sleep 1
systemctl --no-pager --full status "$SERVICE" || true
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
echo ""
echo "Done. Shelfless is running:"
echo " URL: http://${IP:-<container-ip>}:$PORT"
echo " ABS: $ABS_URL"
echo " Logs: journalctl -u $SERVICE -f"

24
deploy/shelfless.service Normal file
View File

@@ -0,0 +1,24 @@
[Unit]
Description=Shelfless (Audiobookshelf frontend)
After=network.target
# If Audiobookshelf runs on the same container, start after it:
# After=network.target audiobookshelf.service
# Wants=audiobookshelf.service
[Service]
Type=simple
# Adjust to where you copied the project:
WorkingDirectory=/opt/shelfless
ExecStart=/usr/bin/node server/index.mjs
Restart=on-failure
RestartSec=3
Environment=NODE_ENV=production
Environment=PORT=8080
# The Audiobookshelf server this frontend talks to (same container = localhost):
Environment=ABS_URL=http://127.0.0.1:13378
# Optional hardening:
# User=shelfless
# Group=shelfless
[Install]
WantedBy=multi-user.target

88
design-system/MASTER.md Normal file
View File

@@ -0,0 +1,88 @@
# Shelfless — Design System (MASTER)
Single source of truth for visual + interaction design. Grounded in ui-ux-pro-max
(Podcast Platform #27 + Music Streaming #45 + Book & Reading Tracker #135 → **Dark Mode
(OLED) + Minimalism** with a literary-warm note) and the project brief.
When building any page: read this file first. A page-specific override under
`design-system/pages/<page>.md` (if present) takes precedence over these rules.
---
## Principles
- Dark theme by default. One consistent accent color, never competing accents.
- Generous whitespace, clear type hierarchy, content (covers) first.
- Subtle micro-animations only (cover hover, player slide-in). No motion overload.
- NO AI-slop: no purple gradients, no glassmorphism overload, no card-in-card nesting.
- NO online badge, user counter, activity feed, or "welcome back" banner.
- Reference feel: Plex overview (but cleaner), Spotify player (but less cluttered).
## Typography
Both verified as Google variable fonts. **No Inter / Roboto / Space Grotesk.**
- Headings / titles / section headers: **Fraunces** (serif, literary, optical sizing).
- Body / UI: **Hanken Grotesk** (humanist grotesque, highly readable).
- Numbers in player timers, durations, data tables: `font-variant-numeric: tabular-nums`.
- Type scale (px): 12 · 14 · 16 · 18 · 24 · 32 · 48. Body 16px, line-height 1.51.6.
- Weight hierarchy: headings 500700, body 400, labels/nav-active 500.
- Loaded locally via `@fontsource-variable/fraunces` + `@fontsource-variable/hanken-grotesk`
(no hard CDN dependency); `font-display: swap`.
## Color tokens (CSS custom properties on `:root`, dark default)
All foreground/background pairs verified ≥4.5:1 (text) / ≥3:1 (large/UI).
| Token | Value | Use |
|------------------|-----------|--------------------------------------|
| `--bg` | `#0C0A09` | app background (warm near-black) |
| `--surface` | `#1C1917` | cards, sidebar, panels |
| `--surface-2` | `#292524` | raised surfaces, player bar, inputs |
| `--border` | `#3F3B38` | borders/dividers (visible in dark) |
| `--text` | `#FAFAF9` | primary text |
| `--text-muted` | `#A8A29E` | secondary text (≥4.5:1 on `--bg`) |
| `--accent` | `#F59E0B` | accent (default Amber) |
| `--on-accent` | `#0C0A09` | text/icon on accent fills |
| `--destructive` | `#EF4444` | delete / danger |
| `--success` | `#22C55E` | progress / completed |
Accent is themeable via `data-accent` on `<html>`; options in Settings:
- `amber` #F59E0B (default) · `teal` #14B8A6 · `coral` #FB7185 · `green` #22C55E · `blue` #3B82F6.
Tailwind maps `bg`, `surface`, `surface-2`, `border`, `text`, `text-muted`, `accent`,
`on-accent`, `destructive`, `success` to these variables (`colors` in tailwind.config).
## Spacing, radius, elevation
- 4/8px spacing rhythm. Section spacing tiers 16 / 24 / 32 / 48.
- Radius ~1012px on cards/buttons/inputs (`--radius: 0.625rem`). 0px never; no huge pills.
- Elevation: one consistent shadow scale; modals/player use a slightly stronger shadow.
Avoid random shadow values.
## Motion (respect `prefers-reduced-motion`)
- Durations 150300ms; enter ease-out, exit ~6070% shorter.
- Animate only `transform` / `opacity` (never width/height/top/left).
- Cover hover: `scale(1.0 → 1.03)` + subtle lift/shadow.
- Player bar: slide-up + fade on first appearance.
- Skeleton shimmer while loading (>300ms). Visible 2px accent focus ring.
- Max 12 animated elements per view.
## Component guidelines
- **MediaCard**: cover (book 2:3 / podcast 1:1), rounded, hover-lift. Progress strip at
bottom in `--accent`, shown only when progress > 0%. Subtle check overlay when finished.
- **Sidebar**: `--surface` bg; active item = accent indicator bar + medium weight; always
icon + label (never icon-only); collapsible to icon mode; on mobile → bottom nav (≤5).
- **PlayerBar**: fixed bottom, `--surface-2`; tabular-nums times; draggable progress bar
with accent only on the playhead/fill; touch targets ≥44px.
- **Grid**: responsive `auto-fill minmax(...)`, `gap-6`. Virtualize only if a single page
renders 50+ items (infinite scroll paginates, so usually fine).
- **Modal**: scrim 4060% black; animate from trigger (scale+fade); Esc/close affordance;
focus trap + return focus; confirm before destructive/unsaved-dismiss.
- **Table** (admin users): `aria-sort`, tabular-nums, row hover; destructive actions
visually separated from normal ones.
- **Forms**: visible labels (not placeholder-only), error below field, helper text,
loading state on submit, password show/hide toggle, autofocus first invalid field.
## Icons
Lucide React only. Consistent stroke width; one icon size scale (sm/md=24/lg). No emoji.
## Anti-patterns (do not do)
Purple/indigo gradients · glassmorphism overload · card-in-card-in-card · emoji icons ·
online/user-count/activity-feed/welcome banners · icon-only nav · hover-only interactions ·
animating layout properties · gray-on-gray low contrast · raw hex in components (use tokens).

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de" data-accent="amber">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Shelfless</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3121
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "shelfless",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"start": "node server/index.mjs"
},
"dependencies": {
"@fontsource-variable/fraunces": "^5.1.0",
"@fontsource-variable/hanken-grotesk": "^5.1.0",
"axios": "^1.7.7",
"lucide-react": "^0.453.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

9
public/favicon.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0C0A09"/>
<g fill="none" stroke="#F59E0B" stroke-width="2.4" stroke-linecap="round">
<path d="M11 9v14"/>
<path d="M16 9v14"/>
<path d="M21.2 9.6l4.2 13.4"/>
</g>
<path d="M7 24h18" stroke="#A8A29E" stroke-width="2.2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

116
server/index.mjs Normal file
View File

@@ -0,0 +1,116 @@
// Production server for Shelfless.
// Serves the built SPA (dist/) and reverse-proxies ABS API paths to the Audiobookshelf
// server given by ABS_URL — so the browser talks to a single origin (no CORS) and the
// ABS address is fixed by the deployment, not entered per browser.
//
// ABS_URL=http://127.0.0.1:13378 PORT=8080 node server/index.mjs
//
// Only Node built-ins are used (no dependencies to install on the container).
import http from 'node:http'
import https from 'node:https'
import { createReadStream, existsSync, statSync } from 'node:fs'
import { join, extname, normalize } from 'node:path'
import { fileURLToPath } from 'node:url'
const PORT = Number(process.env.PORT || 8080)
const HOST = process.env.HOST || '0.0.0.0'
const ABS_URL = process.env.ABS_URL || 'http://127.0.0.1:13378'
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const DIST = join(ROOT, 'dist')
// Paths that must be forwarded to Audiobookshelf.
const PROXY_PREFIXES = ['/api', '/login', '/logout', '/public', '/status', '/hls', '/feed', '/socket.io']
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.webmanifest': 'application/manifest+json',
'.txt': 'text/plain; charset=utf-8',
}
const target = new URL(ABS_URL)
const proxyLib = target.protocol === 'https:' ? https : http
function isProxied(url) {
return PROXY_PREFIXES.some((p) => url === p || url.startsWith(p + '/') || url.startsWith(p + '?'))
}
function proxy(req, res) {
const headers = { ...req.headers, host: target.host }
delete headers.origin
delete headers.referer
const proxyReq = proxyLib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (target.protocol === 'https:' ? 443 : 80),
method: req.method,
path: req.url,
headers,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers)
proxyRes.pipe(res)
},
)
proxyReq.on('error', (e) => {
res.statusCode = 502
res.end(`Upstream error: ${e.message}`)
})
req.pipe(proxyReq)
}
function sendFile(res, filePath, status = 200) {
res.writeHead(status, {
'Content-Type': MIME[extname(filePath)] || 'application/octet-stream',
// Hashed asset files are immutable; index.html should not be cached.
'Cache-Control': filePath.includes(`${join('dist', 'assets')}`)
? 'public, max-age=31536000, immutable'
: 'no-cache',
})
createReadStream(filePath).pipe(res)
}
function serveStatic(req, res) {
// Strip query, prevent path traversal, default to index.html (SPA fallback).
const urlPath = decodeURIComponent((req.url || '/').split('?')[0])
const safe = normalize(urlPath).replace(/^(\.\.[/\\])+/, '')
let filePath = join(DIST, safe)
if (!filePath.startsWith(DIST)) {
res.statusCode = 403
return res.end('Forbidden')
}
if (existsSync(filePath) && statSync(filePath).isFile()) {
return sendFile(res, filePath)
}
// SPA fallback
const indexFile = join(DIST, 'index.html')
if (existsSync(indexFile)) return sendFile(res, indexFile)
res.statusCode = 404
res.end('Not found')
}
const server = http.createServer((req, res) => {
const url = req.url || '/'
if (isProxied(url)) return proxy(req, res)
return serveStatic(req, res)
})
server.listen(PORT, HOST, () => {
console.log(`Shelfless running on http://${HOST}:${PORT} → ABS ${ABS_URL}`)
})

40
src/App.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { AppLayout } from './components/layout/AppLayout'
import { RequireAuth } from './routes/RequireAuth'
import { RequireAdmin } from './routes/RequireAdmin'
import Home from './pages/Home'
import Library from './pages/Library'
import ItemDetail from './pages/ItemDetail'
import Settings from './pages/Settings'
import Setup from './pages/Setup'
import AdminUsers from './pages/admin/AdminUsers'
import AdminLibraries from './pages/admin/AdminLibraries'
import AdminScan from './pages/admin/AdminScan'
import AdminSettings from './pages/admin/AdminSettings'
export default function App() {
return (
<Routes>
<Route path="/setup" element={<Setup />} />
<Route element={<RequireAuth />}>
<Route element={<AppLayout />}>
<Route path="/" element={<Home />} />
<Route path="/library/:id" element={<Library />} />
<Route path="/item/:id" element={<ItemDetail />} />
<Route path="/settings" element={<Settings />} />
<Route element={<RequireAdmin />}>
<Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/libraries" element={<AdminLibraries />} />
<Route path="/admin/scan" element={<AdminScan />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin" element={<Navigate to="/admin/users" replace />} />
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

28
src/api/auth.ts Normal file
View File

@@ -0,0 +1,28 @@
import axios from 'axios'
import { api } from './client'
import { backendBase } from '@/lib/backend'
import type { AuthorizeResponse, LoginResponse } from '@/types/abs'
/**
* Log in against an ABS server. Uses a one-off request (not the shared client) because
* the session/baseURL isn't established yet. NOTE: real ABS login is `POST /login`
* (no `/api` prefix); the token is at `response.user.token`.
*/
export async function login(
serverUrl: string,
username: string,
password: string,
): Promise<LoginResponse> {
const res = await axios.post<LoginResponse>(
`${backendBase(serverUrl)}/login`,
{ username, password },
{ timeout: 30_000 },
)
return res.data
}
/** Validate the stored token and refresh user info. ABS: `POST /api/authorize`. */
export async function authorize(): Promise<AuthorizeResponse> {
const res = await api.post<AuthorizeResponse>('/api/authorize')
return res.data
}

52
src/api/client.ts Normal file
View File

@@ -0,0 +1,52 @@
import axios, { AxiosError, type AxiosInstance } from 'axios'
import { useAuthStore } from '@/store/authStore'
import { backendBase } from '@/lib/backend'
/**
* Shared axios instance for all ABS calls.
* - baseURL + Bearer token are injected per-request from the auth store, so a server-URL
* change or re-login takes effect immediately without recreating the client.
* - A 401 clears the session (RequireAuth then redirects to /setup).
*/
export const api: AxiosInstance = axios.create({
timeout: 30_000,
})
api.interceptors.request.use((config) => {
const { serverUrl, token } = useAuthStore.getState()
if (serverUrl) config.baseURL = backendBase(serverUrl)
if (token) {
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(res) => res,
(error: AxiosError) => {
if (error.response?.status === 401) {
const { status, logout } = useAuthStore.getState()
// Don't thrash during the initial login attempt (no session yet).
if (status === 'authenticated' || status === 'authenticating') {
logout()
}
}
return Promise.reject(error)
},
)
/** Normalize an axios error into a short, user-facing message (never leaks the token). */
export function apiErrorMessage(error: unknown, fallback = 'Ein Fehler ist aufgetreten.'): string {
if (axios.isAxiosError(error)) {
const status = error.response?.status
const data = error.response?.data as { error?: string; message?: string } | undefined
if (status === 401) return 'Anmeldedaten ungültig oder Sitzung abgelaufen.'
if (status === 403) return 'Keine Berechtigung für diese Aktion.'
if (status === 404) return 'Nicht gefunden.'
if (error.code === 'ERR_NETWORK') return 'Server nicht erreichbar. URL prüfen.'
if (error.code === 'ECONNABORTED') return 'Zeitüberschreitung. Server antwortet nicht.'
return data?.error || data?.message || fallback
}
return fallback
}

59
src/api/items.ts Normal file
View File

@@ -0,0 +1,59 @@
import { api } from './client'
import type { BookMetadata, LibraryItem, PodcastMetadata } from '@/types/abs'
export interface GetItemOptions {
expanded?: boolean
/** e.g. "progress" */
include?: string
}
export async function getItem(id: string, opts: GetItemOptions = {}): Promise<LibraryItem> {
const res = await api.get<LibraryItem>(`/api/items/${id}`, {
params: {
expanded: opts.expanded ? 1 : undefined,
include: opts.include,
},
})
return res.data
}
export async function deleteItem(id: string, hard = false): Promise<void> {
await api.delete(`/api/items/${id}`, { params: hard ? { hard: 1 } : undefined })
}
/** Update book/podcast metadata. ABS: PATCH /api/items/:id/media with { metadata }. */
export async function updateMedia(
id: string,
payload: { metadata?: Partial<BookMetadata> | Partial<PodcastMetadata>; tags?: string[] },
): Promise<LibraryItem> {
const res = await api.patch<LibraryItem>(`/api/items/${id}/media`, payload)
return res.data
}
/** Upload a cover image file (multipart/form-data). */
export async function uploadCover(id: string, file: File): Promise<{ success?: boolean; cover?: string }> {
const form = new FormData()
form.append('cover', file)
const res = await api.post(`/api/items/${id}/cover`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return res.data
}
/** Set a cover from a remote URL (JSON body). */
export async function setCoverByUrl(id: string, url: string): Promise<{ success?: boolean; cover?: string }> {
const res = await api.post(`/api/items/${id}/cover`, { url })
return res.data
}
export async function removeCover(id: string): Promise<void> {
await api.delete(`/api/items/${id}/cover`)
}
export async function batchDelete(libraryItemIds: string[], hard = false): Promise<void> {
await api.post('/api/items/batch/delete', { libraryItemIds }, { params: hard ? { hard: 1 } : undefined })
}
export async function batchUpdate(updates: Array<{ id: string } & Record<string, unknown>>): Promise<void> {
await api.post('/api/items/batch/update', updates)
}

123
src/api/libraries.ts Normal file
View File

@@ -0,0 +1,123 @@
import { api } from './client'
import type {
LibrariesResponse,
Library,
LibraryItem,
LibraryItemsResponse,
LibrarySearchResult,
LibraryStats,
} from '@/types/abs'
export interface LibraryItemsParams {
limit?: number
page?: number
sort?: string
desc?: boolean
/** Pre-encoded ABS filter string, e.g. `genres.<base64>` (see buildFilter). */
filter?: string
collapseseries?: boolean
include?: string
}
export async function getLibraries(): Promise<Library[]> {
const res = await api.get<LibrariesResponse>('/api/libraries')
return res.data.libraries
}
export async function getLibrary(id: string): Promise<Library> {
const res = await api.get<{ library: Library } | Library>(`/api/libraries/${id}`)
// ABS may wrap as { library } or return the object directly.
return (res.data as { library?: Library }).library ?? (res.data as Library)
}
export async function getLibraryItems(
id: string,
params: LibraryItemsParams = {},
): Promise<LibraryItemsResponse> {
const res = await api.get<LibraryItemsResponse>(`/api/libraries/${id}/items`, {
params: {
limit: params.limit ?? 50,
page: params.page ?? 0,
sort: params.sort,
desc: params.desc ? 1 : 0,
filter: params.filter,
collapseseries: params.collapseseries ? 1 : 0,
include: params.include,
minified: 1,
},
})
return res.data
}
export async function searchLibrary(id: string, q: string): Promise<LibrarySearchResult> {
const res = await api.get<LibrarySearchResult>(`/api/libraries/${id}/search`, {
params: { q },
})
return res.data
}
export async function getLibraryStats(id: string): Promise<LibraryStats> {
const res = await api.get<LibraryStats>(`/api/libraries/${id}/stats`)
return res.data
}
export interface LibraryFilterData {
authors: { id: string; name: string }[]
genres: string[]
tags: string[]
languages: string[]
narrators: string[]
}
export async function getLibraryFilterData(id: string): Promise<LibraryFilterData> {
const res = await api.get<Partial<LibraryFilterData>>(`/api/libraries/${id}/filterdata`)
return {
authors: res.data.authors ?? [],
genres: res.data.genres ?? [],
tags: res.data.tags ?? [],
languages: res.data.languages ?? [],
narrators: res.data.narrators ?? [],
}
}
/** Recently added items for a library (most-recent first). */
export async function getRecentlyAdded(id: string, limit = 12): Promise<LibraryItem[]> {
const res = await getLibraryItems(id, { limit, page: 0, sort: 'addedAt', desc: true })
return res.results
}
// ── Admin mutations ──────────────────────────────────────────────────────────
export interface LibraryInput {
name: string
folders: { fullPath: string }[]
mediaType?: 'book' | 'podcast'
icon?: string
provider?: string
}
export async function createLibrary(input: LibraryInput): Promise<Library> {
const res = await api.post<Library>('/api/libraries', input)
return res.data
}
export async function updateLibrary(id: string, input: Partial<LibraryInput>): Promise<Library> {
const res = await api.patch<Library>(`/api/libraries/${id}`, input)
return res.data
}
export async function deleteLibrary(id: string): Promise<void> {
await api.delete(`/api/libraries/${id}`)
}
export async function scanLibrary(id: string, force = false): Promise<void> {
await api.post(`/api/libraries/${id}/scan`, undefined, {
params: force ? { force: 1 } : undefined,
})
}
/** ABS filter values are `<group>.<base64(value)>`. */
export function buildFilter(group: string, value: string): string {
// browser-safe base64 of a UTF-8 string
const b64 = btoa(unescape(encodeURIComponent(value)))
return `${group}.${b64}`
}

38
src/api/metadata.ts Normal file
View File

@@ -0,0 +1,38 @@
import { api } from './client'
import type { LibraryItem } from '@/types/abs'
export interface CoverSearchParams {
title: string
author?: string
provider?: string
}
/** Search online cover providers. ABS: GET /api/search/covers. Returns image URLs. */
export async function searchCovers(params: CoverSearchParams): Promise<string[]> {
const res = await api.get<{ results?: string[] }>('/api/search/covers', {
params: {
title: params.title,
author: params.author,
provider: params.provider,
},
})
return res.data.results ?? []
}
export interface MatchPayload {
provider?: string
title?: string
author?: string
isbn?: string
asin?: string
overrideDefaults?: boolean
}
/** Auto-match metadata from an online provider. ABS: POST /api/items/:id/match. */
export async function matchItem(id: string, payload: MatchPayload): Promise<LibraryItem> {
const res = await api.post<{ libraryItem?: LibraryItem } | LibraryItem>(
`/api/items/${id}/match`,
payload,
)
return (res.data as { libraryItem?: LibraryItem }).libraryItem ?? (res.data as LibraryItem)
}

25
src/api/podcasts.ts Normal file
View File

@@ -0,0 +1,25 @@
import { api } from './client'
import type { PodcastEpisode } from '@/types/abs'
/** Check the podcast's RSS feed for new episodes. ABS: POST /api/podcasts/:id/checknew. */
export async function checkNewEpisodes(itemId: string): Promise<PodcastEpisode[]> {
const res = await api.post<{ episodes?: PodcastEpisode[] }>(`/api/podcasts/${itemId}/checknew`)
return res.data.episodes ?? []
}
export interface FeedEpisodePreview {
title: string
description?: string
pubDate?: string
enclosure?: { url: string; type?: string; length?: string }
duration?: string
}
/** Preview an RSS feed (used when adding podcasts). ABS: POST /api/podcasts/feed. */
export async function previewFeed(rssFeed: string): Promise<FeedEpisodePreview[]> {
const res = await api.post<{ podcast?: { episodes?: FeedEpisodePreview[] } }>(
'/api/podcasts/feed',
{ rssFeed },
)
return res.data.podcast?.episodes ?? []
}

56
src/api/progress.ts Normal file
View File

@@ -0,0 +1,56 @@
import { api } from './client'
import type { AbsUser, ItemsInProgressResponse, LibraryItem, MediaProgress } from '@/types/abs'
/** Full user record incl. all media progress (used to populate progress bars). */
export async function getMyProgress(): Promise<MediaProgress[]> {
const res = await api.get<AbsUser & { mediaProgress?: MediaProgress[] }>('/api/me')
return res.data.mediaProgress ?? []
}
export async function getItemsInProgress(limit = 25): Promise<LibraryItem[]> {
const res = await api.get<ItemsInProgressResponse>('/api/me/items-in-progress', {
params: { limit },
})
return res.data.libraryItems ?? []
}
export async function getProgress(
itemId: string,
episodeId?: string,
): Promise<MediaProgress | null> {
const path = episodeId
? `/api/me/progress/${itemId}/${episodeId}`
: `/api/me/progress/${itemId}`
try {
const res = await api.get<MediaProgress>(path)
return res.data
} catch {
return null
}
}
export interface ProgressUpdate {
currentTime?: number
duration?: number
progress?: number // 0..1
isFinished?: boolean
hideFromContinueListening?: boolean
}
export async function updateProgress(
itemId: string,
payload: ProgressUpdate,
episodeId?: string,
): Promise<void> {
const path = episodeId
? `/api/me/progress/${itemId}/${episodeId}`
: `/api/me/progress/${itemId}`
await api.patch(path, payload)
}
export async function removeProgress(itemId: string, episodeId?: string): Promise<void> {
const path = episodeId
? `/api/me/progress/${itemId}/${episodeId}`
: `/api/me/progress/${itemId}`
await api.delete(path)
}

36
src/api/server.ts Normal file
View File

@@ -0,0 +1,36 @@
import { api } from './client'
import { authorize } from './auth'
import type { BackupInfo } from '@/types/abs'
/**
* Server settings + backups.
* NOTE: exact admin paths vary by ABS version — verify against the running instance
* (see memory: abs-api-corrections). Settings are surfaced via the authorize response;
* updates use PATCH /api/settings (best-effort).
*/
export async function getServerSettings(): Promise<Record<string, unknown>> {
// ABS exposes current server settings on the authorize response.
const res = await authorize()
return (res as unknown as { serverSettings?: Record<string, unknown> }).serverSettings ?? {}
}
export async function updateServerSettings(
settings: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const res = await api.patch<{ serverSettings?: Record<string, unknown> }>('/api/settings', settings)
return res.data.serverSettings ?? res.data
}
export async function getBackups(): Promise<BackupInfo[]> {
const res = await api.get<{ backups: BackupInfo[] }>('/api/backups')
return res.data.backups ?? []
}
export async function createBackup(): Promise<BackupInfo[]> {
const res = await api.post<{ backups: BackupInfo[] }>('/api/backups')
return res.data.backups ?? []
}
export async function applyBackup(id: string): Promise<void> {
await api.post(`/api/backups/${id}/apply`)
}

54
src/api/sessions.ts Normal file
View File

@@ -0,0 +1,54 @@
import { api } from './client'
import type { PlaybackSession } from '@/types/abs'
/** Device payload ABS expects when opening a session. */
const deviceInfo = {
deviceId: 'shelfless-web',
clientName: 'Shelfless',
clientVersion: '0.1.0',
}
export interface PlayRequest {
// Force direct play; we use the native <audio> element.
forceDirectPlay?: boolean
forceTranscode?: boolean
mediaPlayer?: string
deviceInfo?: typeof deviceInfo
}
/**
* Open a playback session. ABS: `POST /api/items/:id/play` (or `/play/:episodeId`),
* returns the session incl. audioTracks, chapters, currentTime.
*/
export async function playItem(
itemId: string,
episodeId?: string,
req: PlayRequest = {},
): Promise<PlaybackSession> {
const path = episodeId
? `/api/items/${itemId}/play/${episodeId}`
: `/api/items/${itemId}/play`
const res = await api.post<PlaybackSession>(path, {
forceDirectPlay: true,
mediaPlayer: 'html5',
deviceInfo,
...req,
})
return res.data
}
export interface SyncPayload {
currentTime: number
timeListened: number
duration: number
}
/** Periodic progress sync (every ~30s while playing). */
export async function syncSession(sessionId: string, payload: SyncPayload): Promise<void> {
await api.post(`/api/session/${sessionId}/sync`, payload)
}
/** Close a session (on pause >5min, tab close, or switching items). */
export async function closeSession(sessionId: string, payload?: SyncPayload): Promise<void> {
await api.post(`/api/session/${sessionId}/close`, payload)
}

41
src/api/users.ts Normal file
View File

@@ -0,0 +1,41 @@
import { api } from './client'
import type { AbsUser, UserPermissions, UserType } from '@/types/abs'
export async function getUsers(): Promise<AbsUser[]> {
const res = await api.get<{ users: AbsUser[] }>('/api/users')
return res.data.users ?? []
}
export interface CreateUserInput {
username: string
password: string
type: UserType
email?: string | null
permissions?: Partial<UserPermissions>
librariesAccessible?: string[]
isActive?: boolean
}
export async function createUser(input: CreateUserInput): Promise<AbsUser> {
const res = await api.post<{ user: AbsUser } | AbsUser>('/api/users', input)
return (res.data as { user?: AbsUser }).user ?? (res.data as AbsUser)
}
export interface UpdateUserInput {
username?: string
password?: string
type?: UserType
email?: string | null
isActive?: boolean
permissions?: Partial<UserPermissions>
librariesAccessible?: string[]
}
export async function updateUser(id: string, input: UpdateUserInput): Promise<AbsUser> {
const res = await api.patch<{ user: AbsUser } | AbsUser>(`/api/users/${id}`, input)
return (res.data as { user?: AbsUser }).user ?? (res.data as AbsUser)
}
export async function deleteUser(id: string): Promise<void> {
await api.delete(`/api/users/${id}`)
}

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

View File

@@ -0,0 +1,24 @@
import { useEffect, type RefObject } from 'react'
/** Calls `handler` on a mousedown outside `ref` or when Escape is pressed. */
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T>,
handler: () => void,
active = true,
) {
useEffect(() => {
if (!active) return
function onDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) handler()
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') handler()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [ref, handler, active])
}

11
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react'
/** Returns `value` after it stops changing for `delay` ms. */
export function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}

137
src/index.css Normal file
View File

@@ -0,0 +1,137 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Design tokens (see design-system/MASTER.md) ───────────────────────── */
:root {
--bg: #0c0a09;
--surface: #1c1917;
--surface-2: #292524;
--border: #3f3b38;
--text: #fafaf9;
--text-muted: #a8a29e;
/* accent (default amber) — overridden by [data-accent] below */
--accent: #f59e0b;
--accent-soft: rgba(245, 158, 11, 0.14);
--on-accent: #0c0a09;
--destructive: #ef4444;
--success: #22c55e;
--radius: 0.625rem;
color-scheme: dark;
}
[data-accent='amber'] {
--accent: #f59e0b;
--accent-soft: rgba(245, 158, 11, 0.14);
--on-accent: #0c0a09;
}
[data-accent='teal'] {
--accent: #14b8a6;
--accent-soft: rgba(20, 184, 166, 0.14);
--on-accent: #0c0a09;
}
[data-accent='coral'] {
--accent: #fb7185;
--accent-soft: rgba(251, 113, 133, 0.14);
--on-accent: #0c0a09;
}
[data-accent='green'] {
--accent: #22c55e;
--accent-soft: rgba(34, 197, 94, 0.14);
--on-accent: #0c0a09;
}
[data-accent='blue'] {
--accent: #3b82f6;
--accent-soft: rgba(59, 130, 246, 0.16);
--on-accent: #fafaf9;
}
/* ── Base ──────────────────────────────────────────────────────────────── */
@layer base {
* {
border-color: var(--border);
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background-color: var(--bg);
color: var(--text);
font-family: theme('fontFamily.sans');
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, h3, h4 {
font-family: theme('fontFamily.heading');
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.15;
}
/* tabular numerals for timers / durations / data tables */
.tnum {
font-variant-numeric: tabular-nums;
}
/* visible, on-brand focus ring */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--surface-2);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border);
}
}
@layer utilities {
/* skeleton shimmer */
.skeleton {
position: relative;
overflow: hidden;
background: var(--surface-2);
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.06),
transparent
);
animation: shimmer 1.4s infinite;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}

22
src/lib/backend.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* How the frontend reaches Audiobookshelf, depending on how it's running.
*
* - **Dev**: route through the Vite dynamic proxy (`/__abs/<encoded-server>`), so the
* browser never makes a cross-origin call (ABS sends no CORS headers) and the target
* can change at runtime.
* - **Prod ("managed")**: the app is served by its own Node server (see `server/`), which
* reverse-proxies `/api`, `/login`, … to the ABS server configured via the ABS_URL env
* var. The frontend therefore talks same-origin (empty base) and the user only needs to
* enter credentials — the server URL is fixed by the deployment.
*/
export const isManaged = import.meta.env.PROD
export function backendBase(serverUrl: string | null | undefined): string {
if (import.meta.env.DEV) return serverUrl ? `/__abs/${encodeURIComponent(serverUrl)}` : ''
return '' // managed: same-origin reverse proxy
}
/** The effective server URL to store for the session. */
export function effectiveServerUrl(entered: string): string {
return isManaged ? window.location.origin : entered
}

4
src/lib/cn.ts Normal file
View File

@@ -0,0 +1,4 @@
/** Tiny classnames helper — joins truthy class fragments. */
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ')
}

47
src/lib/format.ts Normal file
View File

@@ -0,0 +1,47 @@
/** Format seconds as H:MM:SS (or M:SS when under an hour). */
export function formatTime(totalSeconds: number | null | undefined): string {
if (totalSeconds == null || !isFinite(totalSeconds) || totalSeconds < 0) return '0:00'
const s = Math.floor(totalSeconds % 60)
const m = Math.floor((totalSeconds / 60) % 60)
const h = Math.floor(totalSeconds / 3600)
const ss = String(s).padStart(2, '0')
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${ss}`
return `${m}:${ss}`
}
/** Human duration like "12 Std 34 Min" / "34 Min" / "45 Sek". */
export function formatDuration(totalSeconds: number | null | undefined): string {
if (!totalSeconds || totalSeconds < 0) return '—'
const h = Math.floor(totalSeconds / 3600)
const m = Math.floor((totalSeconds % 3600) / 60)
if (h > 0) return `${h} Std${m ? ` ${m} Min` : ''}`
if (m > 0) return `${m} Min`
return `${Math.floor(totalSeconds)} Sek`
}
/** Format a byte count as KB/MB/GB. */
export function formatBytes(bytes: number | null | undefined): string {
if (!bytes || bytes < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`
}
/** Format a unix-ms timestamp as a localized date, or "—". */
export function formatDate(ms: number | null | undefined): string {
if (!ms) return '—'
try {
return new Date(ms).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return '—'
}
}

14
src/lib/html.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Convert untrusted HTML (e.g. ABS book/podcast descriptions) to plain text.
* Uses DOMParser, whose documents are inert: scripts don't run and resources
* (img/onerror etc.) don't load — so this is XSS-safe. We render only the text.
*/
export function htmlToText(html: string | null | undefined): string {
if (!html) return ''
try {
const doc = new DOMParser().parseFromString(html, 'text/html')
return (doc.body.textContent || '').replace(/\n{3,}/g, '\n\n').trim()
} catch {
return html.replace(/<[^>]*>/g, '')
}
}

87
src/lib/media.ts Normal file
View File

@@ -0,0 +1,87 @@
import { useAuthStore } from '@/store/authStore'
import { backendBase } from '@/lib/backend'
import type {
Book,
LibraryItem,
Media,
Podcast,
SeriesRef,
} from '@/types/abs'
// ── URLs (token-authenticated; routed through the dev proxy in dev) ──
export function coverUrl(itemId: string, opts?: { ts?: number }): string {
const { serverUrl, token } = useAuthStore.getState()
if (!serverUrl) return ''
const params = new URLSearchParams()
if (token) params.set('token', token)
if (opts?.ts) params.set('ts', String(opts.ts))
const qs = params.toString()
return `${backendBase(serverUrl)}/api/items/${itemId}/cover${qs ? `?${qs}` : ''}`
}
/** Token-authenticated URL for a playback track's contentUrl. */
export function streamUrl(contentUrl: string): string {
const { serverUrl, token } = useAuthStore.getState()
if (!serverUrl) return ''
const sep = contentUrl.includes('?') ? '&' : '?'
return `${backendBase(serverUrl)}${contentUrl}${token ? `${sep}token=${encodeURIComponent(token)}` : ''}`
}
// ── Type guards ──────────────────────────────────────────────────────────────
export function isPodcastItem(item: LibraryItem): boolean {
return item.mediaType === 'podcast'
}
function asBook(media: Media): Book {
return media as Book
}
function asPodcast(media: Media): Podcast {
return media as Podcast
}
// ── Metadata accessors (handle minified vs. expanded shapes) ──────────────────
export function getTitle(item: LibraryItem): string {
return item.media?.metadata?.title || 'Ohne Titel'
}
export function getAuthor(item: LibraryItem): string {
if (item.mediaType === 'podcast') {
return asPodcast(item.media).metadata.author || ''
}
const m = asBook(item.media).metadata
if (m.authorName) return m.authorName
if (m.authors?.length) return m.authors.map((a) => a.name).join(', ')
return ''
}
export function getNarrator(item: LibraryItem): string {
if (item.mediaType !== 'book') return ''
const m = asBook(item.media).metadata
if (m.narratorName) return m.narratorName
if (m.narrators?.length) return m.narrators.join(', ')
return ''
}
export function getSeries(item: LibraryItem): SeriesRef | null {
if (item.mediaType !== 'book') return null
const m = asBook(item.media).metadata
if (m.series?.length) return m.series[0]
if (m.seriesName) {
const match = m.seriesName.match(/^(.*?)(?:\s+#([\d.]+))?$/)
if (match) return { name: match[1], sequence: match[2] ?? null }
return { name: m.seriesName }
}
return null
}
export function getDescription(item: LibraryItem): string {
return item.media?.metadata?.description || ''
}
export function getDuration(item: LibraryItem): number {
if (item.mediaType === 'book') return asBook(item.media).duration ?? 0
return 0
}
export function getCoverAspect(item: LibraryItem): 'square' | 'portrait' {
return item.mediaType === 'podcast' ? 'square' : 'portrait'
}

22
src/lib/url.ts Normal file
View File

@@ -0,0 +1,22 @@
/** URL helpers with a strict http/https allowlist (guards against javascript:/data: etc.). */
export function isSafeHttpUrl(value: string): boolean {
try {
const u = new URL(value)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
/**
* Normalize a user-entered server URL: trim, default to http:// when no scheme is given,
* drop a trailing slash. Returns null if the result isn't a valid http(s) URL.
*/
export function normalizeServerUrl(raw: string): string | null {
let value = raw.trim()
if (!value) return null
if (!/^https?:\/\//i.test(value)) value = `http://${value}`
if (!isSafeHttpUrl(value)) return null
return value.replace(/\/+$/, '')
}

20
src/main.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
// Variable fonts (self-hosted via Fontsource — no CDN dependency)
import '@fontsource-variable/fraunces'
import '@fontsource-variable/hanken-grotesk'
import './index.css'
import App from './App'
import { Toaster } from './components/ui/Toaster'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
<Toaster />
</BrowserRouter>
</React.StrictMode>,
)

96
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { MediaRow } from '@/components/MediaRow'
import { EmptyState } from '@/components/ui/States'
import { Library as LibraryIcon } from 'lucide-react'
import { useLibraryStore } from '@/store/libraryStore'
import { getItemsInProgress } from '@/api/progress'
import { getRecentlyAdded } from '@/api/libraries'
import type { LibraryItem, Library } from '@/types/abs'
interface RecentState {
library: Library
items: LibraryItem[]
loading: boolean
}
export default function Home() {
const libraries = useLibraryStore((s) => s.libraries)
const librariesLoaded = useLibraryStore((s) => s.loaded)
const [continueItems, setContinueItems] = useState<LibraryItem[]>([])
const [continueLoading, setContinueLoading] = useState(true)
const [recent, setRecent] = useState<RecentState[]>([])
useEffect(() => {
let cancelled = false
setContinueLoading(true)
getItemsInProgress(20)
.then((items) => !cancelled && setContinueItems(items))
.catch(() => !cancelled && setContinueItems([]))
.finally(() => !cancelled && setContinueLoading(false))
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!librariesLoaded) return
let cancelled = false
setRecent(libraries.map((library) => ({ library, items: [], loading: true })))
libraries.forEach((library) => {
getRecentlyAdded(library.id, 12)
.then((items) => {
if (cancelled) return
setRecent((prev) =>
prev.map((r) => (r.library.id === library.id ? { ...r, items, loading: false } : r)),
)
})
.catch(() => {
if (cancelled) return
setRecent((prev) =>
prev.map((r) => (r.library.id === library.id ? { ...r, loading: false } : r)),
)
})
})
return () => {
cancelled = true
}
}, [librariesLoaded, libraries])
const nothingYet =
librariesLoaded &&
libraries.length === 0 &&
!continueLoading &&
continueItems.length === 0
return (
<div className="pt-2">
{(continueLoading || continueItems.length > 0) && (
<MediaRow
title="Weiterhören"
items={continueItems}
loading={continueLoading}
/>
)}
{recent.map(({ library, items, loading }) => (
<MediaRow
key={library.id}
title={library.name}
items={items}
loading={loading}
to={`/library/${library.id}`}
emptyNote="Noch keine Titel in dieser Bibliothek."
/>
))}
{nothingYet && (
<EmptyState
icon={<LibraryIcon size={28} />}
title="Noch nichts hier"
hint="Lege im Admin-Bereich eine Bibliothek an und starte einen Scan."
/>
)}
</div>
)
}

247
src/pages/ItemDetail.tsx Normal file
View File

@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { Play, RotateCcw, ChevronDown, ChevronUp, Headphones, BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Tabs, type TabDef } from '@/components/ui/Tabs'
import { ErrorState } from '@/components/ui/States'
import { ChapterList } from '@/components/detail/ChapterList'
import { EpisodeList } from '@/components/detail/EpisodeList'
import { MetadataTab } from '@/components/detail/MetadataTab'
import { CoverTab } from '@/components/detail/CoverTab'
import { FilesTab } from '@/components/detail/FilesTab'
import { cn } from '@/lib/cn'
import { coverUrl, getAuthor, getNarrator, getSeries, getTitle, getDuration } from '@/lib/media'
import { htmlToText } from '@/lib/html'
import { formatDuration } from '@/lib/format'
import { getItem } from '@/api/items'
import { apiErrorMessage } from '@/api/client'
import { useProgressStore } from '@/store/progressStore'
import { usePlayerStore, currentChapter } from '@/store/playerStore'
import type { Book, LibraryItem, Podcast } from '@/types/abs'
export default function ItemDetail() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
const [item, setItem] = useState<LibraryItem | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [error, setError] = useState('')
const [descOpen, setDescOpen] = useState(false)
const prog = useProgressStore((s) => (item ? s.byKey[item.id] : undefined))
const play = usePlayerStore((s) => s.play)
const seek = usePlayerStore((s) => s.seek)
const playingItemId = usePlayerStore((s) => s.item?.id)
const playerPosition = usePlayerStore((s) => s.position)
const playerChapters = usePlayerStore((s) => s.chapters)
useEffect(() => {
let cancelled = false
setStatus('loading')
getItem(id, { expanded: true, include: 'progress' })
.then((it) => {
if (cancelled) return
setItem(it)
setStatus('ready')
})
.catch((err) => {
if (cancelled) return
setError(apiErrorMessage(err, 'Titel konnte nicht geladen werden.'))
setStatus('error')
})
return () => {
cancelled = true
}
}, [id])
const isPodcast = item?.mediaType === 'podcast'
const tabs = useMemo<TabDef[]>(() => {
const base: TabDef[] = [
{ key: 'overview', label: isPodcast ? 'Episoden' : 'Übersicht' },
{ key: 'metadata', label: 'Metadaten' },
{ key: 'cover', label: 'Cover' },
{ key: 'files', label: 'Dateien' },
]
return base
}, [isPodcast])
const activeTab = params.get('tab') ?? 'overview'
function setTab(key: string) {
setParams((p) => {
if (key === 'overview') p.delete('tab')
else p.set('tab', key)
return p
})
}
if (status === 'loading') return <DetailSkeleton />
if (status === 'error' || !item)
return <ErrorState message={error} onRetry={() => window.location.reload()} />
const title = getTitle(item)
const author = getAuthor(item)
const narrator = getNarrator(item)
const series = getSeries(item)
const description = htmlToText(item.media.metadata.description)
const duration = getDuration(item)
const chapters = (item.media as Book).chapters ?? []
const episodeCount = (item.media as Podcast).episodes?.length ?? 0
const publishedYear = !isPodcast ? (item.media as Book).metadata.publishedYear : null
const language = item.media.metadata.language
const finished = prog?.isFinished ?? false
const hasProgress = !!prog && prog.progress > 0 && !finished
const remaining = prog ? Math.max(0, (prog.duration || duration) - prog.currentTime) : duration
function handlePlay() {
if (!item) return
const start = hasProgress && prog ? prog.currentTime : 0
play(item, undefined, start)
}
function handleRestart() {
if (!item) return
play(item, undefined, 0)
}
function handleChapterJump(start: number) {
if (!item) return
if (playingItemId === item.id) seek(start)
else play(item, undefined, start)
}
const activeChapterStart =
playingItemId === item.id && playerChapters.length
? currentChapter(playerChapters, playerPosition)?.start
: undefined
return (
<div className="pb-4">
{/* Header */}
<div className="mb-8 flex flex-col gap-6 sm:flex-row">
<div className="mx-auto w-44 shrink-0 sm:mx-0 sm:w-52">
<div className="relative aspect-square overflow-hidden rounded-lg border border-border bg-surface-2 shadow-card">
<img
src={coverUrl(item.id)}
alt=""
className="h-full w-full object-cover"
onError={(e) => (e.currentTarget.style.visibility = 'hidden')}
/>
</div>
</div>
<div className="min-w-0 flex-1">
{series && (
<p className="mb-1 text-sm text-accent">
{series.name}
{series.sequence ? ` · Band ${series.sequence}` : ''}
</p>
)}
<h1 className="font-heading text-3xl font-semibold leading-tight tracking-tight sm:text-4xl">
{title}
</h1>
{author && <p className="mt-2 text-lg text-text">{author}</p>}
{narrator && <p className="text-sm text-text-muted">Gelesen von {narrator}</p>}
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-text-muted">
{publishedYear && <span>{publishedYear}</span>}
{!isPodcast && duration > 0 && (
<span className="tnum inline-flex items-center gap-1">
<BookOpen size={14} /> {formatDuration(duration)}
</span>
)}
{isPodcast && episodeCount > 0 && (
<span className="inline-flex items-center gap-1">
<Headphones size={14} /> {episodeCount} Episoden
</span>
)}
{language && <span>{language}</span>}
</div>
{/* Progress */}
{hasProgress && prog && (
<div className="mt-4 max-w-md">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
<div className="h-full bg-accent" style={{ width: `${Math.round(prog.progress * 100)}%` }} />
</div>
<p className="tnum mt-1 text-xs text-text-muted">
{Math.round(prog.progress * 100)}% · noch {formatDuration(remaining)}
</p>
</div>
)}
{/* Actions */}
{!isPodcast && (
<div className="mt-5 flex flex-wrap gap-2">
<Button onClick={handlePlay}>
<Play size={18} /> {hasProgress ? 'Weiterhören' : finished ? 'Erneut hören' : 'Abspielen'}
</Button>
{hasProgress && (
<Button variant="subtle" onClick={handleRestart}>
<RotateCcw size={16} /> Von vorn
</Button>
)}
</div>
)}
</div>
</div>
{/* Tabs */}
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
<div className="pt-6">
{activeTab === 'overview' && (
<div className="space-y-6">
{description && (
<div>
<p className={cn('whitespace-pre-line text-sm leading-relaxed text-text-muted', !descOpen && 'line-clamp-4')}>
{description}
</p>
{description.length > 280 && (
<button
onClick={() => setDescOpen((v) => !v)}
className="mt-1 inline-flex items-center gap-1 text-sm text-accent hover:underline"
>
{descOpen ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
{descOpen ? 'Weniger' : 'Mehr'}
</button>
)}
</div>
)}
{isPodcast ? (
<EpisodeList item={item} />
) : chapters.length > 0 ? (
<div>
<h2 className="mb-3 font-heading text-lg font-semibold">Kapitel</h2>
<ChapterList
chapters={chapters}
activeStart={activeChapterStart}
onJump={handleChapterJump}
/>
</div>
) : null}
</div>
)}
{activeTab === 'metadata' && (
<MetadataTab item={item} onUpdated={(updated) => setItem(updated)} />
)}
{activeTab === 'cover' && <CoverTab item={item} />}
{activeTab === 'files' && <FilesTab item={item} />}
</div>
</div>
)
}
function DetailSkeleton() {
return (
<div className="flex flex-col gap-6 sm:flex-row">
<div className="skeleton mx-auto aspect-square w-44 rounded-lg sm:mx-0 sm:w-52" />
<div className="flex-1 space-y-3">
<div className="skeleton h-8 w-2/3 rounded" />
<div className="skeleton h-5 w-1/3 rounded" />
<div className="skeleton h-4 w-1/4 rounded" />
<div className="skeleton mt-4 h-10 w-40 rounded" />
</div>
</div>
)
}

493
src/pages/Library.tsx Normal file
View File

@@ -0,0 +1,493 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import {
ArrowDownUp,
ArrowDown,
ArrowUp,
LayoutGrid,
List,
Filter,
Search,
Play,
Pencil,
RotateCcw,
Trash2,
Check,
} from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { MediaGrid, MediaGridSkeleton } from '@/components/MediaGrid'
import { MediaListRow } from '@/components/MediaListRow'
import { ErrorState, EmptyState } from '@/components/ui/States'
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'
import { ContextMenu, type MenuAction } from '@/components/ContextMenu'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { cn } from '@/lib/cn'
import { getTitle } from '@/lib/media'
import { useDebounce } from '@/hooks/useDebounce'
import { useLibraryStore } from '@/store/libraryStore'
import { useCan } from '@/store/authStore'
import { useProgressStore } from '@/store/progressStore'
import { usePlayerStore } from '@/store/playerStore'
import { toast } from '@/store/toastStore'
import {
getLibraryItems,
getLibraryFilterData,
searchLibrary,
buildFilter,
type LibraryFilterData,
} from '@/api/libraries'
import { deleteItem } from '@/api/items'
import { removeProgress } from '@/api/progress'
import { apiErrorMessage } from '@/api/client'
import type { LibraryItem } from '@/types/abs'
const PAGE_SIZE = 50
interface SortOption {
key: string
label: string
}
interface ActiveFilter {
value: string // pre-encoded ABS filter
label: string
}
export default function Library() {
const { id = '' } = useParams()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const library = useLibraryStore((s) => s.libraries.find((l) => l.id === id))
const canUpdate = useCan('update')
const canDelete = useCan('delete')
const progressRemove = useProgressStore((s) => s.remove)
const play = usePlayerStore((s) => s.play)
const isPodcast = library?.mediaType === 'podcast'
const sortOptions = useMemo<SortOption[]>(
() => [
{ key: 'media.metadata.title', label: 'Titel' },
{ key: isPodcast ? 'media.metadata.author' : 'media.metadata.authorName', label: 'Autor' },
{ key: 'addedAt', label: 'Hinzugefügt' },
{ key: 'media.duration', label: 'Dauer' },
{ key: 'media.metadata.publishedYear', label: 'Jahr' },
],
[isPodcast],
)
const [view, setView] = useState<'grid' | 'list'>('grid')
const [sort, setSort] = useState('addedAt')
const [desc, setDesc] = useState(true)
const [filter, setFilter] = useState<ActiveFilter | null>(null)
const [filterData, setFilterData] = useState<LibraryFilterData | null>(null)
const [items, setItems] = useState<LibraryItem[]>([])
const [total, setTotal] = useState(0)
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState('')
const [reloadKey, setReloadKey] = useState(0)
const [rawQuery, setRawQuery] = useState(searchParams.get('q') ?? '')
const query = useDebounce(rawQuery.trim(), 350)
// Sync the search box when arriving via the global search (?q=) or switching library.
useEffect(() => {
setRawQuery(searchParams.get('q') ?? '')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, searchParams])
const [searchItems, setSearchItems] = useState<LibraryItem[] | null>(null)
const [searching, setSearching] = useState(false)
const [menu, setMenu] = useState<{ x: number; y: number; item: LibraryItem } | null>(null)
const [toDelete, setToDelete] = useState<LibraryItem | null>(null)
const pageRef = useRef(0)
const reqRef = useRef(0)
// Load filter data (genres / authors) once per library.
useEffect(() => {
if (!id) return
getLibraryFilterData(id).then(setFilterData).catch(() => setFilterData(null))
}, [id])
// Reset + load first page when library / sort / filter changes.
useEffect(() => {
if (!id) return
const token = ++reqRef.current
pageRef.current = 0
setStatus('loading')
setItems([])
getLibraryItems(id, {
limit: PAGE_SIZE,
page: 0,
sort,
desc,
filter: filter?.value,
})
.then((res) => {
if (token !== reqRef.current) return
setItems(res.results)
setTotal(res.total)
setStatus('ready')
})
.catch((err) => {
if (token !== reqRef.current) return
setError(apiErrorMessage(err, 'Bibliothek konnte nicht geladen werden.'))
setStatus('error')
})
}, [id, sort, desc, filter, reloadKey])
const loadMore = useCallback(() => {
if (loadingMore || status !== 'ready' || items.length >= total) return
const token = reqRef.current
setLoadingMore(true)
const nextPage = pageRef.current + 1
getLibraryItems(id, {
limit: PAGE_SIZE,
page: nextPage,
sort,
desc,
filter: filter?.value,
})
.then((res) => {
if (token !== reqRef.current) return
pageRef.current = nextPage
setItems((prev) => [...prev, ...res.results])
})
.catch(() => undefined)
.finally(() => setLoadingMore(false))
}, [id, sort, desc, filter, items.length, total, loadingMore, status])
// Live search.
useEffect(() => {
if (!id) return
if (!query) {
setSearchItems(null)
return
}
let cancelled = false
setSearching(true)
searchLibrary(id, query)
.then((res) => {
if (cancelled) return
const flat: LibraryItem[] = [
...(res.book?.map((b) => b.libraryItem) ?? []),
...(res.podcast?.map((p) => p.libraryItem) ?? []),
]
setSearchItems(flat)
})
.catch(() => !cancelled && setSearchItems([]))
.finally(() => !cancelled && setSearching(false))
return () => {
cancelled = true
}
}, [id, query])
// Infinite scroll sentinel.
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (searchItems !== null) return
const el = sentinelRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => entries[0].isIntersecting && loadMore(),
{ rootMargin: '600px' },
)
obs.observe(el)
return () => obs.disconnect()
}, [loadMore, searchItems])
function openMenu(e: React.MouseEvent, item: LibraryItem) {
e.preventDefault()
setMenu({ x: e.clientX, y: e.clientY, item })
}
async function resetItemProgress(item: LibraryItem) {
try {
await removeProgress(item.id)
progressRemove(item.id)
toast.success('Fortschritt zurückgesetzt.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Konnte Fortschritt nicht zurücksetzen.'))
}
}
async function confirmDelete() {
if (!toDelete) return
try {
await deleteItem(toDelete.id)
setItems((prev) => prev.filter((i) => i.id !== toDelete.id))
setSearchItems((prev) => prev?.filter((i) => i.id !== toDelete.id) ?? null)
toast.success('Titel gelöscht.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Löschen fehlgeschlagen.'))
} finally {
setToDelete(null)
}
}
const menuActions = (item: LibraryItem): MenuAction[] => [
{
label: 'Abspielen',
icon: <Play size={16} />,
hidden: item.mediaType === 'podcast',
onSelect: () => play(item),
},
{
label: 'Metadaten bearbeiten',
icon: <Pencil size={16} />,
hidden: !canUpdate,
onSelect: () => navigate(`/item/${item.id}?tab=metadata`),
},
{
label: 'Fortschritt zurücksetzen',
icon: <RotateCcw size={16} />,
onSelect: () => resetItemProgress(item),
},
{
label: 'Löschen',
icon: <Trash2 size={16} />,
danger: true,
hidden: !canDelete,
onSelect: () => setToDelete(item),
},
]
const showSearch = searchItems !== null
const displayItems = showSearch ? searchItems : items
const sortLabel = sortOptions.find((s) => s.key === sort)?.label ?? 'Sortieren'
return (
<>
<PageHeader
title={library?.name ?? 'Bibliothek'}
subtitle={!showSearch && status === 'ready' ? `${total} Titel` : undefined}
/>
{/* Toolbar */}
<div className="mb-5 flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] flex-1">
<Search
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
/>
<input
value={rawQuery}
onChange={(e) => setRawQuery(e.target.value)}
type="search"
placeholder="In dieser Bibliothek 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"
/>
</div>
{!showSearch && (
<>
{/* Filter */}
<Dropdown
align="right"
trigger={(open) => (
<span
className={cn(
'inline-flex h-9 items-center gap-1.5 rounded border px-3 text-sm transition-colors',
filter
? 'border-accent bg-accent-soft text-text'
: 'border-border text-text-muted hover:bg-surface-2',
open && 'bg-surface-2',
)}
>
<Filter size={15} />
{filter ? filter.label : 'Filter'}
</span>
)}
>
{(close) => (
<div className="max-h-[60dvh] overflow-y-auto">
<DropdownItem
active={!filter}
onSelect={() => {
setFilter(null)
close()
}}
>
Alle
</DropdownItem>
<p className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-text-muted/70">
Status
</p>
{[
{ v: 'in-progress', l: 'In Bearbeitung' },
{ v: 'finished', l: 'Abgeschlossen' },
{ v: 'not-started', l: 'Nicht begonnen' },
].map((s) => {
const value = `progress.${s.v}`
return (
<DropdownItem
key={s.v}
active={filter?.value === value}
onSelect={() => {
setFilter({ value, label: s.l })
close()
}}
>
{s.l}
</DropdownItem>
)
})}
{filterData && filterData.genres.length > 0 && (
<>
<p className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-text-muted/70">
Genre
</p>
{filterData.genres.slice(0, 40).map((g) => {
const value = buildFilter('genres', g)
return (
<DropdownItem
key={g}
active={filter?.value === value}
onSelect={() => {
setFilter({ value, label: g })
close()
}}
>
{g}
</DropdownItem>
)
})}
</>
)}
</div>
)}
</Dropdown>
{/* Sort */}
<Dropdown
align="right"
trigger={(open) => (
<span
className={cn(
'inline-flex h-9 items-center gap-1.5 rounded border border-border px-3 text-sm text-text-muted transition-colors hover:bg-surface-2',
open && 'bg-surface-2',
)}
>
<ArrowDownUp size={15} />
{sortLabel}
</span>
)}
>
{(close) =>
sortOptions.map((o) => (
<DropdownItem
key={o.key}
active={sort === o.key}
icon={sort === o.key ? <Check size={15} /> : <span className="w-[15px]" />}
onSelect={() => {
setSort(o.key)
close()
}}
>
{o.label}
</DropdownItem>
))
}
</Dropdown>
<button
onClick={() => setDesc((d) => !d)}
aria-label={desc ? 'Absteigend' : 'Aufsteigend'}
title={desc ? 'Absteigend' : 'Aufsteigend'}
className="grid h-9 w-9 place-items-center rounded border border-border text-text-muted transition-colors hover:bg-surface-2"
>
{desc ? <ArrowDown size={16} /> : <ArrowUp size={16} />}
</button>
</>
)}
<div className="flex h-9 items-center overflow-hidden rounded border border-border">
<button
onClick={() => setView('grid')}
aria-label="Rasteransicht"
className={cn('grid h-full w-9 place-items-center', view === 'grid' ? 'bg-surface-2 text-text' : 'text-text-muted')}
>
<LayoutGrid size={16} />
</button>
<button
onClick={() => setView('list')}
aria-label="Listenansicht"
className={cn('grid h-full w-9 place-items-center', view === 'list' ? 'bg-surface-2 text-text' : 'text-text-muted')}
>
<List size={16} />
</button>
</div>
</div>
{/* Content */}
{status === 'error' ? (
<ErrorState message={error} onRetry={() => setReloadKey((k) => k + 1)} />
) : (showSearch ? searching : status === 'loading') ? (
view === 'grid' ? <MediaGridSkeleton /> : <ListSkeleton />
) : displayItems.length === 0 ? (
<EmptyState
title={showSearch ? 'Keine Treffer' : 'Keine Titel'}
hint={showSearch ? 'Andere Suchbegriffe versuchen.' : 'Diese Bibliothek ist leer oder gefiltert.'}
/>
) : view === 'grid' ? (
<MediaGrid items={displayItems} onContextMenu={openMenu} />
) : (
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{displayItems.map((item) => (
<MediaListRow key={item.id} item={item} onContextMenu={openMenu} />
))}
</div>
)}
{/* Infinite scroll sentinel */}
{!showSearch && status === 'ready' && items.length < total && (
<div ref={sentinelRef} className="flex justify-center py-8 text-sm text-text-muted">
{loadingMore ? 'Lädt …' : ''}
</div>
)}
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
actions={menuActions(menu.item)}
onClose={() => setMenu(null)}
/>
)}
<ConfirmDialog
open={!!toDelete}
title="Titel löschen?"
danger
confirmLabel="Löschen"
message={
<>
<strong className="text-text">{toDelete ? getTitle(toDelete) : ''}</strong> wird
dauerhaft entfernt. Diese Aktion kann nicht rückgängig gemacht werden.
</>
}
onConfirm={confirmDelete}
onCancel={() => setToDelete(null)}
/>
</>
)
}
function ListSkeleton() {
return (
<div className="flex flex-col gap-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border border-border p-2">
<div className="skeleton h-14 w-14 shrink-0 rounded" />
<div className="flex-1 space-y-2">
<div className="skeleton h-3.5 w-1/3 rounded" />
<div className="skeleton h-3 w-1/5 rounded" />
</div>
</div>
))}
</div>
)
}

156
src/pages/Settings.tsx Normal file
View File

@@ -0,0 +1,156 @@
import { Check, LogOut, Server, Plus } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { cn } from '@/lib/cn'
import {
ACCENTS,
PLAYBACK_SPEEDS,
useSettingsStore,
} from '@/store/settingsStore'
import { useAuthStore } from '@/store/authStore'
import { isManaged } from '@/lib/backend'
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-lg border border-border bg-surface p-5">
<h2 className="mb-4 font-heading text-lg font-semibold">{title}</h2>
{children}
</section>
)
}
export default function Settings() {
const navigate = useNavigate()
const { accent, customAccent, setAccent, setCustomAccent, defaultSpeed, setDefaultSpeed } =
useSettingsStore()
const serverUrl = useAuthStore((s) => s.serverUrl)
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
function onLogout() {
logout()
navigate('/setup', { replace: true })
}
return (
<>
<PageHeader title="Einstellungen" subtitle="Persönliche Optionen" />
<div className="grid max-w-2xl gap-5">
<Card title="Akzentfarbe">
<div className="flex flex-wrap items-center gap-3">
{ACCENTS.map((a) => {
const active = !customAccent && accent === a.name
return (
<button
key={a.name}
onClick={() => setAccent(a.name)}
aria-label={a.label}
aria-pressed={active}
className={cn(
'relative grid h-10 w-10 place-items-center rounded-full ring-2 ring-offset-2 ring-offset-surface transition-transform hover:scale-105',
active ? 'ring-text' : 'ring-transparent',
)}
style={{ backgroundColor: a.swatch }}
>
{active && <Check size={18} className="text-black/80" />}
</button>
)
})}
{/* Free color picker */}
<label
className={cn(
'relative grid h-10 w-10 cursor-pointer place-items-center overflow-hidden rounded-full ring-2 ring-offset-2 ring-offset-surface transition-transform hover:scale-105',
customAccent ? 'ring-text' : 'ring-transparent',
)}
style={{
background: customAccent
? customAccent
: 'conic-gradient(from 0deg, #f59e0b, #22c55e, #14b8a6, #3b82f6, #fb7185, #f59e0b)',
}}
title="Eigene Farbe wählen"
>
{customAccent ? (
<Check size={18} className="text-white mix-blend-difference" />
) : (
<Plus size={18} className="text-white drop-shadow" />
)}
<input
type="color"
value={customAccent ?? '#f59e0b'}
onChange={(e) => setCustomAccent(e.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
aria-label="Eigene Akzentfarbe"
/>
</label>
</div>
{customAccent && (
<div className="mt-3 flex items-center gap-2">
<Input
value={customAccent}
onChange={(e) => setCustomAccent(e.target.value)}
placeholder="#aabbcc"
className="w-32 font-mono"
aria-label="Hex-Wert der Akzentfarbe"
/>
<button
onClick={() => setAccent('amber')}
className="text-sm text-text-muted hover:text-text"
>
Zurücksetzen
</button>
</div>
)}
</Card>
<Card title="Standard-Geschwindigkeit">
<div className="flex flex-wrap gap-2">
{PLAYBACK_SPEEDS.map((s) => (
<button
key={s}
onClick={() => setDefaultSpeed(s)}
className={cn(
'tnum rounded border px-3 py-1.5 text-sm transition-colors',
defaultSpeed === s
? 'border-accent bg-accent-soft text-text'
: 'border-border text-text-muted hover:bg-surface-2',
)}
>
{s}×
</button>
))}
</div>
</Card>
<Card title="Server & Konto">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 text-sm">
<Server size={18} className="shrink-0 text-text-muted" />
<div className="min-w-0">
<p className="truncate text-text">{serverUrl ?? '—'}</p>
<p className="text-text-muted">
Angemeldet als <span className="text-text">{user?.username ?? '—'}</span>
{user?.type ? ` · ${user.type}` : ''}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{!isManaged && (
<Button variant="subtle" onClick={() => navigate('/setup')}>
<Server size={16} /> Server wechseln
</Button>
)}
<Button variant="danger" onClick={onLogout}>
<LogOut size={16} /> Abmelden
</Button>
</div>
</div>
</Card>
</div>
</>
)
}

137
src/pages/Setup.tsx Normal file
View File

@@ -0,0 +1,137 @@
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Headphones, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Checkbox } from '@/components/ui/Checkbox'
import { login } from '@/api/auth'
import { apiErrorMessage } from '@/api/client'
import { useAuthStore } from '@/store/authStore'
import { normalizeServerUrl } from '@/lib/url'
import { isManaged, effectiveServerUrl } from '@/lib/backend'
/** First-run setup: connect to an ABS server and log in. */
export default function Setup() {
const navigate = useNavigate()
const location = useLocation()
const setSession = useAuthStore((s) => s.setSession)
const existingUrl = useAuthStore((s) => s.serverUrl)
const [serverUrl, setServerUrl] = useState(existingUrl ?? '')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPw, setShowPw] = useState(false)
const [remember, setRemember] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const redirectTo = (location.state as { from?: string } | null)?.from ?? '/'
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
// In managed (production) mode the server is fixed by the deployment.
let url: string
if (isManaged) {
url = effectiveServerUrl('')
} else {
const normalized = normalizeServerUrl(serverUrl)
if (!normalized) {
setError('Bitte eine gültige http(s)-Server-URL eingeben.')
return
}
url = normalized
}
if (!username || !password) {
setError('Benutzername und Passwort erforderlich.')
return
}
setLoading(true)
try {
const res = await login(url, username, password)
if (!res?.user?.token) throw new Error('no-token')
setSession(url, res.user, remember)
navigate(redirectTo, { replace: true })
} catch (err) {
setError(apiErrorMessage(err, 'Anmeldung fehlgeschlagen.'))
} finally {
setLoading(false)
}
}
return (
<div className="grid min-h-dvh place-items-center bg-bg px-5 py-10">
<div className="w-full max-w-sm animate-fade-in">
<div className="mb-8 flex flex-col items-center text-center">
<div className="mb-3 grid h-12 w-12 place-items-center rounded-xl bg-accent-soft text-accent">
<Headphones size={24} />
</div>
<h1 className="font-heading text-3xl font-semibold tracking-tight">Shelfless</h1>
<p className="mt-1 text-sm text-text-muted">
{isManaged ? 'Mit deinem Konto anmelden' : 'Mit deinem Audiobookshelf-Server verbinden'}
</p>
</div>
<form
onSubmit={onSubmit}
className="flex flex-col gap-4 rounded-lg border border-border bg-surface p-6"
>
{!isManaged && (
<Input
label="Server-URL"
placeholder="http://192.168.1.10:13378"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
autoComplete="url"
inputMode="url"
autoFocus
/>
)}
<Input
label="Benutzername"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus={isManaged}
/>
<div className="relative">
<Input
label="Passwort"
type={showPw ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPw((v) => !v)}
aria-label={showPw ? 'Passwort verbergen' : 'Passwort anzeigen'}
className="absolute right-2 top-[34px] grid h-8 w-8 place-items-center rounded text-text-muted hover:text-text"
>
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<Checkbox label="Angemeldet bleiben" checked={remember} onChange={setRemember} />
{error && (
<p
role="alert"
className="rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</p>
)}
<Button type="submit" loading={loading} className="mt-1 w-full">
{isManaged ? 'Anmelden' : 'Verbinden'}
</Button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,153 @@
import { useState } from 'react'
import { Plus, Pencil, Trash2, ScanLine, Folder, BookOpen, Headphones } from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/States'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { Spinner } from '@/components/ui/Spinner'
import { LibraryModal } from '@/components/admin/LibraryModal'
import { getLibraries, deleteLibrary, scanLibrary } from '@/api/libraries'
import { apiErrorMessage } from '@/api/client'
import { toast } from '@/store/toastStore'
import { useLibraryStore } from '@/store/libraryStore'
import type { Library } from '@/types/abs'
export default function AdminLibraries() {
const libraries = useLibraryStore((s) => s.libraries)
const setLibraries = useLibraryStore((s) => s.setLibraries)
const [modal, setModal] = useState<{ open: boolean; library: Library | null }>({ open: false, library: null })
const [toDelete, setToDelete] = useState<Library | null>(null)
const [scanning, setScanning] = useState<Set<string>>(new Set())
async function reload() {
try {
setLibraries(await getLibraries())
} catch {
/* keep current */
}
}
async function startScan(lib: Library, force: boolean) {
setScanning((s) => new Set(s).add(lib.id))
try {
await scanLibrary(lib.id, force)
toast.success(`Scan gestartet: ${lib.name}`)
} catch (err) {
toast.error(apiErrorMessage(err, 'Scan konnte nicht gestartet werden.'))
} finally {
// Live progress requires the ABS socket; clear the indicator after a moment.
setTimeout(() => {
setScanning((s) => {
const next = new Set(s)
next.delete(lib.id)
return next
})
}, 4000)
}
}
async function confirmDelete() {
if (!toDelete) return
try {
await deleteLibrary(toDelete.id)
toast.success('Bibliothek gelöscht.')
await reload()
} catch (err) {
toast.error(apiErrorMessage(err, 'Löschen fehlgeschlagen.'))
} finally {
setToDelete(null)
}
}
return (
<>
<PageHeader
title="Bibliotheken"
subtitle="Bibliotheks-Verwaltung"
actions={
<Button onClick={() => setModal({ open: true, library: null })}>
<Plus size={16} /> Bibliothek
</Button>
}
/>
{libraries.length === 0 ? (
<EmptyState title="Keine Bibliotheken" hint="Lege eine an, um zu starten." />
) : (
<div className="grid gap-3">
{libraries.map((lib) => (
<div
key={lib.id}
className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4 sm:flex-row sm:items-center"
>
<div className="grid h-11 w-11 shrink-0 place-items-center rounded bg-accent-soft text-accent">
{lib.mediaType === 'podcast' ? <Headphones size={20} /> : <BookOpen size={20} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-text">{lib.name}</p>
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-muted">
{lib.mediaType === 'podcast' ? 'Podcasts' : 'Hörbücher'}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-muted">
{lib.folders.map((f) => (
<span key={f.id} className="inline-flex items-center gap-1">
<Folder size={12} /> {f.fullPath}
</span>
))}
</div>
</div>
<div className="flex shrink-0 gap-1">
<Button
variant="subtle"
size="sm"
onClick={() => startScan(lib, false)}
disabled={scanning.has(lib.id)}
>
{scanning.has(lib.id) ? <Spinner size={14} /> : <ScanLine size={15} />} Scan
</Button>
<button
onClick={() => setModal({ open: true, library: lib })}
aria-label="Bearbeiten"
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-surface-2 hover:text-text"
>
<Pencil size={15} />
</button>
<button
onClick={() => setToDelete(lib)}
aria-label="Löschen"
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 size={15} />
</button>
</div>
</div>
))}
</div>
)}
<LibraryModal
open={modal.open}
library={modal.library}
onClose={() => setModal({ open: false, library: null })}
onSaved={reload}
/>
<ConfirmDialog
open={!!toDelete}
title="Bibliothek löschen?"
danger
confirmLabel="Löschen"
message={
<>
<strong className="text-text">{toDelete?.name}</strong> und alle zugehörigen Items +
Fortschritte werden entfernt. Diese Aktion kann nicht rückgängig gemacht werden.
</>
}
onConfirm={confirmDelete}
onCancel={() => setToDelete(null)}
/>
</>
)
}

View File

@@ -0,0 +1,100 @@
import { useState } from 'react'
import { ScanLine, RefreshCw, FileSearch } from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { EmptyState } from '@/components/ui/States'
import { scanLibrary } from '@/api/libraries'
import { apiErrorMessage } from '@/api/client'
import { toast } from '@/store/toastStore'
import { useLibraryStore } from '@/store/libraryStore'
interface LogEntry {
time: string
text: string
}
export default function AdminScan() {
const libraries = useLibraryStore((s) => s.libraries)
const [libraryId, setLibraryId] = useState(libraries[0]?.id ?? '')
const [busy, setBusy] = useState(false)
const [log, setLog] = useState<LogEntry[]>([])
function addLog(text: string) {
setLog((l) => [{ time: new Date().toLocaleTimeString('de-DE'), text }, ...l].slice(0, 30))
}
async function runScan(force: boolean) {
const lib = libraries.find((l) => l.id === libraryId)
if (!lib) return
setBusy(true)
try {
await scanLibrary(lib.id, force)
const kind = force ? 'Vollständiger Scan' : 'Scan (nur neue Dateien)'
addLog(`${kind} gestartet: ${lib.name}`)
toast.success('Scan gestartet.')
} catch (err) {
const msg = apiErrorMessage(err, 'Scan konnte nicht gestartet werden.')
addLog(`Fehler: ${msg}`)
toast.error(msg)
} finally {
setBusy(false)
}
}
if (libraries.length === 0) {
return (
<>
<PageHeader title="Scanner" subtitle="Bibliotheken scannen" />
<EmptyState title="Keine Bibliotheken" hint="Lege zuerst eine Bibliothek an." />
</>
)
}
return (
<>
<PageHeader title="Scanner" subtitle="Bibliotheken scannen" />
<div className="grid max-w-2xl gap-5">
<div className="rounded-lg border border-border bg-surface p-5">
<Select
label="Bibliothek"
value={libraryId}
onChange={(e) => setLibraryId(e.target.value)}
options={libraries.map((l) => ({ value: l.id, label: l.name }))}
/>
<div className="mt-4 flex flex-wrap gap-2">
<Button onClick={() => runScan(false)} loading={busy}>
<ScanLine size={16} /> Nur neue Dateien
</Button>
<Button variant="subtle" onClick={() => runScan(true)} disabled={busy}>
<RefreshCw size={16} /> Vollständiger Scan
</Button>
</div>
<p className="mt-3 text-xs text-text-muted">
Der Live-Fortschritt eines laufenden Scans wird von ABS über Websocket
bereitgestellt; hier werden Scans angestoßen und protokolliert.
</p>
</div>
<div className="rounded-lg border border-border bg-surface p-5">
<h2 className="mb-3 flex items-center gap-2 font-heading text-lg font-semibold">
<FileSearch size={18} /> Protokoll
</h2>
{log.length === 0 ? (
<p className="text-sm text-text-muted">Noch keine Scans in dieser Sitzung.</p>
) : (
<ul className="space-y-1.5">
{log.map((e, i) => (
<li key={i} className="flex gap-3 text-sm">
<span className="tnum shrink-0 text-text-muted">{e.time}</span>
<span className="text-text">{e.text}</span>
</li>
))}
</ul>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react'
import { Save, DatabaseBackup, Plus, Upload, Archive } from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Checkbox } from '@/components/ui/Checkbox'
import { ErrorState } from '@/components/ui/States'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { formatBytes, formatDate } from '@/lib/format'
import {
getServerSettings,
updateServerSettings,
getBackups,
createBackup,
applyBackup,
} from '@/api/server'
import { apiErrorMessage } from '@/api/client'
import { toast } from '@/store/toastStore'
import type { BackupInfo } from '@/types/abs'
type Primitive = string | number | boolean
function isEditable(v: unknown): v is Primitive {
return typeof v === 'boolean' || typeof v === 'string' || typeof v === 'number'
}
// Friendlier labels for common ABS server settings; others fall back to the raw key.
const labels: Record<string, string> = {
scannerFindCovers: 'Cover automatisch suchen',
scannerPreferMatchedMetadata: 'Gematchte Metadaten bevorzugen',
storeCoverWithItem: 'Cover beim Item speichern',
storeMetadataWithItem: 'Metadaten beim Item speichern',
metadataFileFormat: 'Metadaten-Dateiformat',
backupSchedule: 'Backup-Zeitplan (Cron)',
backupsToKeep: 'Backups behalten',
language: 'Sprache',
}
export default function AdminSettings() {
const [settings, setSettings] = useState<Record<string, Primitive>>({})
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [backups, setBackups] = useState<BackupInfo[]>([])
const [creatingBackup, setCreatingBackup] = useState(false)
const [toApply, setToApply] = useState<BackupInfo | null>(null)
function loadSettings() {
setStatus('loading')
getServerSettings()
.then((raw) => {
const primitives: Record<string, Primitive> = {}
for (const [k, v] of Object.entries(raw)) if (isEditable(v)) primitives[k] = v
setSettings(primitives)
setStatus('ready')
})
.catch((err) => {
setError(apiErrorMessage(err, 'Einstellungen konnten nicht geladen werden.'))
setStatus('error')
})
}
function loadBackups() {
getBackups().then(setBackups).catch(() => setBackups([]))
}
useEffect(() => {
loadSettings()
loadBackups()
}, [])
function setField(key: string, value: Primitive) {
setSettings((s) => ({ ...s, [key]: value }))
}
async function save() {
setSaving(true)
try {
await updateServerSettings(settings)
toast.success('Einstellungen gespeichert.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Speichern fehlgeschlagen (Endpoint ggf. abweichend).'))
} finally {
setSaving(false)
}
}
async function doCreateBackup() {
setCreatingBackup(true)
try {
const list = await createBackup()
setBackups(list)
toast.success('Backup erstellt.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Backup fehlgeschlagen.'))
} finally {
setCreatingBackup(false)
}
}
async function doApply() {
if (!toApply) return
try {
await applyBackup(toApply.id)
toast.success('Backup wird eingespielt. Server startet ggf. neu.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Einspielen fehlgeschlagen.'))
} finally {
setToApply(null)
}
}
const entries = Object.entries(settings)
return (
<>
<PageHeader title="Server" subtitle="Server-Einstellungen & Backups" />
<div className="grid max-w-2xl gap-5">
{/* Settings */}
<section className="rounded-lg border border-border bg-surface p-5">
<h2 className="mb-4 font-heading text-lg font-semibold">Einstellungen</h2>
{status === 'error' ? (
<ErrorState message={error} onRetry={loadSettings} />
) : status === 'loading' ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="skeleton h-9 rounded" />
))}
</div>
) : entries.length === 0 ? (
<p className="text-sm text-text-muted">Keine editierbaren Einstellungen gefunden.</p>
) : (
<div className="space-y-3">
{entries.map(([key, value]) =>
typeof value === 'boolean' ? (
<Checkbox
key={key}
label={labels[key] ?? key}
checked={value}
onChange={(v) => setField(key, v)}
/>
) : (
<Input
key={key}
label={labels[key] ?? key}
value={String(value)}
type={typeof value === 'number' ? 'number' : 'text'}
onChange={(e) =>
setField(key, typeof value === 'number' ? Number(e.target.value) : e.target.value)
}
/>
),
)}
<Button onClick={save} loading={saving} className="mt-1">
<Save size={16} /> Speichern
</Button>
</div>
)}
</section>
{/* Backups */}
<section className="rounded-lg border border-border bg-surface p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 font-heading text-lg font-semibold">
<DatabaseBackup size={18} /> Backups
</h2>
<Button size="sm" onClick={doCreateBackup} loading={creatingBackup}>
<Plus size={15} /> Erstellen
</Button>
</div>
{backups.length === 0 ? (
<p className="text-sm text-text-muted">Keine Backups vorhanden.</p>
) : (
<div className="overflow-hidden rounded-lg border border-border">
{backups.map((b) => (
<div
key={b.id}
className="flex items-center gap-3 border-b border-border px-3 py-2.5 last:border-b-0"
>
<Archive size={16} className="shrink-0 text-text-muted" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-text">{b.fileName}</p>
<p className="tnum text-xs text-text-muted">
{formatDate(b.createdAt)} · {formatBytes(b.size)}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setToApply(b)}>
<Upload size={15} /> Einspielen
</Button>
</div>
))}
</div>
)}
</section>
</div>
<ConfirmDialog
open={!!toApply}
title="Backup einspielen?"
danger
confirmLabel="Einspielen"
message="Der aktuelle Stand wird durch das Backup ersetzt. Der Server startet möglicherweise neu."
onConfirm={doApply}
onCancel={() => setToApply(null)}
/>
</>
)
}

View File

@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react'
import { UserPlus, Pencil, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/ui/PageHeader'
import { Button } from '@/components/ui/Button'
import { ErrorState, EmptyState } from '@/components/ui/States'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { UserModal } from '@/components/admin/UserModal'
import { cn } from '@/lib/cn'
import { formatDate } from '@/lib/format'
import { getUsers, deleteUser } from '@/api/users'
import { apiErrorMessage } from '@/api/client'
import { toast } from '@/store/toastStore'
import { useLibraryStore } from '@/store/libraryStore'
import { useAuthStore } from '@/store/authStore'
import type { AbsUser } from '@/types/abs'
const typeLabel: Record<string, string> = {
root: 'Root',
admin: 'Admin',
user: 'Benutzer',
guest: 'Gast',
}
export default function AdminUsers() {
const libraries = useLibraryStore((s) => s.libraries)
const me = useAuthStore((s) => s.user)
const [users, setUsers] = useState<AbsUser[]>([])
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [error, setError] = useState('')
const [modal, setModal] = useState<{ open: boolean; user: AbsUser | null }>({ open: false, user: null })
const [toDelete, setToDelete] = useState<AbsUser | null>(null)
function load() {
setStatus('loading')
getUsers()
.then((u) => {
setUsers(u)
setStatus('ready')
})
.catch((err) => {
setError(apiErrorMessage(err, 'Benutzer konnten nicht geladen werden.'))
setStatus('error')
})
}
useEffect(load, [])
async function confirmDelete() {
if (!toDelete) return
try {
await deleteUser(toDelete.id)
setUsers((prev) => prev.filter((u) => u.id !== toDelete.id))
toast.success('Benutzer gelöscht.')
} catch (err) {
toast.error(apiErrorMessage(err, 'Löschen fehlgeschlagen.'))
} finally {
setToDelete(null)
}
}
return (
<>
<PageHeader
title="Benutzer"
subtitle="User-Verwaltung"
actions={
<Button onClick={() => setModal({ open: true, user: null })}>
<UserPlus size={16} /> Benutzer
</Button>
}
/>
{status === 'error' ? (
<ErrorState message={error} onRetry={load} />
) : status === 'loading' ? (
<TableSkeleton />
) : users.length === 0 ? (
<EmptyState title="Keine Benutzer" />
) : (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-text-muted">
<th className="px-4 py-3 font-medium">Benutzer</th>
<th className="px-4 py-3 font-medium">Typ</th>
<th className="hidden px-4 py-3 font-medium sm:table-cell">E-Mail</th>
<th className="hidden px-4 py-3 font-medium md:table-cell">Letzter Login</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-border last:border-b-0 hover:bg-surface-2/50">
<td className="px-4 py-3 font-medium text-text">
{u.username}
{u.id === me?.id && <span className="ml-2 text-xs text-text-muted">(du)</span>}
</td>
<td className="px-4 py-3 text-text-muted">{typeLabel[u.type] ?? u.type}</td>
<td className="hidden px-4 py-3 text-text-muted sm:table-cell">{u.email || '—'}</td>
<td className="hidden px-4 py-3 text-text-muted md:table-cell tnum">{formatDate(u.lastSeen ?? undefined)}</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-block rounded-full px-2 py-0.5 text-xs',
u.isActive ? 'bg-success/15 text-success' : 'bg-surface-2 text-text-muted',
)}
>
{u.isActive ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<button
onClick={() => setModal({ open: true, user: u })}
aria-label="Bearbeiten"
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-surface-2 hover:text-text"
>
<Pencil size={15} />
</button>
{u.type !== 'root' && u.id !== me?.id && (
<button
onClick={() => setToDelete(u)}
aria-label="Löschen"
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 size={15} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<UserModal
open={modal.open}
user={modal.user}
libraries={libraries}
onClose={() => setModal({ open: false, user: null })}
onSaved={load}
/>
<ConfirmDialog
open={!!toDelete}
title="Benutzer löschen?"
danger
confirmLabel="Löschen"
message={
<>
<strong className="text-text">{toDelete?.username}</strong> wird dauerhaft gelöscht.
</>
}
onConfirm={confirmDelete}
onCancel={() => setToDelete(null)}
/>
</>
)
}
function TableSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton h-12 rounded" />
))}
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useIsAdmin } from '@/store/authStore'
/** Admin-only gate. Non-admins are redirected home. */
export function RequireAdmin() {
const isAdmin = useIsAdmin()
if (!isAdmin) return <Navigate to="/" replace />
return <Outlet />
}

View File

@@ -0,0 +1,53 @@
import { useEffect } from 'react'
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { authorize } from '@/api/auth'
import { Spinner } from '@/components/ui/Spinner'
/**
* Gate for all in-app routes. On first load it validates a persisted token via
* `POST /api/authorize`; while that runs it shows a loader. No token / failed check →
* redirect to /setup (remembering where the user was headed).
*/
export function RequireAuth() {
const location = useLocation()
const { token, status, setStatus, setUser, logout } = useAuthStore()
useEffect(() => {
if (!token) {
setStatus('unauthenticated')
return
}
if (status !== 'idle') return
let cancelled = false
setStatus('authenticating')
authorize()
.then((res) => {
if (cancelled) return
setUser(res.user)
setStatus('authenticated')
})
.catch(() => {
if (!cancelled) logout()
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
if (token && (status === 'idle' || status === 'authenticating')) {
return (
<div className="grid min-h-dvh place-items-center bg-bg text-text-muted">
<Spinner size={28} />
</div>
)
}
if (!token || status === 'unauthenticated') {
return <Navigate to="/setup" replace state={{ from: location.pathname + location.search }} />
}
return <Outlet />
}

68
src/store/authStore.ts Normal file
View File

@@ -0,0 +1,68 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { AbsUser, UserPermissions } from '@/types/abs'
import { isAdminUser } from '@/types/abs'
export type AuthStatus =
| 'idle' // not yet checked on this page load
| 'authenticating' // validating a stored token
| 'authenticated'
| 'unauthenticated'
interface AuthState {
serverUrl: string | null
token: string | null
user: AbsUser | null
status: AuthStatus
/** When true, the session is persisted across reloads ("stay signed in"). */
remember: boolean
setSession: (serverUrl: string, user: AbsUser, remember: boolean) => void
setUser: (user: AbsUser) => void
setStatus: (status: AuthStatus) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
serverUrl: null,
token: null,
user: null,
status: 'idle',
remember: false,
setSession: (serverUrl, user, remember) =>
set({ serverUrl, user, token: user.token, status: 'authenticated', remember }),
setUser: (user) => set({ user }),
setStatus: (status) => set({ status }),
logout: () => set({ token: null, user: null, status: 'unauthenticated' }),
}),
{
name: 'shelfless.auth',
// Always remember the server + the "remember" choice. Only persist the token/user
// (i.e. stay signed in) when the user opted in — otherwise re-login each session.
partialize: (s) =>
s.remember
? { serverUrl: s.serverUrl, token: s.token, user: s.user, remember: true }
: { serverUrl: s.serverUrl, remember: false },
},
),
)
// ── Convenience selectors ────────────────────────────────────────────────
export const useIsAdmin = () => useAuthStore((s) => isAdminUser(s.user))
export const usePermissions = (): UserPermissions | undefined =>
useAuthStore((s) => s.user?.permissions)
/**
* Whether the current user may perform an action. Admins/root implicitly have every
* permission (ABS doesn't always set the individual flags for them).
*/
export const useCan = (key: keyof UserPermissions) =>
useAuthStore((s) => isAdminUser(s.user) || !!s.user?.permissions?.[key])
/** Non-hook accessors for use outside React (e.g. axios interceptors). */
export const authState = {
get: () => useAuthStore.getState(),
}

24
src/store/libraryStore.ts Normal file
View File

@@ -0,0 +1,24 @@
import { create } from 'zustand'
import type { Library } from '@/types/abs'
interface LibraryState {
libraries: Library[]
loaded: boolean
loading: boolean
error: string | null
setLibraries: (libraries: Library[]) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
reset: () => void
}
export const useLibraryStore = create<LibraryState>((set) => ({
libraries: [],
loaded: false,
loading: false,
error: null,
setLibraries: (libraries) => set({ libraries, loaded: true, loading: false, error: null }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error, loading: false }),
reset: () => set({ libraries: [], loaded: false, loading: false, error: null }),
}))

153
src/store/playerStore.ts Normal file
View File

@@ -0,0 +1,153 @@
import { create } from 'zustand'
import type { AudioTrack, Chapter, LibraryItem, PlaybackSession } from '@/types/abs'
import { playItem, closeSession } from '@/api/sessions'
import { getTitle, getAuthor } from '@/lib/media'
import { useSettingsStore } from './settingsStore'
import { apiErrorMessage } from '@/api/client'
import { toast } from './toastStore'
interface PlayerState {
// What's loaded
item: LibraryItem | null
episodeId: string | null
title: string
author: string
coverItemId: string | null
// Session / audio model
session: PlaybackSession | null
audioTracks: AudioTrack[]
chapters: Chapter[]
duration: number // total seconds across all tracks
// Live playback state (kept in sync by the Phase 6 audio engine)
position: number // global seconds
isPlaying: boolean
speed: number
volume: number
loading: boolean
// Intent signal the audio engine consumes then clears
seekTo: number | null
// Actions
play: (item: LibraryItem, episodeId?: string | null, startTime?: number) => Promise<void>
togglePlay: () => void
setPlaying: (playing: boolean) => void
seek: (seconds: number) => void
clearSeek: () => void
skip: (delta: number) => void
setPosition: (seconds: number) => void
setSpeed: (speed: number) => void
setVolume: (volume: number) => void
stop: () => Promise<void>
}
export const usePlayerStore = create<PlayerState>((set, get) => ({
item: null,
episodeId: null,
title: '',
author: '',
coverItemId: null,
session: null,
audioTracks: [],
chapters: [],
duration: 0,
position: 0,
isPlaying: false,
speed: useSettingsStore.getState().defaultSpeed || 1,
volume: 1,
loading: false,
seekTo: null,
play: async (item, episodeId = null, startTime) => {
// Close any existing session first.
const prev = get().session
if (prev) closeSession(prev.id).catch(() => undefined)
set({
item,
episodeId,
title: getTitle(item),
author: getAuthor(item),
coverItemId: item.id,
loading: true,
isPlaying: false,
session: null,
audioTracks: [],
chapters: [],
position: 0,
duration: 0,
})
try {
const session = await playItem(item.id, episodeId ?? undefined)
const tracks = session.audioTracks ?? []
const duration =
session.duration ||
tracks.reduce((sum, t) => Math.max(sum, t.startOffset + t.duration), 0)
const start = startTime ?? session.currentTime ?? 0
set({
session,
audioTracks: tracks,
chapters: session.chapters ?? [],
duration,
position: start,
seekTo: start,
loading: false,
isPlaying: true,
speed: useSettingsStore.getState().defaultSpeed || get().speed,
})
} catch (err) {
set({ loading: false, item: null })
toast.error(apiErrorMessage(err, 'Wiedergabe konnte nicht gestartet werden.'))
}
},
togglePlay: () => set((s) => ({ isPlaying: !s.isPlaying })),
setPlaying: (isPlaying) => set({ isPlaying }),
seek: (seconds) => {
const dur = get().duration
const clamped = Math.max(0, Math.min(seconds, dur || seconds))
set({ seekTo: clamped, position: clamped })
},
clearSeek: () => set({ seekTo: null }),
skip: (delta) => {
const { position, duration } = get()
const target = Math.max(0, Math.min(position + delta, duration || position + delta))
set({ seekTo: target, position: target })
},
setPosition: (seconds) => set({ position: seconds }),
setSpeed: (speed) => set({ speed }),
setVolume: (volume) => set({ volume }),
stop: async () => {
const { session, position, duration } = get()
if (session) {
closeSession(session.id, {
currentTime: position,
timeListened: 0,
duration,
}).catch(() => undefined)
}
set({
item: null,
episodeId: null,
session: null,
audioTracks: [],
chapters: [],
isPlaying: false,
position: 0,
duration: 0,
seekTo: null,
})
},
}))
/** Current chapter for a global position, if chapters exist. */
export function currentChapter(chapters: Chapter[], position: number): Chapter | null {
for (const c of chapters) {
if (position >= c.start && position < c.end) return c
}
return chapters.length ? chapters[chapters.length - 1] : null
}

View File

@@ -0,0 +1,43 @@
import { create } from 'zustand'
import type { MediaProgress } from '@/types/abs'
import { getMyProgress } from '@/api/progress'
/** Key a progress record: episodes are tracked separately from their parent item. */
export function progressKey(itemId: string, episodeId?: string | null): string {
return episodeId ? `${itemId}:${episodeId}` : itemId
}
interface ProgressState {
byKey: Record<string, MediaProgress>
loaded: boolean
load: () => Promise<void>
get: (itemId: string, episodeId?: string | null) => MediaProgress | undefined
upsert: (p: MediaProgress) => void
remove: (itemId: string, episodeId?: string | null) => void
}
export const useProgressStore = create<ProgressState>((set, get) => ({
byKey: {},
loaded: false,
load: async () => {
const list = await getMyProgress()
const byKey: Record<string, MediaProgress> = {}
for (const p of list) byKey[progressKey(p.libraryItemId, p.episodeId)] = p
set({ byKey, loaded: true })
},
get: (itemId, episodeId) => get().byKey[progressKey(itemId, episodeId)],
upsert: (p) =>
set((s) => ({
byKey: { ...s.byKey, [progressKey(p.libraryItemId, p.episodeId)]: p },
})),
remove: (itemId, episodeId) =>
set((s) => {
const next = { ...s.byKey }
delete next[progressKey(itemId, episodeId)]
return { byKey: next }
}),
}))

View File

@@ -0,0 +1,93 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type AccentName = 'amber' | 'teal' | 'coral' | 'green' | 'blue'
export const ACCENTS: { name: AccentName; label: string; swatch: string }[] = [
{ name: 'amber', label: 'Amber', swatch: '#f59e0b' },
{ name: 'teal', label: 'Türkis', swatch: '#14b8a6' },
{ name: 'coral', label: 'Koralle', swatch: '#fb7185' },
{ name: 'green', label: 'Grün', swatch: '#22c55e' },
{ name: 'blue', label: 'Blau', swatch: '#3b82f6' },
]
export const PLAYBACK_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3] as const
interface SettingsState {
accent: AccentName
/** When set, a freely chosen hex color overrides the named preset. */
customAccent: string | null
defaultSpeed: number
sidebarCollapsed: boolean
setAccent: (accent: AccentName) => void
setCustomAccent: (hex: string) => void
setDefaultSpeed: (speed: number) => void
toggleSidebar: () => void
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
accent: 'amber',
customAccent: null,
defaultSpeed: 1,
sidebarCollapsed: false,
setAccent: (accent) => {
applyTheme(accent, null)
set({ accent, customAccent: null })
},
setCustomAccent: (hex) => {
applyTheme('amber', hex)
set({ customAccent: hex })
},
setDefaultSpeed: (defaultSpeed) => set({ defaultSpeed }),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
}),
{
name: 'shelfless.settings',
onRehydrateStorage: () => (state) => {
if (state) applyTheme(state.accent, state.customAccent)
},
},
),
)
// ── Theme application ─────────────────────────────────────────────────────
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
let h = hex.trim().replace(/^#/, '')
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
}
}
/** Pick readable foreground (near-black or near-white) for a given accent color. */
function onAccentFor(r: number, g: number, b: number): string {
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.6 ? '#0c0a09' : '#fafaf9'
}
/**
* Reflect the chosen accent onto the document. A named preset uses the
* `[data-accent]` CSS rules; a custom hex sets inline CSS variables (which win).
*/
export function applyTheme(accent: AccentName, customAccent: string | null) {
if (typeof document === 'undefined') return
const root = document.documentElement
const rgb = customAccent ? hexToRgb(customAccent) : null
if (customAccent && rgb) {
root.style.setProperty('--accent', customAccent)
root.style.setProperty('--accent-soft', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.14)`)
root.style.setProperty('--on-accent', onAccentFor(rgb.r, rgb.g, rgb.b))
root.setAttribute('data-accent', 'custom')
} else {
root.style.removeProperty('--accent')
root.style.removeProperty('--accent-soft')
root.style.removeProperty('--on-accent')
root.setAttribute('data-accent', accent)
}
}

37
src/store/toastStore.ts Normal file
View File

@@ -0,0 +1,37 @@
import { create } from 'zustand'
export type ToastKind = 'success' | 'error' | 'info'
export interface Toast {
id: number
kind: ToastKind
message: string
}
interface ToastState {
toasts: Toast[]
push: (kind: ToastKind, message: string) => void
dismiss: (id: number) => void
}
let counter = 0
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
push: (kind, message) => {
const id = ++counter
set((s) => ({ toasts: [...s.toasts, { id, kind, message }] }))
// auto-dismiss after 4s
setTimeout(() => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }))
}, 4000)
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}))
/** Convenience helpers usable outside React. */
export const toast = {
success: (m: string) => useToastStore.getState().push('success', m),
error: (m: string) => useToastStore.getState().push('error', m),
info: (m: string) => useToastStore.getState().push('info', m),
}

334
src/types/abs.ts Normal file
View File

@@ -0,0 +1,334 @@
/**
* Audiobookshelf API types. Only fields Shelfless uses are typed; unknown extras are
* tolerated. See memory: abs-api-corrections.
*
* ABS returns two shapes for media:
* - "minified" (library item lists): metadata uses flattened strings
* (authorName, narratorName, seriesName).
* - "expanded" (single item with ?expanded=1): metadata uses arrays of objects
* (authors[], narrators[], series[]).
* We model both with optional fields and read defensively via helpers in lib/media.
*/
// ── Auth ──────────────────────────────────────────────────────────────────
export type UserType = 'root' | 'admin' | 'user' | 'guest'
export interface UserPermissions {
download: boolean
update: boolean
delete: boolean
upload: boolean
accessAllLibraries: boolean
accessAllTags: boolean
accessExplicitContent: boolean
[key: string]: boolean | undefined
}
export interface AbsUser {
id: string
username: string
email?: string | null
type: UserType
token: string
isActive?: boolean
isLocked?: boolean
lastSeen?: number | null
createdAt?: number
permissions: UserPermissions
librariesAccessible?: string[]
itemTagsAccessible?: string[]
}
export interface LoginResponse {
user: AbsUser
userDefaultLibraryId?: string
serverSettings?: Record<string, unknown>
source?: string
}
export interface AuthorizeResponse {
user: AbsUser
userDefaultLibraryId?: string
}
export function isAdminUser(user: Pick<AbsUser, 'type'> | null | undefined): boolean {
return user?.type === 'root' || user?.type === 'admin'
}
// ── Libraries ───────────────────────────────────────────────────────────────
export type MediaType = 'book' | 'podcast'
export interface LibraryFolder {
id: string
fullPath: string
libraryId?: string
addedAt?: number
}
export interface LibrarySettings {
coverAspectRatio?: number
disableWatcher?: boolean
autoScanCronExpression?: string | null
[key: string]: unknown
}
export interface Library {
id: string
name: string
folders: LibraryFolder[]
displayOrder: number
icon: string
mediaType: MediaType
provider: string
settings?: LibrarySettings
createdAt: number
lastUpdate: number
}
export interface LibrariesResponse {
libraries: Library[]
}
export interface LibraryStats {
totalItems?: number
totalAuthors?: number
totalGenres?: number
totalDuration?: number
totalSize?: number
numAudioTracks?: number
[key: string]: unknown
}
// ── Metadata / Media ─────────────────────────────────────────────────────────
export interface AuthorRef {
id?: string
name: string
}
export interface SeriesRef {
id?: string
name: string
sequence?: string | null
}
export interface BookMetadata {
title: string | null
titleIgnorePrefix?: string
subtitle?: string | null
// expanded
authors?: AuthorRef[]
narrators?: string[]
series?: SeriesRef[]
// minified
authorName?: string
narratorName?: string
seriesName?: string
genres?: string[]
publishedYear?: string | null
publishedDate?: string | null
publisher?: string | null
description?: string | null
isbn?: string | null
asin?: string | null
language?: string | null
explicit?: boolean
}
export interface Chapter {
id: number
start: number
end: number
title: string
}
export interface AudioFileMeta {
filename: string
ext: string
path: string
relPath?: string
size: number
}
export interface AudioFile {
index: number
ino: string
metadata: AudioFileMeta
duration: number
bitRate?: number
codec?: string
channels?: number
mimeType?: string
}
export interface Book {
id?: string
libraryItemId?: string
metadata: BookMetadata
coverPath: string | null
tags?: string[]
audioFiles?: AudioFile[]
chapters?: Chapter[]
duration?: number
size?: number
numTracks?: number
numChapters?: number
numAudioFiles?: number
ebookFormat?: string | null
}
export interface PodcastMetadata {
title: string | null
author?: string | null
description?: string | null
releaseDate?: string | null
genres?: string[]
feedUrl?: string | null
imageUrl?: string | null
itunesId?: string | null
itunesArtistId?: string | null
language?: string | null
explicit?: boolean
type?: string | null
}
export interface PodcastEpisode {
id: string
index?: number
episode?: string | null
season?: string | null
title: string
subtitle?: string | null
description?: string | null
pubDate?: string | null
publishedAt?: number | null
audioFile?: AudioFile
duration?: number
size?: number
}
export interface Podcast {
id?: string
libraryItemId?: string
metadata: PodcastMetadata
coverPath: string | null
tags?: string[]
episodes?: PodcastEpisode[]
numEpisodes?: number
autoDownloadEpisodes?: boolean
}
export type Media = Book | Podcast
// ── Library items ─────────────────────────────────────────────────────────
export interface LibraryFile {
ino: string
metadata: AudioFileMeta
fileType?: string
addedAt?: number
updatedAt?: number
}
export interface LibraryItem {
id: string
ino?: string
libraryId: string
folderId?: string
path?: string
relPath?: string
isFile?: boolean
mtimeMs?: number
ctimeMs?: number
birthtimeMs?: number
addedAt: number
updatedAt: number
isMissing?: boolean
isInvalid?: boolean
mediaType: MediaType
media: Media
libraryFiles?: LibraryFile[]
numFiles?: number
size?: number
// present on some list/expanded responses
collapsedSeries?: unknown
recentEpisode?: PodcastEpisode
progress?: MediaProgress
}
export interface LibraryItemsResponse {
results: LibraryItem[]
total: number
limit: number
page: number
sortBy?: string
sortDesc?: boolean
mediaType?: MediaType
minified?: boolean
}
export interface LibrarySearchResult {
book?: { libraryItem: LibraryItem; matchKey?: string; matchText?: string }[]
podcast?: { libraryItem: LibraryItem }[]
authors?: { id: string; name: string }[]
series?: { series: SeriesRef; books: LibraryItem[] }[]
tags?: string[]
}
// ── Progress & sessions ─────────────────────────────────────────────────────
export interface MediaProgress {
id: string
libraryItemId: string
episodeId?: string | null
duration: number
progress: number // 0..1
currentTime: number
isFinished: boolean
hideFromContinueListening?: boolean
lastUpdate?: number
startedAt?: number
finishedAt?: number | null
}
export interface ItemsInProgressResponse {
libraryItems: LibraryItem[]
}
export interface AudioTrack {
index: number
startOffset: number
duration: number
title?: string
contentUrl: string
mimeType: string
codec?: string
metadata?: AudioFileMeta
}
export interface PlaybackSession {
id: string
userId: string
libraryId?: string
libraryItemId: string
episodeId?: string | null
mediaType: MediaType
displayTitle?: string
displayAuthor?: string
coverPath?: string | null
duration: number
playMethod?: number
mediaPlayer?: string
audioTracks: AudioTrack[]
chapters?: Chapter[]
currentTime: number
startedAt?: number
updatedAt?: number
}
// ── Server / admin ──────────────────────────────────────────────────────────
export interface BackupInfo {
id: string
backupMetadataCovers?: boolean
fileName: string
path?: string
serverVersion?: string
createdAt: number
size?: number
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

53
tailwind.config.js Normal file
View File

@@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
bg: 'var(--bg)',
surface: 'var(--surface)',
'surface-2': 'var(--surface-2)',
border: 'var(--border)',
text: 'var(--text)',
'text-muted': 'var(--text-muted)',
accent: 'var(--accent)',
'accent-soft': 'var(--accent-soft)',
'on-accent': 'var(--on-accent)',
destructive: 'var(--destructive)',
success: 'var(--success)',
},
fontFamily: {
heading: ['"Fraunces Variable"', 'Fraunces', 'ui-serif', 'Georgia', 'serif'],
sans: ['"Hanken Grotesk Variable"', '"Hanken Grotesk"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
borderRadius: {
DEFAULT: 'var(--radius)',
lg: 'calc(var(--radius) + 4px)',
xl: 'calc(var(--radius) + 8px)',
},
boxShadow: {
card: '0 1px 2px rgba(0,0,0,0.4), 0 8px 24px -12px rgba(0,0,0,0.6)',
lift: '0 12px 32px -8px rgba(0,0,0,0.7)',
bar: '0 -8px 24px -12px rgba(0,0,0,0.8)',
},
keyframes: {
'slide-up': {
from: { transform: 'translateY(100%)', opacity: '0' },
to: { transform: 'translateY(0)', opacity: '1' },
},
'fade-in': {
from: { opacity: '0' },
to: { opacity: '1' },
},
shimmer: {
'100%': { transform: 'translateX(100%)' },
},
},
animation: {
'slide-up': 'slide-up 280ms cubic-bezier(0.16, 1, 0.3, 1)',
'fade-in': 'fade-in 200ms ease-out',
},
},
},
plugins: [],
}

27
tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

1
tsconfig.app.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/items.ts","./src/api/libraries.ts","./src/api/metadata.ts","./src/api/podcasts.ts","./src/api/progress.ts","./src/api/server.ts","./src/api/sessions.ts","./src/api/users.ts","./src/components/contextmenu.tsx","./src/components/mediacard.tsx","./src/components/mediagrid.tsx","./src/components/medialistrow.tsx","./src/components/mediarow.tsx","./src/components/admin/librarymodal.tsx","./src/components/admin/usermodal.tsx","./src/components/detail/chapterlist.tsx","./src/components/detail/covertab.tsx","./src/components/detail/episodelist.tsx","./src/components/detail/filestab.tsx","./src/components/detail/metadatatab.tsx","./src/components/layout/applayout.tsx","./src/components/layout/bottomnav.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/player/playerbar.tsx","./src/components/player/useaudioengine.ts","./src/components/ui/button.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/input.tsx","./src/components/ui/modal.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/placeholder.tsx","./src/components/ui/select.tsx","./src/components/ui/spinner.tsx","./src/components/ui/states.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toaster.tsx","./src/hooks/useclickoutside.ts","./src/hooks/usedebounce.ts","./src/lib/backend.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/html.ts","./src/lib/media.ts","./src/lib/url.ts","./src/pages/home.tsx","./src/pages/itemdetail.tsx","./src/pages/library.tsx","./src/pages/settings.tsx","./src/pages/setup.tsx","./src/pages/admin/adminlibraries.tsx","./src/pages/admin/adminscan.tsx","./src/pages/admin/adminsettings.tsx","./src/pages/admin/adminusers.tsx","./src/routes/requireadmin.tsx","./src/routes/requireauth.tsx","./src/store/authstore.ts","./src/store/librarystore.ts","./src/store/playerstore.ts","./src/store/progressstore.ts","./src/store/settingsstore.ts","./src/store/toaststore.ts","./src/types/abs.ts"],"version":"5.9.3"}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

20
tsconfig.node.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}

73
vite.config.ts Normal file
View File

@@ -0,0 +1,73 @@
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'node:path'
import http from 'node:http'
import https from 'node:https'
/**
* Dev-only dynamic proxy. The browser can't call an arbitrary ABS server directly
* (it sends no CORS headers), so in dev the client routes every ABS request through
* `/__abs/<url-encoded-server>/<path>`. This middleware decodes the target from the
* path and forwards the request server-side — no CORS, and the target can change at
* runtime (just log in to a different server). Production needs a real reverse proxy.
*/
function dynamicAbsProxy(): Plugin {
return {
name: 'shelfless-dynamic-abs-proxy',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/__abs/')) return next()
const m = req.url.match(/^\/__abs\/([^/]+)(.*)$/)
if (!m) {
res.statusCode = 400
return res.end('Bad proxy path')
}
let target: URL
try {
target = new URL(decodeURIComponent(m[1]))
} catch {
res.statusCode = 400
return res.end('Bad proxy target')
}
const isHttps = target.protocol === 'https:'
const lib = isHttps ? https : http
const headers = { ...req.headers, host: target.host }
delete headers.origin
delete headers.referer
const proxyReq = lib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: m[2] || '/',
headers,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers)
proxyRes.pipe(res)
},
)
proxyReq.on('error', (e) => {
res.statusCode = 502
res.end(`Proxy error: ${e.message}`)
})
req.pipe(proxyReq)
})
},
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), dynamicAbsProxy()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
},
})