feat(ux): sticky header on Albums, NewReleases, Artists

Header with search/sort/genre/year controls now pins to top while
scrolling, so filters stay reachable. Nested .content-body (App.tsx
wraps routes) was becoming the sticky anchor and swallowing the effect —
fixed with .content-body .content-body { overflow: visible }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-18 22:24:22 +02:00
parent c96eb0a805
commit 4f2c313bb7
5 changed files with 98 additions and 69 deletions
+1 -1
View File
@@ -188,7 +188,7 @@ export default function Albums() {
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}> <div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}> <h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0 {selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size }) ? t('albums.selectionCount', { count: selectedIds.size })
+68 -66
View File
@@ -172,77 +172,79 @@ export default function Artists() {
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}> <div className="page-sticky-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
{selectionMode && selectedIds.size > 0 <h1 className="page-title" style={{ marginBottom: 0 }}>
? t('artists.selectionCount', { count: selectedIds.size }) {selectionMode && selectedIds.size > 0
: t('artists.title')} ? t('artists.selectionCount', { count: selectedIds.size })
</h1> : t('artists.title')}
<input </h1>
className="input" <input
style={{ maxWidth: 220 }} className="input"
placeholder={t('artists.search')} style={{ maxWidth: 220 }}
value={filter} placeholder={t('artists.search')}
onChange={e => setFilter(e.target.value)} value={filter}
id="artist-filter-input" onChange={e => setFilter(e.target.value)}
/> id="artist-filter-input"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
</div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: 'var(--space-4)' }}>
{!(selectionMode && selectedIds.size > 0) && (<> {ALPHABET.map(l => (
<button <button
className={`btn btn-surface`} key={l}
onClick={() => setShowArtistImages(!showArtistImages)} onClick={() => setLetterFilter(l)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }} className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')} >
data-tooltip-wrap {l === ALL_SENTINEL ? t('artists.all') : l}
> </button>
<Images size={20} /> ))}
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginBottom: '2rem' }}>
{ALPHABET.map(l => (
<button
key={l}
onClick={() => setLetterFilter(l)}
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
>
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>} {loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
{!loading && viewMode === 'grid' && ( {!loading && viewMode === 'grid' && (
+1 -1
View File
@@ -137,7 +137,7 @@ export default function NewReleases() {
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}> <div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}> <h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0 {selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size }) ? t('albums.selectionCount', { count: selectedIds.size })
+27
View File
@@ -1031,6 +1031,33 @@
contain: paint; contain: paint;
} }
/* Every page re-uses .content-body as its outer wrapper, but App.tsx already
renders one around <Routes /> as the scroll container. The inner one must
not establish its own scroll ancestor — otherwise `position: sticky`
children anchor to a container that never scrolls, and the header just
scrolls along with the content. Padding stays: the outer App.tsx
content-body has `padding: 0` inline, the inner one is what gives each
page its breathing room. */
.content-body .content-body {
overflow: visible;
}
/* Sticky page header: keeps page title + filter/search bar visible while the
body scrolls. Negative horizontal margins (+ matching padding) make the
background flush with the scroll container edges so the sticky block looks
like a real bar, not a floating island. `top: 0` clamps below the content-
body's own padding-top; the residual 24px gap above the sticky block while
scrolling is intentional — gives the bar visual breathing room. */
.page-sticky-header {
position: sticky;
top: 0;
z-index: 5;
background: var(--bg-app);
margin: 0 calc(-1 * var(--space-6)) var(--space-5);
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--border-subtle);
}
/* ─── Offline Banner ─── */ /* ─── Offline Banner ─── */
.offline-banner { .offline-banner {
display: flex; display: flex;
+1 -1
View File
@@ -4035,7 +4035,7 @@ select.input.input:focus {
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: none;
} }
} }