ui: keep sidebar controls visible during playback; move collapse to header

- PlayerBar no longer overlaps the sidebar on desktop (offsets by sidebar width),
  so Settings/collapse stay reachable while playing.
- Sidebar collapse toggle moved onto the header logo icon; remove bottom button.
- Prod server: catch-all proxy so any non-SPA, non-asset request is forwarded to
  ABS — only Shelfless needs to be exposed, ABS stays internal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-06-02 21:00:49 +02:00
parent 757a6fab63
commit e570390902
3 changed files with 46 additions and 32 deletions

View File

@@ -85,30 +85,37 @@ function sendFile(res, filePath, status = 200) {
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])
// Resolve a request path to an existing static file inside dist/, or null.
function resolveStaticFile(url) {
const urlPath = decodeURIComponent((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 filePath = join(DIST, safe)
if (!filePath.startsWith(DIST)) return null
if (existsSync(filePath) && statSync(filePath).isFile()) return filePath
return null
}
const INDEX_HTML = join(DIST, 'index.html')
const server = http.createServer((req, res) => {
const url = req.url || '/'
// 1) Known ABS paths → forward upstream.
if (isProxied(url)) return proxy(req, res)
return serveStatic(req, res)
// 2) An existing static asset of the SPA → serve it.
const file = resolveStaticFile(url)
if (file) return sendFile(res, file)
// 3) A browser navigating to a client-side route → serve the SPA shell.
const accept = String(req.headers.accept || '')
if (req.method === 'GET' && accept.includes('text/html') && existsSync(INDEX_HTML)) {
return sendFile(res, INDEX_HTML)
}
// 4) Anything else (any other API/data request) → forward to ABS, so the real
// server never needs to be exposed on its own domain.
return proxy(req, res)
})
server.listen(PORT, HOST, () => {

View File

@@ -76,10 +76,21 @@ export function Sidebar() {
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>
<div className="flex h-14 items-center gap-2 px-3">
<button
onClick={toggle}
aria-label={collapsed ? 'Seitenleiste ausklappen' : 'Seitenleiste einklappen'}
title={collapsed ? 'Ausklappen' : 'Einklappen'}
className="group grid h-9 w-9 shrink-0 place-items-center rounded bg-accent-soft text-accent transition-colors hover:brightness-110"
>
{/* Headphones by default; a collapse chevron on hover to hint the toggle. */}
<Headphones size={18} className="group-hover:hidden" />
{collapsed ? (
<PanelLeftOpen size={18} className="hidden group-hover:block" />
) : (
<PanelLeftClose size={18} className="hidden group-hover:block" />
)}
</button>
{!collapsed && (
<span className="font-heading text-lg font-semibold tracking-tight">Shelfless</span>
)}
@@ -115,14 +126,6 @@ export function Sidebar() {
<div className="border-t border-border px-2 py-2">
<NavItem to="/settings" label="Einstellungen" icon={<Settings size={18} />} collapsed={collapsed} />
<button
onClick={toggle}
className="mt-1 flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-text-muted transition-colors hover:bg-surface-2"
aria-label={collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
>
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
{!collapsed && <span>Einklappen</span>}
</button>
</div>
</aside>
)

View File

@@ -17,7 +17,7 @@ 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 { PLAYBACK_SPEEDS, useSettingsStore } from '@/store/settingsStore'
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'
import { Spinner } from '@/components/ui/Spinner'
import { useAudioEngine } from './useAudioEngine'
@@ -37,6 +37,7 @@ export function PlayerBar() {
const speed = usePlayerStore((s) => s.speed)
const volume = usePlayerStore((s) => s.volume)
const chapters = usePlayerStore((s) => s.chapters)
const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed)
const toggle = usePlayerStore((s) => s.togglePlay)
const skip = usePlayerStore((s) => s.skip)
@@ -50,7 +51,10 @@ export function PlayerBar() {
return (
<div
className={cn(
'fixed inset-x-0 bottom-16 z-40 border-t border-border bg-surface/95 backdrop-blur md:bottom-0',
'fixed bottom-16 left-0 right-0 z-40 border-t border-border bg-surface/95 backdrop-blur md:bottom-0',
// On desktop the bar starts at the sidebar's right edge so it never covers the
// sidebar's own controls (Settings / collapse).
sidebarCollapsed ? 'md:left-[68px]' : 'md:left-60',
'animate-slide-up shadow-bar',
!item && 'hidden',
)}