mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -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 })
|
||||||
|
|||||||
@@ -172,7 +172,8 @@ 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', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||||
{selectionMode && selectedIds.size > 0
|
{selectionMode && selectedIds.size > 0
|
||||||
@@ -231,7 +232,7 @@ export default function Artists() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginBottom: '2rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: 'var(--space-4)' }}>
|
||||||
{ALPHABET.map(l => (
|
{ALPHABET.map(l => (
|
||||||
<button
|
<button
|
||||||
key={l}
|
key={l}
|
||||||
@@ -242,6 +243,7 @@ export default function Artists() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</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>}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4035,7 +4035,7 @@ select.input.input:focus {
|
|||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user