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)
|
createReadStream(filePath).pipe(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
function serveStatic(req, res) {
|
// Resolve a request path to an existing static file inside dist/, or null.
|
||||||
// Strip query, prevent path traversal, default to index.html (SPA fallback).
|
function resolveStaticFile(url) {
|
||||||
const urlPath = decodeURIComponent((req.url || '/').split('?')[0])
|
const urlPath = decodeURIComponent((url || '/').split('?')[0])
|
||||||
const safe = normalize(urlPath).replace(/^(\.\.[/\\])+/, '')
|
const safe = normalize(urlPath).replace(/^(\.\.[/\\])+/, '')
|
||||||
let filePath = join(DIST, safe)
|
const filePath = join(DIST, safe)
|
||||||
|
if (!filePath.startsWith(DIST)) return null
|
||||||
if (!filePath.startsWith(DIST)) {
|
if (existsSync(filePath) && statSync(filePath).isFile()) return filePath
|
||||||
res.statusCode = 403
|
return null
|
||||||
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 INDEX_HTML = join(DIST, 'index.html')
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const url = req.url || '/'
|
const url = req.url || '/'
|
||||||
|
|
||||||
|
// 1) Known ABS paths → forward upstream.
|
||||||
if (isProxied(url)) return proxy(req, res)
|
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, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
|
|||||||
@@ -76,10 +76,21 @@ export function Sidebar() {
|
|||||||
collapsed ? 'w-[68px]' : 'w-60',
|
collapsed ? 'w-[68px]' : 'w-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-14 items-center gap-2 px-4">
|
<div className="flex h-14 items-center gap-2 px-3">
|
||||||
<div className="grid h-8 w-8 shrink-0 place-items-center rounded bg-accent-soft text-accent">
|
<button
|
||||||
<Headphones size={18} />
|
onClick={toggle}
|
||||||
</div>
|
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 && (
|
{!collapsed && (
|
||||||
<span className="font-heading text-lg font-semibold tracking-tight">Shelfless</span>
|
<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">
|
<div className="border-t border-border px-2 py-2">
|
||||||
<NavItem to="/settings" label="Einstellungen" icon={<Settings size={18} />} collapsed={collapsed} />
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { cn } from '@/lib/cn'
|
|||||||
import { formatTime } from '@/lib/format'
|
import { formatTime } from '@/lib/format'
|
||||||
import { coverUrl } from '@/lib/media'
|
import { coverUrl } from '@/lib/media'
|
||||||
import { usePlayerStore, currentChapter } from '@/store/playerStore'
|
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 { Dropdown, DropdownItem } from '@/components/ui/Dropdown'
|
||||||
import { Spinner } from '@/components/ui/Spinner'
|
import { Spinner } from '@/components/ui/Spinner'
|
||||||
import { useAudioEngine } from './useAudioEngine'
|
import { useAudioEngine } from './useAudioEngine'
|
||||||
@@ -37,6 +37,7 @@ export function PlayerBar() {
|
|||||||
const speed = usePlayerStore((s) => s.speed)
|
const speed = usePlayerStore((s) => s.speed)
|
||||||
const volume = usePlayerStore((s) => s.volume)
|
const volume = usePlayerStore((s) => s.volume)
|
||||||
const chapters = usePlayerStore((s) => s.chapters)
|
const chapters = usePlayerStore((s) => s.chapters)
|
||||||
|
const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed)
|
||||||
|
|
||||||
const toggle = usePlayerStore((s) => s.togglePlay)
|
const toggle = usePlayerStore((s) => s.togglePlay)
|
||||||
const skip = usePlayerStore((s) => s.skip)
|
const skip = usePlayerStore((s) => s.skip)
|
||||||
@@ -50,7 +51,10 @@ export function PlayerBar() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
'animate-slide-up shadow-bar',
|
||||||
!item && 'hidden',
|
!item && 'hidden',
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user