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:
@@ -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, () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user