mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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 })
|
||||||
|
|||||||
+68
-66
@@ -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' && (
|
||||||
|
|||||||
@@ -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