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:
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"6c7f673e-5c18-4d34-9e6e-9a12d7dafcf4","pid":12940,"procStart":"639159816679139320","acquiredAt":1780378787616}
|
||||
6
.claude/settings.json
Normal file
6
.claude/settings.json
Normal 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
3
.env.example
Normal 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
28
.gitignore
vendored
Normal 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
44
README.md
Normal 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
60
deploy/README.md
Normal 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
75
deploy/install.sh
Normal 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
24
deploy/shelfless.service
Normal 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
88
design-system/MASTER.md
Normal 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.5–1.6.
|
||||
- Weight hierarchy: headings 500–700, 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 ~10–12px 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 150–300ms; enter ease-out, exit ~60–70% 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 1–2 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 40–60% 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
14
index.html
Normal 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
3121
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
9
public/favicon.svg
Normal file
9
public/favicon.svg
Normal 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
116
server/index.mjs
Normal 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
40
src/App.tsx
Normal 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
28
src/api/auth.ts
Normal 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
52
src/api/client.ts
Normal 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
59
src/api/items.ts
Normal 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
123
src/api/libraries.ts
Normal 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
38
src/api/metadata.ts
Normal 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
25
src/api/podcasts.ts
Normal 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
56
src/api/progress.ts
Normal 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
36
src/api/server.ts
Normal 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
54
src/api/sessions.ts
Normal 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
41
src/api/users.ts
Normal 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}`)
|
||||
}
|
||||
82
src/components/ContextMenu.tsx
Normal file
82
src/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export interface MenuAction {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
onSelect: () => void
|
||||
danger?: boolean
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
x: number
|
||||
y: number
|
||||
actions: MenuAction[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Cursor-positioned action menu (right-click / long-press), clamped to the viewport. */
|
||||
export function ContextMenu({ x, y, actions, onClose }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ x, y })
|
||||
|
||||
useEffect(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
window.addEventListener('scroll', onClose, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onClose, true)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const { width, height } = el.getBoundingClientRect()
|
||||
setPos({
|
||||
x: Math.min(x, window.innerWidth - width - 8),
|
||||
y: Math.min(y, window.innerHeight - height - 8),
|
||||
})
|
||||
}, [x, y])
|
||||
|
||||
const visible = actions.filter((a) => !a.hidden)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ top: pos.y, left: pos.x }}
|
||||
className="fixed z-50 min-w-[190px] overflow-hidden rounded-lg border border-border bg-surface py-1 shadow-lift animate-fade-in"
|
||||
role="menu"
|
||||
>
|
||||
{visible.map((a, i) => (
|
||||
<button
|
||||
key={i}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
a.onSelect()
|
||||
onClose()
|
||||
}}
|
||||
className={
|
||||
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors ' +
|
||||
(a.danger
|
||||
? 'text-destructive hover:bg-destructive/10'
|
||||
: 'text-text hover:bg-surface-2')
|
||||
}
|
||||
>
|
||||
{a.icon && <span className="shrink-0 text-text-muted">{a.icon}</span>}
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
96
src/components/MediaCard.tsx
Normal file
96
src/components/MediaCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { BookOpen, Headphones, Check, Pencil } from 'lucide-react'
|
||||
import { coverUrl, getAuthor, getTitle } from '@/lib/media'
|
||||
import { useProgressStore } from '@/store/progressStore'
|
||||
import { useCan } from '@/store/authStore'
|
||||
import type { LibraryItem } from '@/types/abs'
|
||||
|
||||
interface Props {
|
||||
item: LibraryItem
|
||||
onContextMenu?: (e: React.MouseEvent, item: LibraryItem) => void
|
||||
}
|
||||
|
||||
export function MediaCard({ item, onContextMenu }: Props) {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const prog = useProgressStore((s) => s.byKey[item.id])
|
||||
const canEdit = useCan('update')
|
||||
const navigate = useNavigate()
|
||||
const isPodcast = item.mediaType === 'podcast'
|
||||
|
||||
const pct = prog && !prog.isFinished ? Math.round(prog.progress * 100) : 0
|
||||
const finished = prog?.isFinished ?? false
|
||||
const title = getTitle(item)
|
||||
const author = getAuthor(item)
|
||||
|
||||
function editMetadata(e: React.MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate(`/item/${item.id}?tab=metadata`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/item/${item.id}`}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(e, item) : undefined}
|
||||
className="group block focus:outline-none"
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg border border-border bg-surface-2 shadow-card transition-transform duration-200 ease-out group-hover:-translate-y-1 group-hover:scale-[1.03] group-hover:shadow-lift group-focus-visible:ring-2 group-focus-visible:ring-accent">
|
||||
{!imgError ? (
|
||||
<img
|
||||
src={coverUrl(item.id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setImgError(true)}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-3 text-center text-text-muted">
|
||||
{isPodcast ? <Headphones size={28} /> : <BookOpen size={28} />}
|
||||
<span className="line-clamp-3 text-xs">{title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick-edit (visible on hover) → straight to the metadata tab */}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={editMetadata}
|
||||
aria-label="Metadaten bearbeiten"
|
||||
className="absolute left-2 top-2 grid h-8 w-8 place-items-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur transition-opacity hover:bg-black/75 focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{finished && (
|
||||
<div className="absolute right-2 top-2 grid h-7 w-7 place-items-center rounded-full bg-success text-black shadow">
|
||||
<Check size={16} strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pct > 0 && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 px-0.5">
|
||||
<p className="line-clamp-2 text-sm font-medium leading-snug text-text">{title}</p>
|
||||
{author && <p className="mt-0.5 line-clamp-1 text-xs text-text-muted">{author}</p>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function MediaCardSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<div className="skeleton aspect-square rounded-lg" />
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<div className="skeleton h-3.5 w-4/5 rounded" />
|
||||
<div className="skeleton h-3 w-2/5 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/components/MediaGrid.tsx
Normal file
34
src/components/MediaGrid.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { MediaCard, MediaCardSkeleton } from './MediaCard'
|
||||
import type { LibraryItem } from '@/types/abs'
|
||||
|
||||
interface Props {
|
||||
items: LibraryItem[]
|
||||
onContextMenu?: (e: React.MouseEvent, item: LibraryItem) => void
|
||||
}
|
||||
|
||||
const GRID_STYLE: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: '1.25rem',
|
||||
}
|
||||
|
||||
/** Responsive auto-fill grid of media cards (Library view). */
|
||||
export function MediaGrid({ items, onContextMenu }: Props) {
|
||||
return (
|
||||
<div style={GRID_STYLE}>
|
||||
{items.map((item) => (
|
||||
<MediaCard key={item.id} item={item} onContextMenu={onContextMenu} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MediaGridSkeleton({ count = 18 }: { count?: number }) {
|
||||
return (
|
||||
<div style={GRID_STYLE}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<MediaCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/components/MediaListRow.tsx
Normal file
68
src/components/MediaListRow.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BookOpen, Headphones, Check } from 'lucide-react'
|
||||
import { coverUrl, getAuthor, getTitle, getDuration } from '@/lib/media'
|
||||
import { formatDuration } from '@/lib/format'
|
||||
import { useProgressStore } from '@/store/progressStore'
|
||||
import type { LibraryItem } from '@/types/abs'
|
||||
|
||||
interface Props {
|
||||
item: LibraryItem
|
||||
onContextMenu?: (e: React.MouseEvent, item: LibraryItem) => void
|
||||
}
|
||||
|
||||
export function MediaListRow({ item, onContextMenu }: Props) {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const prog = useProgressStore((s) => s.byKey[item.id])
|
||||
const isPodcast = item.mediaType === 'podcast'
|
||||
|
||||
const pct = prog && !prog.isFinished ? Math.round(prog.progress * 100) : 0
|
||||
const finished = prog?.isFinished ?? false
|
||||
const duration = getDuration(item)
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/item/${item.id}`}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(e, item) : undefined}
|
||||
className="group flex items-center gap-3 bg-surface px-3 py-2 transition-colors hover:bg-surface-2 focus:outline-none focus-visible:bg-surface-2"
|
||||
>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded bg-surface-2">
|
||||
{!imgError ? (
|
||||
<img
|
||||
src={coverUrl(item.id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setImgError(true)}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-text-muted">
|
||||
{isPodcast ? <Headphones size={18} /> : <BookOpen size={18} />}
|
||||
</div>
|
||||
)}
|
||||
{pct > 0 && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-text">{getTitle(item)}</p>
|
||||
<p className="truncate text-xs text-text-muted">{getAuthor(item)}</p>
|
||||
</div>
|
||||
|
||||
{finished && (
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-success text-black">
|
||||
<Check size={14} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isPodcast && duration > 0 && (
|
||||
<span className="tnum hidden shrink-0 text-xs text-text-muted sm:block">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
51
src/components/MediaRow.tsx
Normal file
51
src/components/MediaRow.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { MediaCard, MediaCardSkeleton } from './MediaCard'
|
||||
import type { LibraryItem } from '@/types/abs'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
items: LibraryItem[]
|
||||
loading?: boolean
|
||||
to?: string
|
||||
emptyNote?: string
|
||||
}
|
||||
|
||||
/** Horizontally scrollable row of media cards (Home page). */
|
||||
export function MediaRow({ title, items, loading, to, emptyNote }: Props) {
|
||||
if (!loading && items.length === 0 && !emptyNote) return null
|
||||
|
||||
return (
|
||||
<section className="mb-9">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2 className="font-heading text-xl font-semibold tracking-tight">{title}</h2>
|
||||
{to && (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex items-center gap-0.5 text-sm text-text-muted transition-colors hover:text-text"
|
||||
>
|
||||
Alle <ChevronRight size={15} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && items.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{emptyNote}</p>
|
||||
) : (
|
||||
<div className="-mx-1 flex snap-x gap-4 overflow-x-auto px-1 pb-2 [scrollbar-width:thin]">
|
||||
{loading
|
||||
? Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="w-[140px] shrink-0 snap-start sm:w-[160px]">
|
||||
<MediaCardSkeleton />
|
||||
</div>
|
||||
))
|
||||
: items.map((item) => (
|
||||
<div key={item.id} className="w-[140px] shrink-0 snap-start sm:w-[160px]">
|
||||
<MediaCard item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
136
src/components/admin/LibraryModal.tsx
Normal file
136
src/components/admin/LibraryModal.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, X, Folder } from 'lucide-react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { createLibrary, updateLibrary } from '@/api/libraries'
|
||||
import { apiErrorMessage } from '@/api/client'
|
||||
import { toast } from '@/store/toastStore'
|
||||
import type { Library, MediaType } from '@/types/abs'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
library: Library | null // null = create
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
export function LibraryModal({ open, library, onClose, onSaved }: Props) {
|
||||
const isEdit = !!library
|
||||
const [name, setName] = useState(library?.name ?? '')
|
||||
const [mediaType, setMediaType] = useState<MediaType>(library?.mediaType ?? 'book')
|
||||
const [folders, setFolders] = useState<string[]>(library?.folders.map((f) => f.fullPath) ?? [])
|
||||
const [folderInput, setFolderInput] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function addFolder() {
|
||||
const p = folderInput.trim()
|
||||
if (p && !folders.includes(p)) {
|
||||
setFolders((f) => [...f, p])
|
||||
setFolderInput('')
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) {
|
||||
toast.error('Name erforderlich.')
|
||||
return
|
||||
}
|
||||
if (folders.length === 0) {
|
||||
toast.error('Mindestens ein Ordner erforderlich.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = { name: name.trim(), folders: folders.map((fullPath) => ({ fullPath })) }
|
||||
if (isEdit && library) {
|
||||
await updateLibrary(library.id, payload)
|
||||
toast.success('Bibliothek aktualisiert.')
|
||||
} else {
|
||||
await createLibrary({ ...payload, mediaType })
|
||||
toast.success('Bibliothek erstellt.')
|
||||
}
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, 'Speichern fehlgeschlagen.'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? 'Bibliothek bearbeiten' : 'Bibliothek erstellen'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={save} loading={saving}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
|
||||
{!isEdit && (
|
||||
<Select
|
||||
label="Typ"
|
||||
value={mediaType}
|
||||
onChange={(e) => setMediaType(e.target.value as MediaType)}
|
||||
options={[
|
||||
{ value: 'book', label: 'Hörbücher' },
|
||||
{ value: 'podcast', label: 'Podcasts' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-sm font-medium text-text">Ordner</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="/audiobooks"
|
||||
value={folderInput}
|
||||
onChange={(e) => setFolderInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addFolder()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button variant="subtle" onClick={addFolder} disabled={!folderInput.trim()}>
|
||||
<Plus size={16} /> Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
{folders.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{folders.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 rounded border border-border bg-surface-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<Folder size={15} className="shrink-0 text-text-muted" />
|
||||
<span className="flex-1 truncate">{f}</span>
|
||||
<button
|
||||
onClick={() => setFolders((cur) => cur.filter((x) => x !== f))}
|
||||
aria-label="Ordner entfernen"
|
||||
className="text-text-muted hover:text-destructive"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
177
src/components/admin/UserModal.tsx
Normal file
177
src/components/admin/UserModal.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { createUser, updateUser } from '@/api/users'
|
||||
import { apiErrorMessage } from '@/api/client'
|
||||
import { toast } from '@/store/toastStore'
|
||||
import type { AbsUser, Library, UserPermissions, UserType } from '@/types/abs'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
user: AbsUser | null // null = create
|
||||
libraries: Library[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const PERMISSION_FIELDS: { key: keyof UserPermissions; label: string }[] = [
|
||||
{ key: 'download', label: 'Download' },
|
||||
{ key: 'update', label: 'Bearbeiten' },
|
||||
{ key: 'delete', label: 'Löschen' },
|
||||
{ key: 'upload', label: 'Hochladen' },
|
||||
{ key: 'accessExplicitContent', label: 'Explizite Inhalte' },
|
||||
]
|
||||
|
||||
const defaultPerms: UserPermissions = {
|
||||
download: true,
|
||||
update: false,
|
||||
delete: false,
|
||||
upload: false,
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
accessExplicitContent: false,
|
||||
}
|
||||
|
||||
export function UserModal({ open, user, libraries, onClose, onSaved }: Props) {
|
||||
const isEdit = !!user
|
||||
const isRoot = user?.type === 'root'
|
||||
|
||||
const [username, setUsername] = useState(user?.username ?? '')
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState(user?.email ?? '')
|
||||
const [type, setType] = useState<UserType>(user?.type ?? 'user')
|
||||
const [isActive, setIsActive] = useState(user?.isActive ?? true)
|
||||
const [perms, setPerms] = useState<UserPermissions>({ ...defaultPerms, ...user?.permissions })
|
||||
const [libAccess, setLibAccess] = useState<string[]>(user?.librariesAccessible ?? [])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function setPerm(key: keyof UserPermissions, value: boolean) {
|
||||
setPerms((p) => ({ ...p, [key]: value }))
|
||||
}
|
||||
function toggleLib(id: string) {
|
||||
setLibAccess((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!username.trim()) {
|
||||
toast.error('Benutzername erforderlich.')
|
||||
return
|
||||
}
|
||||
if (!isEdit && !password) {
|
||||
toast.error('Passwort erforderlich.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
username: username.trim(),
|
||||
email: email || null,
|
||||
type,
|
||||
isActive,
|
||||
permissions: perms,
|
||||
librariesAccessible: perms.accessAllLibraries ? [] : libAccess,
|
||||
}
|
||||
if (isEdit && user) {
|
||||
await updateUser(user.id, { ...payload, ...(password ? { password } : {}) })
|
||||
toast.success('Benutzer aktualisiert.')
|
||||
} else {
|
||||
await createUser({ ...payload, password })
|
||||
toast.success('Benutzer erstellt.')
|
||||
}
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, 'Speichern fehlgeschlagen.'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? 'Benutzer bearbeiten' : 'Benutzer erstellen'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={save} loading={saving}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input label="Benutzername" value={username} onChange={(e) => setUsername(e.target.value)} disabled={isRoot} />
|
||||
<Input
|
||||
label={isEdit ? 'Neues Passwort (leer = unverändert)' : 'Passwort'}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="E-Mail" type="email" value={email ?? ''} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Select
|
||||
label="Typ"
|
||||
value={type}
|
||||
disabled={isRoot}
|
||||
onChange={(e) => setType(e.target.value as UserType)}
|
||||
options={
|
||||
isRoot
|
||||
? [{ value: 'root', label: 'Root' }]
|
||||
: [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'Benutzer' },
|
||||
{ value: 'guest', label: 'Gast' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Checkbox label="Konto aktiv" checked={isActive} onChange={setIsActive} disabled={isRoot} />
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-text">Berechtigungen</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PERMISSION_FIELDS.map((f) => (
|
||||
<Checkbox
|
||||
key={f.key}
|
||||
label={f.label}
|
||||
checked={!!perms[f.key]}
|
||||
onChange={(v) => setPerm(f.key, v)}
|
||||
disabled={isRoot}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
label="Zugriff auf alle Bibliotheken"
|
||||
checked={!!perms.accessAllLibraries}
|
||||
onChange={(v) => setPerm('accessAllLibraries', v)}
|
||||
disabled={isRoot}
|
||||
/>
|
||||
{!perms.accessAllLibraries && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 rounded border border-border p-3">
|
||||
{libraries.map((lib) => (
|
||||
<Checkbox
|
||||
key={lib.id}
|
||||
label={lib.name}
|
||||
checked={libAccess.includes(lib.id)}
|
||||
onChange={() => toggleLib(lib.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
39
src/components/detail/ChapterList.tsx
Normal file
39
src/components/detail/ChapterList.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Play } from 'lucide-react'
|
||||
import { formatTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { Chapter } from '@/types/abs'
|
||||
|
||||
interface Props {
|
||||
chapters: Chapter[]
|
||||
activeStart?: number
|
||||
onJump: (start: number) => void
|
||||
}
|
||||
|
||||
export function ChapterList({ chapters, activeStart, onJump }: Props) {
|
||||
if (!chapters.length) return null
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{chapters.map((c) => {
|
||||
const active = activeStart != null && activeStart === c.start
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onJump(c.start)}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-3 border-b border-border px-3 py-2.5 text-left text-sm last:border-b-0 transition-colors hover:bg-surface-2',
|
||||
active && 'bg-accent-soft',
|
||||
)}
|
||||
>
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-surface-2 text-text-muted group-hover:bg-accent group-hover:text-on-accent">
|
||||
<Play size={13} />
|
||||
</span>
|
||||
<span className={cn('flex-1 truncate', active ? 'text-text' : 'text-text')}>
|
||||
{c.title}
|
||||
</span>
|
||||
<span className="tnum shrink-0 text-xs text-text-muted">{formatTime(c.start)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
203
src/components/detail/CoverTab.tsx
Normal file
203
src/components/detail/CoverTab.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Upload, Link2, Search, Trash2, ImageOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { coverUrl, getAuthor, getTitle } from '@/lib/media'
|
||||
import { isSafeHttpUrl } from '@/lib/url'
|
||||
import { uploadCover, setCoverByUrl, removeCover } from '@/api/items'
|
||||
import { searchCovers } from '@/api/metadata'
|
||||
import { apiErrorMessage } from '@/api/client'
|
||||
import { toast } from '@/store/toastStore'
|
||||
import { useCan } from '@/store/authStore'
|
||||
import type { LibraryItem } from '@/types/abs'
|
||||
|
||||
export function CoverTab({ item }: { item: LibraryItem }) {
|
||||
const canEdit = useCan('upload')
|
||||
const [ts, setTs] = useState(() => Date.now())
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [urlValue, setUrlValue] = useState('')
|
||||
const [results, setResults] = useState<string[] | null>(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function refresh() {
|
||||
setTs(Date.now())
|
||||
}
|
||||
|
||||
async function doUpload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Bitte eine Bilddatei wählen.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await uploadCover(item.id, file)
|
||||
refresh()
|
||||
toast.success('Cover hochgeladen.')
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, 'Upload fehlgeschlagen.'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUrl(url: string) {
|
||||
if (!isSafeHttpUrl(url)) {
|
||||
toast.error('Ungültige Bild-URL (nur http/https).')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await setCoverByUrl(item.id, url)
|
||||
refresh()
|
||||
toast.success('Cover gesetzt.')
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, 'Cover konnte nicht gesetzt werden.'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemove() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await removeCover(item.id)
|
||||
refresh()
|
||||
toast.success('Cover entfernt.')
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, 'Entfernen fehlgeschlagen.'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
setSearching(true)
|
||||
try {
|
||||
const res = await searchCovers({ title: getTitle(item), author: getAuthor(item) })
|
||||
setResults(res)
|
||||
if (res.length === 0) toast.info('Keine Cover gefunden.')
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, 'Cover-Suche fehlgeschlagen.'))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 md:grid-cols-[240px_1fr]">
|
||||
{/* Current cover + dropzone */}
|
||||
<div>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
if (!canEdit) return
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
if (!canEdit) return
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) doUpload(file)
|
||||
}}
|
||||
className={cn(
|
||||
'relative aspect-square overflow-hidden rounded-lg border bg-surface-2',
|
||||
dragOver ? 'border-accent ring-2 ring-accent' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={coverUrl(item.id, { ts })}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = 'hidden')}
|
||||
/>
|
||||
{busy && (
|
||||
<div className="absolute inset-0 grid place-items-center bg-black/50">
|
||||
<Spinner size={26} />
|
||||
</div>
|
||||
)}
|
||||
{canEdit && dragOver && (
|
||||
<div className="absolute inset-0 grid place-items-center bg-black/60 text-sm text-text">
|
||||
Datei ablegen …
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{canEdit ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Hochladen</h3>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) doUpload(f)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="subtle" onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||
<Upload size={16} /> Datei wählen
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={doRemove} disabled={busy}>
|
||||
<Trash2 size={16} /> Cover entfernen
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-text-muted">Oder eine Datei auf das Cover ziehen.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Per URL</h3>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="https://…/cover.jpg"
|
||||
value={urlValue}
|
||||
onChange={(e) => setUrlValue(e.target.value)}
|
||||
/>
|
||||
<Button variant="subtle" onClick={() => applyUrl(urlValue)} disabled={busy || !urlValue}>
|
||||
<Link2 size={16} /> Setzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text">Automatische Suche</h3>
|
||||
<Button variant="ghost" size="sm" onClick={runSearch} loading={searching}>
|
||||
<Search size={15} /> Suchen
|
||||
</Button>
|
||||
</div>
|
||||
{results && results.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
|
||||
{results.map((url) => (
|
||||
<button
|
||||
key={url}
|
||||
onClick={() => applyUrl(url)}
|
||||
disabled={busy}
|
||||
className="group overflow-hidden rounded border border-border transition-colors hover:border-accent"
|
||||
>
|
||||
<img src={url} alt="" loading="lazy" className="aspect-square w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-text-muted">
|
||||
<ImageOff size={18} /> Keine Berechtigung zum Ändern des Covers.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
src/components/detail/EpisodeList.tsx
Normal file
94
src/components/detail/EpisodeList.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Play, Check } from 'lucide-react'
|
||||
import { formatDuration } from '@/lib/format'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useProgressStore, progressKey } from '@/store/progressStore'
|
||||
import { usePlayerStore } from '@/store/playerStore'
|
||||
import { htmlToText } from '@/lib/html'
|
||||
import type { LibraryItem, Podcast, PodcastEpisode } from '@/types/abs'
|
||||
|
||||
interface Props {
|
||||
item: LibraryItem
|
||||
}
|
||||
|
||||
function pubLabel(ep: PodcastEpisode): string {
|
||||
if (ep.publishedAt) {
|
||||
try {
|
||||
return new Date(ep.publishedAt).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return ep.pubDate ?? ''
|
||||
}
|
||||
|
||||
export function EpisodeList({ item }: Props) {
|
||||
const episodes = (item.media as Podcast).episodes ?? []
|
||||
const byKey = useProgressStore((s) => s.byKey)
|
||||
const play = usePlayerStore((s) => s.play)
|
||||
const currentEpisodeId = usePlayerStore((s) => s.episodeId)
|
||||
|
||||
if (!episodes.length) {
|
||||
return <p className="text-sm text-text-muted">Keine Episoden vorhanden.</p>
|
||||
}
|
||||
|
||||
// newest first
|
||||
const sorted = [...episodes].sort((a, b) => (b.publishedAt ?? 0) - (a.publishedAt ?? 0))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{sorted.map((ep) => {
|
||||
const prog = byKey[progressKey(item.id, ep.id)]
|
||||
const pct = prog && !prog.isFinished ? Math.round(prog.progress * 100) : 0
|
||||
const finished = prog?.isFinished ?? false
|
||||
const isCurrent = currentEpisodeId === ep.id
|
||||
const duration = ep.duration ?? ep.audioFile?.duration ?? 0
|
||||
const startTime = prog && !prog.isFinished ? prog.currentTime : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
className={cn('flex items-start gap-3 bg-surface px-3 py-3', isCurrent && 'bg-surface-2')}
|
||||
>
|
||||
<button
|
||||
onClick={() => play(item, ep.id, startTime)}
|
||||
aria-label="Episode abspielen"
|
||||
className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-full bg-surface-2 text-text transition-colors hover:bg-accent hover:text-on-accent"
|
||||
>
|
||||
<Play size={16} />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="flex-1 truncate text-sm font-medium text-text">{ep.title}</p>
|
||||
{finished && <Check size={15} className="shrink-0 text-success" />}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-text-muted">
|
||||
{pubLabel(ep) && <span>{pubLabel(ep)}</span>}
|
||||
{duration > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="tnum">{formatDuration(duration)}</span>
|
||||
</>
|
||||
)}
|
||||
{pct > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="tnum text-accent">{pct}%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{ep.description && (
|
||||
<p className="mt-1.5 line-clamp-2 text-xs text-text-muted">
|
||||
{htmlToText(ep.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
src/components/detail/FilesTab.tsx
Normal file
40
src/components/detail/FilesTab.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FileAudio } from 'lucide-react'
|
||||
import { formatBytes, formatDuration } from '@/lib/format'
|
||||
import type { AudioFile, Book, LibraryItem, Podcast } from '@/types/abs'
|
||||
|
||||
function collectAudioFiles(item: LibraryItem): AudioFile[] {
|
||||
if (item.mediaType === 'book') return (item.media as Book).audioFiles ?? []
|
||||
const eps = (item.media as Podcast).episodes ?? []
|
||||
return eps.map((e) => e.audioFile).filter((f): f is AudioFile => !!f)
|
||||
}
|
||||
|
||||
export function FilesTab({ item }: { item: LibraryItem }) {
|
||||
const files = collectAudioFiles(item)
|
||||
|
||||
if (files.length === 0) {
|
||||
return <p className="text-sm text-text-muted">Keine Audiodateien vorhanden.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{files.map((f, i) => (
|
||||
<div
|
||||
key={`${f.ino}-${i}`}
|
||||
className="flex items-center gap-3 border-b border-border px-3 py-2.5 last:border-b-0"
|
||||
>
|
||||
<FileAudio size={18} className="shrink-0 text-text-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-text">{f.metadata.filename}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{f.codec?.toUpperCase()} {f.bitRate ? `· ${Math.round(f.bitRate / 1000)} kbps` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="tnum shrink-0 text-xs text-text-muted">{formatDuration(f.duration)}</span>
|
||||
<span className="tnum hidden shrink-0 text-xs text-text-muted sm:block">
|
||||
{formatBytes(f.metadata.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
202
src/components/detail/MetadataTab.tsx
Normal file
202
src/components/detail/MetadataTab.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react'
|
||||
import { Save } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { updateMedia } from '@/api/items'
|
||||
import { apiErrorMessage } from '@/api/client'
|
||||
import { toast } from '@/store/toastStore'
|
||||
import { useCan } from '@/store/authStore'
|
||||
import { htmlToText } from '@/lib/html'
|
||||
import type { Book, BookMetadata, LibraryItem, Podcast, PodcastMetadata } from '@/types/abs'
|
||||
|
||||
interface Props {
|
||||
item: LibraryItem
|
||||
onUpdated: (item: LibraryItem) => void
|
||||
}
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string }) {
|
||||
const { label, className, ...rest } = props
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-text">{label}</span>
|
||||
<textarea
|
||||
className={cn(
|
||||
'min-h-[120px] w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text placeholder:text-text-muted/60 focus:border-accent focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetadataTab({ item, onUpdated }: Props) {
|
||||
const canEdit = useCan('update')
|
||||
|
||||
if (item.mediaType === 'podcast') {
|
||||
return <PodcastForm item={item} onUpdated={onUpdated} canEdit={canEdit} />
|
||||
}
|
||||
return <BookForm item={item} onUpdated={onUpdated} canEdit={canEdit} />
|
||||
}
|
||||
|
||||
function BookForm({ item, onUpdated, canEdit }: Props & { canEdit: boolean }) {
|
||||
const m = (item.media as Book).metadata
|
||||
const [form, setForm] = useState({
|
||||
title: m.title ?? '',
|
||||
subtitle: m.subtitle ?? '',
|
||||
authors: m.authorName ?? (m.authors?.map((a) => a.name).join(', ') ?? ''),
|
||||
narrators: m.narratorName ?? (m.narrators?.join(', ') ?? ''),
|
||||
seriesName: m.series?.[0]?.name ?? m.seriesName ?? '',
|
||||
seriesSeq: m.series?.[0]?.sequence ?? '',
|
||||
genres: m.genres?.join(', ') ?? '',
|
||||
publishedYear: m.publishedYear ?? '',
|
||||
publisher: m.publisher ?? '',
|
||||
isbn: m.isbn ?? '',
|
||||
asin: m.asin ?? '',
|
||||
language: m.language ?? '',
|
||||
description: htmlToText(m.description),
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function set<K extends keyof typeof form>(k: K, v: string) {
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const metadata: Partial<BookMetadata> = {
|
||||
title: form.title,
|
||||
subtitle: form.subtitle || null,
|
||||
authors: splitList(form.authors).map((name) => ({ name })),
|
||||
narrators: splitList(form.narrators),
|
||||
series: form.seriesName
|
||||
? [{ name: form.seriesName, sequence: form.seriesSeq || null }]
|
||||
: [],
|
||||
genres: splitList(form.genres),
|
||||
publishedYear: form.publishedYear || null,
|
||||
publisher: form.publisher || null,
|
||||
isbn: form.isbn || null,
|
||||
asin: form.asin || null,
|
||||
language: form.language || null,
|
||||
description: form.description || null,
|
||||
}
|
||||
const updated = await updateMedia(item.id, { metadata })
|
||||
onUpdated(updated)
|
||||
toast.success('Metadaten gespeichert.')
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, 'Speichern fehlgeschlagen.'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return <ReadOnlyMeta item={item} />
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<Input label="Titel" value={form.title} onChange={(e) => set('title', e.target.value)} />
|
||||
<Input label="Untertitel" value={form.subtitle} onChange={(e) => set('subtitle', e.target.value)} />
|
||||
<Input label="Autor(en)" hint="Komma-getrennt" value={form.authors} onChange={(e) => set('authors', e.target.value)} />
|
||||
<Input label="Sprecher" hint="Komma-getrennt" value={form.narrators} onChange={(e) => set('narrators', e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Reihe" value={form.seriesName} onChange={(e) => set('seriesName', e.target.value)} />
|
||||
<Input label="Reihennummer" value={form.seriesSeq} onChange={(e) => set('seriesSeq', e.target.value)} />
|
||||
</div>
|
||||
<Input label="Genres" hint="Komma-getrennt" value={form.genres} onChange={(e) => set('genres', e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Jahr" value={form.publishedYear} onChange={(e) => set('publishedYear', e.target.value)} />
|
||||
<Input label="Verlag" value={form.publisher} onChange={(e) => set('publisher', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input label="ISBN" value={form.isbn} onChange={(e) => set('isbn', e.target.value)} />
|
||||
<Input label="ASIN" value={form.asin} onChange={(e) => set('asin', e.target.value)} />
|
||||
<Input label="Sprache" value={form.language} onChange={(e) => set('language', e.target.value)} />
|
||||
</div>
|
||||
<Textarea label="Beschreibung" value={form.description} onChange={(e) => set('description', e.target.value)} />
|
||||
<Button onClick={save} loading={saving}>
|
||||
<Save size={16} /> Speichern
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PodcastForm({ item, onUpdated, canEdit }: Props & { canEdit: boolean }) {
|
||||
const m = (item.media as Podcast).metadata
|
||||
const [form, setForm] = useState({
|
||||
title: m.title ?? '',
|
||||
author: m.author ?? '',
|
||||
genres: m.genres?.join(', ') ?? '',
|
||||
language: m.language ?? '',
|
||||
feedUrl: m.feedUrl ?? '',
|
||||
description: htmlToText(m.description),
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function set<K extends keyof typeof form>(k: K, v: string) {
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const metadata: Partial<PodcastMetadata> = {
|
||||
title: form.title,
|
||||
author: form.author || null,
|
||||
genres: splitList(form.genres),
|
||||
language: form.language || null,
|
||||
description: form.description || null,
|
||||
}
|
||||
const updated = await updateMedia(item.id, { metadata })
|
||||
onUpdated(updated)
|
||||
toast.success('Metadaten gespeichert.')
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, 'Speichern fehlgeschlagen.'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return <ReadOnlyMeta item={item} />
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<Input label="Titel" value={form.title} onChange={(e) => set('title', e.target.value)} />
|
||||
<Input label="Autor" value={form.author} onChange={(e) => set('author', e.target.value)} />
|
||||
<Input label="Genres" hint="Komma-getrennt" value={form.genres} onChange={(e) => set('genres', e.target.value)} />
|
||||
<Input label="Sprache" value={form.language} onChange={(e) => set('language', e.target.value)} />
|
||||
<Input label="RSS-Feed" value={form.feedUrl} readOnly disabled />
|
||||
<Textarea label="Beschreibung" value={form.description} onChange={(e) => set('description', e.target.value)} />
|
||||
<Button onClick={save} loading={saving}>
|
||||
<Save size={16} /> Speichern
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadOnlyMeta({ item }: { item: LibraryItem }) {
|
||||
const m = item.media.metadata
|
||||
const rows: [string, string][] = [
|
||||
['Titel', m.title ?? '—'],
|
||||
['Beschreibung', htmlToText(m.description) || '—'],
|
||||
]
|
||||
return (
|
||||
<div className="max-w-2xl space-y-3">
|
||||
<p className="text-sm text-text-muted">Keine Berechtigung zum Bearbeiten.</p>
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">{k}</p>
|
||||
<p className="text-sm text-text">{v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
src/components/layout/AppLayout.tsx
Normal file
63
src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { BottomNav } from './BottomNav'
|
||||
import { TopBar } from './TopBar'
|
||||
import { PlayerBar } from '@/components/player/PlayerBar'
|
||||
import { useLibraryStore } from '@/store/libraryStore'
|
||||
import { useProgressStore } from '@/store/progressStore'
|
||||
import { usePlayerStore } from '@/store/playerStore'
|
||||
import { getLibraries } from '@/api/libraries'
|
||||
import { apiErrorMessage } from '@/api/client'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* App shell: collapsible sidebar (desktop) + content area + a reserved slot at the
|
||||
* bottom for the persistent player (added in Phase 6) and the mobile bottom-nav.
|
||||
* Loads the library list once after auth so the navigation can populate.
|
||||
*/
|
||||
export function AppLayout() {
|
||||
const { loaded, loading, setLibraries, setLoading, setError } = useLibraryStore()
|
||||
const loadProgress = useProgressStore((s) => s.load)
|
||||
const progressLoaded = useProgressStore((s) => s.loaded)
|
||||
const playerActive = usePlayerStore((s) => !!s.item)
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded || loading) return
|
||||
setLoading(true)
|
||||
getLibraries()
|
||||
.then(setLibraries)
|
||||
.catch((err) => setError(apiErrorMessage(err, 'Bibliotheken konnten nicht geladen werden.')))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!progressLoaded) loadProgress().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-bg text-text">
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex min-h-dvh flex-1 flex-col">
|
||||
<TopBar />
|
||||
<main
|
||||
className={cn(
|
||||
'flex-1 px-5 pt-4 md:px-8',
|
||||
playerActive ? 'pb-44 md:pb-28' : 'pb-20 md:pb-8',
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1400px]">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlayerBar />
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/components/layout/BottomNav.tsx
Normal file
50
src/components/layout/BottomNav.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Home, Library as LibraryIcon, Settings, Shield } from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useLibraryStore } from '@/store/libraryStore'
|
||||
import { useIsAdmin } from '@/store/authStore'
|
||||
|
||||
interface Tab {
|
||||
to: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
/** Mobile bottom navigation (≤5 items, icon + label). Replaces the sidebar < md. */
|
||||
export function BottomNav() {
|
||||
const firstLibrary = useLibraryStore((s) => s.libraries[0])
|
||||
const isAdmin = useIsAdmin()
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ to: '/', label: 'Home', icon: <Home size={20} />, end: true },
|
||||
{
|
||||
to: firstLibrary ? `/library/${firstLibrary.id}` : '/',
|
||||
label: 'Medien',
|
||||
icon: <LibraryIcon size={20} />,
|
||||
},
|
||||
{ to: '/settings', label: 'Optionen', icon: <Settings size={20} /> },
|
||||
]
|
||||
if (isAdmin) tabs.push({ to: '/admin/users', label: 'Admin', icon: <Shield size={20} /> })
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-16 border-t border-border bg-surface/95 backdrop-blur md:hidden">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.label}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-1 text-[11px] transition-colors',
|
||||
isActive ? 'text-accent' : 'text-text-muted',
|
||||
)
|
||||
}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
129
src/components/layout/Sidebar.tsx
Normal file
129
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
Home,
|
||||
Settings,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Headphones,
|
||||
BookOpen,
|
||||
Users,
|
||||
FolderCog,
|
||||
ScanLine,
|
||||
ServerCog,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { useLibraryStore } from '@/store/libraryStore'
|
||||
import { useIsAdmin } from '@/store/authStore'
|
||||
|
||||
interface ItemProps {
|
||||
to: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
collapsed: boolean
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
function NavItem({ to, label, icon, collapsed, end }: ItemProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
title={collapsed ? label : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'group relative flex items-center gap-3 rounded px-3 py-2 text-sm transition-colors',
|
||||
'hover:bg-surface-2',
|
||||
isActive ? 'bg-surface-2 font-medium text-text' : 'text-text-muted',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r bg-accent transition-opacity',
|
||||
isActive ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<span className="shrink-0 text-current">{icon}</span>
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel({ children, collapsed }: { children: string; collapsed: boolean }) {
|
||||
if (collapsed) return <div className="my-2 border-t border-border" />
|
||||
return (
|
||||
<p className="px-3 pb-1 pt-4 text-[11px] font-semibold uppercase tracking-wider text-text-muted/70">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const collapsed = useSettingsStore((s) => s.sidebarCollapsed)
|
||||
const toggle = useSettingsStore((s) => s.toggleSidebar)
|
||||
const libraries = useLibraryStore((s) => s.libraries)
|
||||
const isAdmin = useIsAdmin()
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'sticky top-0 hidden h-dvh shrink-0 flex-col border-r border-border bg-surface md:flex',
|
||||
collapsed ? 'w-[68px]' : 'w-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
<div className="grid h-8 w-8 shrink-0 place-items-center rounded bg-accent-soft text-accent">
|
||||
<Headphones size={18} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="font-heading text-lg font-semibold tracking-tight">Shelfless</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-2">
|
||||
<NavItem to="/" end label="Home" icon={<Home size={18} />} collapsed={collapsed} />
|
||||
|
||||
<SectionLabel collapsed={collapsed}>Bibliotheken</SectionLabel>
|
||||
{libraries.length === 0 && !collapsed && (
|
||||
<p className="px-3 py-1 text-xs text-text-muted/60">Noch keine geladen</p>
|
||||
)}
|
||||
{libraries.map((lib) => (
|
||||
<NavItem
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
label={lib.name}
|
||||
icon={lib.mediaType === 'podcast' ? <Headphones size={18} /> : <BookOpen size={18} />}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<SectionLabel collapsed={collapsed}>Admin</SectionLabel>
|
||||
<NavItem to="/admin/users" label="Benutzer" icon={<Users size={18} />} collapsed={collapsed} />
|
||||
<NavItem to="/admin/libraries" label="Bibliotheken" icon={<FolderCog size={18} />} collapsed={collapsed} />
|
||||
<NavItem to="/admin/scan" label="Scanner" icon={<ScanLine size={18} />} collapsed={collapsed} />
|
||||
<NavItem to="/admin/settings" label="Server" icon={<ServerCog size={18} />} collapsed={collapsed} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
<NavItem to="/settings" label="Einstellungen" icon={<Settings size={18} />} collapsed={collapsed} />
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="mt-1 flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-text-muted transition-colors hover:bg-surface-2"
|
||||
aria-label={collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
||||
{!collapsed && <span>Einklappen</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
39
src/components/layout/TopBar.tsx
Normal file
39
src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useLibraryStore } from '@/store/libraryStore'
|
||||
|
||||
/**
|
||||
* Top bar with a global search launcher: submits into the first library's scoped search
|
||||
* (the Library view reads `?q=` and runs live search there).
|
||||
*/
|
||||
export function TopBar() {
|
||||
const [q, setQ] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const firstLibrary = useLibraryStore((s) => s.libraries[0])
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const term = q.trim()
|
||||
if (term && firstLibrary) navigate(`/library/${firstLibrary.id}?q=${encodeURIComponent(term)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-bg/85 px-5 backdrop-blur md:px-8">
|
||||
<form onSubmit={onSubmit} className="relative w-full max-w-md">
|
||||
<Search
|
||||
size={16}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
|
||||
/>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
type="search"
|
||||
placeholder="Suchen …"
|
||||
aria-label="Suchen"
|
||||
className="h-9 w-full rounded border border-border bg-surface pl-9 pr-3 text-sm text-text placeholder:text-text-muted/70 focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</form>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
248
src/components/player/PlayerBar.tsx
Normal file
248
src/components/player/PlayerBar.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
X,
|
||||
ListTree,
|
||||
Gauge,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
BookOpen,
|
||||
Headphones,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { formatTime } from '@/lib/format'
|
||||
import { coverUrl } from '@/lib/media'
|
||||
import { usePlayerStore, currentChapter } from '@/store/playerStore'
|
||||
import { PLAYBACK_SPEEDS } from '@/store/settingsStore'
|
||||
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { useAudioEngine } from './useAudioEngine'
|
||||
|
||||
export function PlayerBar() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
useAudioEngine(audioRef)
|
||||
|
||||
const item = usePlayerStore((s) => s.item)
|
||||
const title = usePlayerStore((s) => s.title)
|
||||
const author = usePlayerStore((s) => s.author)
|
||||
const coverItemId = usePlayerStore((s) => s.coverItemId)
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying)
|
||||
const loading = usePlayerStore((s) => s.loading)
|
||||
const position = usePlayerStore((s) => s.position)
|
||||
const duration = usePlayerStore((s) => s.duration)
|
||||
const speed = usePlayerStore((s) => s.speed)
|
||||
const volume = usePlayerStore((s) => s.volume)
|
||||
const chapters = usePlayerStore((s) => s.chapters)
|
||||
|
||||
const toggle = usePlayerStore((s) => s.togglePlay)
|
||||
const skip = usePlayerStore((s) => s.skip)
|
||||
const seek = usePlayerStore((s) => s.seek)
|
||||
const setSpeed = usePlayerStore((s) => s.setSpeed)
|
||||
const setVolume = usePlayerStore((s) => s.setVolume)
|
||||
const stop = usePlayerStore((s) => s.stop)
|
||||
|
||||
const chapter = chapters.length ? currentChapter(chapters, position) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-16 z-40 border-t border-border bg-surface/95 backdrop-blur md:bottom-0',
|
||||
'animate-slide-up shadow-bar',
|
||||
!item && 'hidden',
|
||||
)}
|
||||
>
|
||||
<audio ref={audioRef} preload="metadata" />
|
||||
|
||||
<div className="mx-auto flex max-w-[1600px] items-center gap-3 px-3 py-2.5 md:px-5">
|
||||
{/* Now playing */}
|
||||
<Link
|
||||
to={item ? `/item/${item.id}` : '#'}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 md:w-72 md:flex-none"
|
||||
>
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-surface-2">
|
||||
{coverItemId && (
|
||||
<img
|
||||
src={coverUrl(coverItemId)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = 'hidden')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-text">{title}</p>
|
||||
<p className="truncate text-xs text-text-muted">{author}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Center controls */}
|
||||
<div className="flex flex-1 flex-col items-center gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => skip(-30)}
|
||||
aria-label="30 Sekunden zurück"
|
||||
className="grid h-9 w-9 place-items-center rounded-full text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label={isPlaying ? 'Pause' : 'Abspielen'}
|
||||
className="grid h-11 w-11 place-items-center rounded-full bg-accent text-on-accent transition-transform hover:scale-105"
|
||||
>
|
||||
{loading ? <Spinner size={18} /> : isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => skip(30)}
|
||||
aria-label="30 Sekunden vor"
|
||||
className="grid h-9 w-9 place-items-center rounded-full text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
<RotateCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="hidden w-full max-w-2xl items-center gap-2 md:flex">
|
||||
<span className="tnum w-12 text-right text-xs text-text-muted">{formatTime(position)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(duration, 1)}
|
||||
step={1}
|
||||
value={Math.min(position, duration || position)}
|
||||
onChange={(e) => seek(Number(e.target.value))}
|
||||
aria-label="Position"
|
||||
className="h-1 flex-1 cursor-pointer accent-[var(--accent)]"
|
||||
/>
|
||||
<span className="tnum w-12 text-xs text-text-muted">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right controls */}
|
||||
<div className="flex flex-none items-center justify-end gap-1 md:w-72">
|
||||
{chapters.length > 0 && (
|
||||
<Dropdown
|
||||
align="right"
|
||||
trigger={(open) => (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden h-8 max-w-[160px] items-center gap-1.5 rounded px-2 text-xs text-text-muted transition-colors hover:bg-surface-2 hover:text-text lg:inline-flex',
|
||||
open && 'bg-surface-2',
|
||||
)}
|
||||
title={chapter?.title}
|
||||
>
|
||||
<ListTree size={15} />
|
||||
<span className="truncate">{chapter?.title ?? 'Kapitel'}</span>
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{(close) => (
|
||||
<div className="max-h-[50dvh] overflow-y-auto">
|
||||
{chapters.map((c) => (
|
||||
<DropdownItem
|
||||
key={c.id}
|
||||
active={chapter?.id === c.id}
|
||||
onSelect={() => {
|
||||
seek(c.start)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-3">
|
||||
<span className="truncate">{c.title}</span>
|
||||
<span className="tnum shrink-0 text-xs text-text-muted">
|
||||
{formatTime(c.start)}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
align="right"
|
||||
trigger={(open) => (
|
||||
<span
|
||||
className={cn(
|
||||
'tnum inline-flex h-8 items-center gap-1 rounded px-2 text-xs text-text-muted transition-colors hover:bg-surface-2 hover:text-text',
|
||||
open && 'bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<Gauge size={15} /> {speed}×
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{(close) =>
|
||||
PLAYBACK_SPEEDS.map((s) => (
|
||||
<DropdownItem
|
||||
key={s}
|
||||
active={speed === s}
|
||||
onSelect={() => {
|
||||
setSpeed(s)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<span className="tnum">{s}×</span>
|
||||
</DropdownItem>
|
||||
))
|
||||
}
|
||||
</Dropdown>
|
||||
|
||||
<div className="hidden items-center gap-1 lg:flex">
|
||||
<button
|
||||
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
||||
aria-label={volume > 0 ? 'Stumm' : 'Ton an'}
|
||||
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
{volume > 0 ? <Volume2 size={17} /> : <VolumeX size={17} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
aria-label="Lautstärke"
|
||||
className="h-1 w-20 cursor-pointer accent-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={stop}
|
||||
aria-label="Player schließen"
|
||||
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile progress (thin, full width) */}
|
||||
<div className="flex items-center gap-2 px-3 pb-2 md:hidden">
|
||||
<span className="tnum w-10 text-right text-[10px] text-text-muted">{formatTime(position)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(duration, 1)}
|
||||
step={1}
|
||||
value={Math.min(position, duration || position)}
|
||||
onChange={(e) => seek(Number(e.target.value))}
|
||||
aria-label="Position"
|
||||
className="h-1 flex-1 cursor-pointer accent-[var(--accent)]"
|
||||
/>
|
||||
<span className="tnum w-10 text-[10px] text-text-muted">{formatTime(duration)}</span>
|
||||
{item && (
|
||||
<span className="text-text-muted">
|
||||
{item.mediaType === 'podcast' ? <Headphones size={14} /> : <BookOpen size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
src/components/player/useAudioEngine.ts
Normal file
206
src/components/player/useAudioEngine.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { usePlayerStore } from '@/store/playerStore'
|
||||
import { useProgressStore } from '@/store/progressStore'
|
||||
import { streamUrl } from '@/lib/media'
|
||||
import { syncSession, closeSession } from '@/api/sessions'
|
||||
import { updateProgress } from '@/api/progress'
|
||||
import type { AudioTrack } from '@/types/abs'
|
||||
|
||||
const SYNC_INTERVAL_MS = 30_000
|
||||
const PAUSE_CLOSE_MS = 5 * 60_000
|
||||
|
||||
function trackIndexForPosition(tracks: AudioTrack[], pos: number): number {
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const t = tracks[i]
|
||||
if (pos < t.startOffset + t.duration) return i
|
||||
}
|
||||
return Math.max(0, tracks.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a single <audio> element from the player store: multi-track playback with global
|
||||
* position mapping, seek, speed/volume, 30s session sync, finish detection, and auto-close
|
||||
* on long pause / tab close. Mount once (in the PlayerBar).
|
||||
*/
|
||||
export function useAudioEngine(audioRef: React.RefObject<HTMLAudioElement>) {
|
||||
const item = usePlayerStore((s) => s.item)
|
||||
const episodeId = usePlayerStore((s) => s.episodeId)
|
||||
const tracks = usePlayerStore((s) => s.audioTracks)
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying)
|
||||
const speed = usePlayerStore((s) => s.speed)
|
||||
const volume = usePlayerStore((s) => s.volume)
|
||||
const seekTo = usePlayerStore((s) => s.seekTo)
|
||||
|
||||
const loadedTrack = useRef<number | null>(null)
|
||||
const lastSyncPos = useRef(0)
|
||||
const pauseTimer = useRef<number | null>(null)
|
||||
|
||||
// Load the right track for a global position and optionally start playback.
|
||||
// Reads tracks from the store so it's safe inside once-attached event listeners.
|
||||
function ensureTrack(globalPos: number, autoplay: boolean) {
|
||||
const audio = audioRef.current
|
||||
const curTracks = usePlayerStore.getState().audioTracks
|
||||
if (!audio || curTracks.length === 0) return
|
||||
const idx = trackIndexForPosition(curTracks, globalPos)
|
||||
const local = Math.max(0, globalPos - curTracks[idx].startOffset)
|
||||
if (loadedTrack.current !== idx) {
|
||||
loadedTrack.current = idx
|
||||
audio.src = streamUrl(curTracks[idx].contentUrl)
|
||||
const onMeta = () => {
|
||||
audio.currentTime = local
|
||||
if (autoplay) audio.play().catch(() => undefined)
|
||||
}
|
||||
audio.addEventListener('loadedmetadata', onMeta, { once: true })
|
||||
audio.load()
|
||||
} else {
|
||||
audio.currentTime = local
|
||||
if (autoplay) audio.play().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
// New playback session: reset and load from the requested seek position.
|
||||
useEffect(() => {
|
||||
loadedTrack.current = null
|
||||
lastSyncPos.current = usePlayerStore.getState().position
|
||||
if (tracks.length > 0) {
|
||||
ensureTrack(usePlayerStore.getState().position, usePlayerStore.getState().isPlaying)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [item?.id, episodeId, tracks])
|
||||
|
||||
// Handle explicit seeks.
|
||||
useEffect(() => {
|
||||
if (seekTo == null) return
|
||||
ensureTrack(seekTo, usePlayerStore.getState().isPlaying)
|
||||
usePlayerStore.getState().clearSeek()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seekTo])
|
||||
|
||||
// Play / pause.
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
if (isPlaying) {
|
||||
if (loadedTrack.current === null) ensureTrack(usePlayerStore.getState().position, true)
|
||||
else audio.play().catch(() => undefined)
|
||||
} else {
|
||||
audio.pause()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPlaying])
|
||||
|
||||
// Speed & volume.
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.playbackRate = speed
|
||||
}, [speed, audioRef])
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.volume = volume
|
||||
}, [volume, audioRef])
|
||||
|
||||
// Persist progress to the server + local store.
|
||||
function persist(finished = false) {
|
||||
const st = usePlayerStore.getState()
|
||||
if (!st.item || !st.session) return
|
||||
const pos = finished ? st.duration : st.position
|
||||
const progress = st.duration > 0 ? Math.min(1, pos / st.duration) : 0
|
||||
const listened = Math.max(0, pos - lastSyncPos.current)
|
||||
lastSyncPos.current = pos
|
||||
|
||||
syncSession(st.session.id, {
|
||||
currentTime: pos,
|
||||
timeListened: listened,
|
||||
duration: st.duration,
|
||||
}).catch(() => undefined)
|
||||
|
||||
if (finished) {
|
||||
updateProgress(st.item.id, { isFinished: true, currentTime: pos, progress: 1 }, st.episodeId ?? undefined).catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
useProgressStore.getState().upsert({
|
||||
id: `${st.item.id}${st.episodeId ? `:${st.episodeId}` : ''}`,
|
||||
libraryItemId: st.item.id,
|
||||
episodeId: st.episodeId,
|
||||
duration: st.duration,
|
||||
progress,
|
||||
currentTime: pos,
|
||||
isFinished: finished,
|
||||
})
|
||||
}
|
||||
|
||||
// Audio element event listeners (attached once).
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
function onTimeUpdate() {
|
||||
const st = usePlayerStore.getState()
|
||||
const idx = loadedTrack.current
|
||||
if (idx == null || !st.audioTracks[idx] || !audio) return
|
||||
st.setPosition(st.audioTracks[idx].startOffset + audio.currentTime)
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
const st = usePlayerStore.getState()
|
||||
const idx = loadedTrack.current
|
||||
if (idx == null) return
|
||||
if (idx + 1 < st.audioTracks.length) {
|
||||
ensureTrack(st.audioTracks[idx + 1].startOffset, true)
|
||||
} else {
|
||||
st.setPlaying(false)
|
||||
persist(true)
|
||||
}
|
||||
}
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate)
|
||||
audio.addEventListener('ended', onEnded)
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate)
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 30s sync while playing.
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return
|
||||
const id = window.setInterval(() => persist(false), SYNC_INTERVAL_MS)
|
||||
return () => window.clearInterval(id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPlaying])
|
||||
|
||||
// Auto-close session after a long pause.
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
if (pauseTimer.current) {
|
||||
window.clearTimeout(pauseTimer.current)
|
||||
pauseTimer.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!usePlayerStore.getState().session) return
|
||||
pauseTimer.current = window.setTimeout(() => {
|
||||
persist(false)
|
||||
usePlayerStore.getState().stop()
|
||||
}, PAUSE_CLOSE_MS)
|
||||
return () => {
|
||||
if (pauseTimer.current) window.clearTimeout(pauseTimer.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPlaying])
|
||||
|
||||
// Flush + close on tab close.
|
||||
useEffect(() => {
|
||||
function onHide() {
|
||||
const st = usePlayerStore.getState()
|
||||
if (st.session) {
|
||||
persist(false)
|
||||
closeSession(st.session.id).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
window.addEventListener('pagehide', onHide)
|
||||
return () => window.removeEventListener('pagehide', onHide)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}
|
||||
46
src/components/ui/Button.tsx
Normal file
46
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { Spinner } from './Spinner'
|
||||
|
||||
type Variant = 'primary' | 'subtle' | 'ghost' | 'danger'
|
||||
type Size = 'sm' | 'md'
|
||||
|
||||
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant
|
||||
size?: Size
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: 'bg-accent text-on-accent hover:brightness-110',
|
||||
subtle: 'bg-surface-2 text-text hover:bg-border',
|
||||
ghost: 'bg-transparent text-text-muted hover:bg-surface-2 hover:text-text',
|
||||
danger: 'bg-destructive text-white hover:brightness-110',
|
||||
}
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: 'h-8 px-3 text-sm gap-1.5',
|
||||
md: 'h-10 px-4 text-sm gap-2',
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
||||
{ variant = 'primary', size = 'md', loading, disabled, className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
'inline-flex select-none items-center justify-center rounded font-medium transition-[filter,background-color,color] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{loading && <Spinner size={size === 'sm' ? 14 : 16} />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
28
src/components/ui/Checkbox.tsx
Normal file
28
src/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
interface Props {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Checkbox({ checked, onChange, label, disabled }: Props) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center gap-2.5 text-sm',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border bg-surface-2 accent-[var(--accent)]"
|
||||
/>
|
||||
<span className="text-text">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
57
src/components/ui/ConfirmDialog.tsx
Normal file
57
src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from './Modal'
|
||||
import { Button } from './Button'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
title: string
|
||||
message: React.ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
onConfirm: () => void | Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Bestätigen',
|
||||
cancelLabel = 'Abbrechen',
|
||||
danger,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function handleConfirm() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={busy ? () => undefined : onCancel}
|
||||
title={title}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={danger ? 'danger' : 'primary'} loading={busy} onClick={handleConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="text-sm text-text-muted">{message}</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
63
src/components/ui/Dropdown.tsx
Normal file
63
src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useClickOutside } from '@/hooks/useClickOutside'
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: (open: boolean) => React.ReactNode
|
||||
children: (close: () => void) => React.ReactNode
|
||||
align?: 'left' | 'right'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Lightweight popover menu: a trigger button + an absolutely-positioned panel. */
|
||||
export function Dropdown({ trigger, children, align = 'left', className }: DropdownProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useClickOutside(ref, () => setOpen(false), open)
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button type="button" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
|
||||
{trigger(open)}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-40 mt-1 min-w-[200px] overflow-hidden rounded-lg border border-border bg-surface py-1 shadow-lift animate-fade-in',
|
||||
align === 'right' ? 'right-0' : 'left-0',
|
||||
className,
|
||||
)}
|
||||
role="menu"
|
||||
>
|
||||
{children(() => setOpen(false))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
onSelect: () => void
|
||||
active?: boolean
|
||||
children: React.ReactNode
|
||||
danger?: boolean
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DropdownItem({ onSelect, active, children, danger, icon }: ItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
||||
danger ? 'text-destructive hover:bg-destructive/10' : 'text-text hover:bg-surface-2',
|
||||
active && !danger && 'bg-surface-2 font-medium',
|
||||
)}
|
||||
>
|
||||
{icon && <span className="shrink-0 text-text-muted">{icon}</span>}
|
||||
<span className="flex-1 truncate">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
41
src/components/ui/Input.tsx
Normal file
41
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { forwardRef, useId } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ label, hint, error, className, id, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const autoId = useId()
|
||||
const inputId = id ?? autoId
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="mb-1.5 block text-sm font-medium text-text">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={cn(
|
||||
'h-10 w-full rounded border bg-surface-2 px-3 text-sm text-text placeholder:text-text-muted/60 focus:outline-none',
|
||||
error ? 'border-destructive focus:border-destructive' : 'border-border focus:border-accent',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">{error}</p>
|
||||
) : hint ? (
|
||||
<p className="mt-1.5 text-xs text-text-muted">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
77
src/components/ui/Modal.tsx
Normal file
77
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const sizes = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-2xl' }
|
||||
|
||||
export function Modal({ open, onClose, title, children, footer, size = 'md' }: Props) {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
panelRef.current?.focus()
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = prev
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 animate-fade-in"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
'relative z-10 w-full overflow-hidden rounded-xl border border-border bg-surface shadow-lift outline-none animate-slide-up',
|
||||
sizes[size],
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
|
||||
<h2 className="font-heading text-lg font-semibold">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="grid h-8 w-8 place-items-center rounded text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[70dvh] overflow-y-auto px-5 py-4">{children}</div>
|
||||
{footer && (
|
||||
<div className="flex justify-end gap-2 border-t border-border bg-surface px-5 py-3.5">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
17
src/components/ui/PageHeader.tsx
Normal file
17
src/components/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, actions }: Props) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-heading text-3xl font-semibold tracking-tight">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/components/ui/Placeholder.tsx
Normal file
11
src/components/ui/Placeholder.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Construction } from 'lucide-react'
|
||||
|
||||
/** Temporary content marker for views filled in by later build phases. */
|
||||
export function Placeholder({ note }: { note: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-surface/40 px-6 py-16 text-center">
|
||||
<Construction className="mb-3 text-text-muted" size={28} />
|
||||
<p className="text-sm text-text-muted">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
src/components/ui/Select.tsx
Normal file
39
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { forwardRef, useId } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
interface Props extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
options: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, Props>(function Select(
|
||||
{ label, options, className, id, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const autoId = useId()
|
||||
const selectId = id ?? autoId
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="mb-1.5 block text-sm font-medium text-text">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={cn(
|
||||
'h-10 w-full rounded border border-border bg-surface-2 px-3 text-sm text-text focus:border-accent focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
6
src/components/ui/Spinner.tsx
Normal file
6
src/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Spinner({ size = 18, className }: { size?: number; className?: string }) {
|
||||
return <Loader2 size={size} className={cn('animate-spin', className)} />
|
||||
}
|
||||
40
src/components/ui/States.tsx
Normal file
40
src/components/ui/States.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AlertTriangle, Inbox, RotateCw } from 'lucide-react'
|
||||
import { Button } from './Button'
|
||||
|
||||
export function ErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-destructive/30 bg-destructive/5 px-6 py-12 text-center">
|
||||
<AlertTriangle className="mb-3 text-destructive" size={26} />
|
||||
<p className="mb-4 max-w-md text-sm text-text">{message}</p>
|
||||
{onRetry && (
|
||||
<Button variant="subtle" size="sm" onClick={onRetry}>
|
||||
<RotateCw size={15} /> Erneut versuchen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
hint,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
hint?: string
|
||||
icon?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-surface/40 px-6 py-16 text-center">
|
||||
<div className="mb-3 text-text-muted">{icon ?? <Inbox size={28} />}</div>
|
||||
<p className="text-sm font-medium text-text">{title}</p>
|
||||
{hint && <p className="mt-1 max-w-md text-sm text-text-muted">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/ui/Tabs.tsx
Normal file
38
src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export interface TabDef {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: TabDef[]
|
||||
active: string
|
||||
onChange: (key: string) => void
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, active, onChange }: Props) {
|
||||
return (
|
||||
<div role="tablist" className="flex gap-1 border-b border-border">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
role="tab"
|
||||
aria-selected={active === t.key}
|
||||
onClick={() => onChange(t.key)}
|
||||
className={cn(
|
||||
'relative -mb-px px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
active === t.key
|
||||
? 'text-text'
|
||||
: 'text-text-muted hover:text-text',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
{active === t.key && (
|
||||
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
src/components/ui/Toaster.tsx
Normal file
40
src/components/ui/Toaster.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { CheckCircle2, XCircle, Info, X } from 'lucide-react'
|
||||
import { useToastStore, type ToastKind } from '@/store/toastStore'
|
||||
|
||||
const icons: Record<ToastKind, React.ReactNode> = {
|
||||
success: <CheckCircle2 size={18} className="text-success" />,
|
||||
error: <XCircle size={18} className="text-destructive" />,
|
||||
info: <Info size={18} className="text-accent" />,
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const toasts = useToastStore((s) => s.toasts)
|
||||
const dismiss = useToastStore((s) => s.dismiss)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 left-1/2 z-[60] flex w-full max-w-sm -translate-x-1/2 flex-col gap-2 px-4"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-surface-2 px-4 py-3 shadow-lift animate-slide-up"
|
||||
>
|
||||
<span className="shrink-0">{icons[t.kind]}</span>
|
||||
<p className="flex-1 text-sm text-text">{t.message}</p>
|
||||
<button
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="Schließen"
|
||||
className="shrink-0 text-text-muted hover:text-text"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
24
src/hooks/useClickOutside.ts
Normal file
24
src/hooks/useClickOutside.ts
Normal 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
11
src/hooks/useDebounce.ts
Normal 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
137
src/index.css
Normal 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
22
src/lib/backend.ts
Normal 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
4
src/lib/cn.ts
Normal 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
47
src/lib/format.ts
Normal 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
14
src/lib/html.ts
Normal 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
87
src/lib/media.ts
Normal 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
22
src/lib/url.ts
Normal 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
20
src/main.tsx
Normal 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
96
src/pages/Home.tsx
Normal 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
247
src/pages/ItemDetail.tsx
Normal 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
493
src/pages/Library.tsx
Normal 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
156
src/pages/Settings.tsx
Normal 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
137
src/pages/Setup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
src/pages/admin/AdminLibraries.tsx
Normal file
153
src/pages/admin/AdminLibraries.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
100
src/pages/admin/AdminScan.tsx
Normal file
100
src/pages/admin/AdminScan.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
210
src/pages/admin/AdminSettings.tsx
Normal file
210
src/pages/admin/AdminSettings.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
172
src/pages/admin/AdminUsers.tsx
Normal file
172
src/pages/admin/AdminUsers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
src/routes/RequireAdmin.tsx
Normal file
9
src/routes/RequireAdmin.tsx
Normal 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 />
|
||||
}
|
||||
53
src/routes/RequireAuth.tsx
Normal file
53
src/routes/RequireAuth.tsx
Normal 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
68
src/store/authStore.ts
Normal 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
24
src/store/libraryStore.ts
Normal 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
153
src/store/playerStore.ts
Normal 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
|
||||
}
|
||||
43
src/store/progressStore.ts
Normal file
43
src/store/progressStore.ts
Normal 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 }
|
||||
}),
|
||||
}))
|
||||
93
src/store/settingsStore.ts
Normal file
93
src/store/settingsStore.ts
Normal 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
37
src/store/toastStore.ts
Normal 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
334
src/types/abs.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
53
tailwind.config.js
Normal file
53
tailwind.config.js
Normal 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
27
tsconfig.app.json
Normal 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
1
tsconfig.app.tsbuildinfo
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
20
tsconfig.node.json
Normal file
20
tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
73
vite.config.ts
Normal file
73
vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user