mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
Per bcorporaal's request in #252: album-oriented listeners want a cleaner artist page focused on the album grid, with the freedom to reorder/hide bio, top tracks, and similar artists. Five sections are now reorder- and toggle-able from Settings → Personalisation → "Artist page sections": Bio, Top tracks, Similar artists, Albums, Also featured on. The fixed header block (artist photo, name, action buttons) stays at the top. Implementation reuses the existing sidebar customizer pattern: - new `src/store/artistLayoutStore.ts` modelled on `sidebarStore.ts` with the same persist + onRehydrateStorage migration so future-added sections don't silently disappear from existing users' configs. - `ArtistDetail.tsx` render block refactored from a flat sequence of conditional JSX into a `renderableSectionIds.map()` driven by the store. The "first rendered section gets marginTop: 0" rule now works regardless of the configured order — both hidden-by-toggle and empty-data sections are filtered before the layout decides what's first. - `ArtistLayoutCustomizer` component in Settings, lifted from `LyricsSourcesCustomizer` (drag via `useDragSource` + `psy-drop` event, per-row toggle, reset button). - 7 new i18n keys per locale across all 8 languages. The store hook MUST stay above the loading / !artist early returns — otherwise React's hook call order mismatches between renders and the component fails silently. Lesson learned during the refactor. Co-authored-by: Psychotoxical <dev@psysonic.app> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
c40243dfea
commit
f7a721b7b1
@@ -658,6 +658,14 @@ export const deTranslation = {
|
||||
homeCustomizerTitle: 'Startseite',
|
||||
sidebarTitle: 'Seitenleiste',
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
artistLayoutTitle: 'Künstlerseiten-Abschnitte',
|
||||
artistLayoutDesc: 'Per Drag & Drop neu anordnen, einzelne Abschnitte der Künstlerseite ein- oder ausblenden. Abschnitte ohne Daten werden automatisch übersprungen.',
|
||||
artistLayoutReset: 'Zurücksetzen',
|
||||
artistLayoutBio: 'Künstler-Biografie',
|
||||
artistLayoutTopTracks: 'Top-Tracks',
|
||||
artistLayoutSimilar: 'Ähnliche Künstler*innen',
|
||||
artistLayoutAlbums: 'Alben',
|
||||
artistLayoutFeatured: 'Auch enthalten auf',
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
sidebarFixed: 'Immer sichtbar',
|
||||
randomNavSplitTitle: 'Mix-Navigation aufteilen',
|
||||
|
||||
@@ -660,6 +660,14 @@ export const enTranslation = {
|
||||
homeCustomizerTitle: 'Home Page',
|
||||
sidebarTitle: 'Sidebar',
|
||||
sidebarReset: 'Reset to default',
|
||||
artistLayoutTitle: 'Artist page sections',
|
||||
artistLayoutDesc: 'Drag to reorder, toggle to hide individual sections of the artist page. Sections without data are skipped automatically.',
|
||||
artistLayoutReset: 'Reset to default',
|
||||
artistLayoutBio: 'Artist biography',
|
||||
artistLayoutTopTracks: 'Top tracks',
|
||||
artistLayoutSimilar: 'Similar artists',
|
||||
artistLayoutAlbums: 'Albums',
|
||||
artistLayoutFeatured: 'Also featured on',
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
sidebarFixed: 'Always visible',
|
||||
randomNavSplitTitle: 'Split Mix navigation',
|
||||
|
||||
@@ -651,6 +651,14 @@ export const esTranslation = {
|
||||
homeCustomizerTitle: 'Página de Inicio',
|
||||
sidebarTitle: 'Barra Lateral',
|
||||
sidebarReset: 'Restablecer a predeterminado',
|
||||
artistLayoutTitle: 'Secciones de la página del artista',
|
||||
artistLayoutDesc: 'Arrastra para reordenar, alterna para ocultar secciones individuales de la página del artista. Las secciones sin datos se omiten automáticamente.',
|
||||
artistLayoutReset: 'Restablecer a predeterminado',
|
||||
artistLayoutBio: 'Biografía del artista',
|
||||
artistLayoutTopTracks: 'Mejores pistas',
|
||||
artistLayoutSimilar: 'Artistas similares',
|
||||
artistLayoutAlbums: 'Álbumes',
|
||||
artistLayoutFeatured: 'También presente en',
|
||||
sidebarDrag: 'Arrastra para reordenar',
|
||||
sidebarFixed: 'Siempre visible',
|
||||
randomNavSplitTitle: 'Dividir navegación Mix',
|
||||
|
||||
@@ -646,6 +646,14 @@ export const frTranslation = {
|
||||
homeCustomizerTitle: 'Page d\'accueil',
|
||||
sidebarTitle: 'Barre latérale',
|
||||
sidebarReset: 'Réinitialiser',
|
||||
artistLayoutTitle: 'Sections de la page artiste',
|
||||
artistLayoutDesc: 'Glissez pour réorganiser, basculez pour masquer des sections individuelles de la page artiste. Les sections sans données sont ignorées automatiquement.',
|
||||
artistLayoutReset: 'Réinitialiser',
|
||||
artistLayoutBio: 'Biographie de l\'artiste',
|
||||
artistLayoutTopTracks: 'Titres populaires',
|
||||
artistLayoutSimilar: 'Artistes similaires',
|
||||
artistLayoutAlbums: 'Albums',
|
||||
artistLayoutFeatured: 'Apparaît aussi sur',
|
||||
sidebarDrag: 'Glisser pour réorganiser',
|
||||
sidebarFixed: 'Toujours visible',
|
||||
randomNavSplitTitle: 'Diviser la navigation Mix',
|
||||
|
||||
@@ -645,6 +645,14 @@ export const nbTranslation = {
|
||||
homeCustomizerTitle: 'Hjemmeside',
|
||||
sidebarTitle: 'Sidefelt',
|
||||
sidebarReset: 'Tilbakestill til standard',
|
||||
artistLayoutTitle: 'Artistsidens seksjoner',
|
||||
artistLayoutDesc: 'Dra for å omorganisere, veksle for å skjule individuelle seksjoner av artistsiden. Seksjoner uten data hoppes over automatisk.',
|
||||
artistLayoutReset: 'Tilbakestill til standard',
|
||||
artistLayoutBio: 'Artistbiografi',
|
||||
artistLayoutTopTracks: 'Toppspor',
|
||||
artistLayoutSimilar: 'Lignende artister',
|
||||
artistLayoutAlbums: 'Album',
|
||||
artistLayoutFeatured: 'Også med på',
|
||||
sidebarDrag: 'Dra for å endre rekkefølge',
|
||||
sidebarFixed: 'Alltid synlig',
|
||||
randomNavSplitTitle: 'Del Mix-navigasjon',
|
||||
|
||||
@@ -645,6 +645,14 @@ export const nlTranslation = {
|
||||
homeCustomizerTitle: 'Startpagina',
|
||||
sidebarTitle: 'Zijbalk',
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
artistLayoutTitle: 'Secties artiestenpagina',
|
||||
artistLayoutDesc: 'Versleep om te herschikken, schakel om individuele secties van de artiestenpagina te verbergen. Secties zonder gegevens worden automatisch overgeslagen.',
|
||||
artistLayoutReset: 'Standaard herstellen',
|
||||
artistLayoutBio: 'Biografie van de artiest',
|
||||
artistLayoutTopTracks: 'Populaire nummers',
|
||||
artistLayoutSimilar: 'Soortgelijke artiesten',
|
||||
artistLayoutAlbums: 'Albums',
|
||||
artistLayoutFeatured: 'Ook te horen op',
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
sidebarFixed: 'Altijd zichtbaar',
|
||||
randomNavSplitTitle: 'Mix-navigatie splitsen',
|
||||
|
||||
@@ -673,6 +673,14 @@ export const ruTranslation = {
|
||||
homeCustomizerTitle: 'Главная',
|
||||
sidebarTitle: 'Боковая панель',
|
||||
sidebarReset: 'Сбросить порядок',
|
||||
artistLayoutTitle: 'Разделы страницы исполнителя',
|
||||
artistLayoutDesc: 'Перетаскивайте, чтобы изменить порядок, переключайте, чтобы скрыть отдельные разделы страницы исполнителя. Разделы без данных пропускаются автоматически.',
|
||||
artistLayoutReset: 'Сбросить',
|
||||
artistLayoutBio: 'Биография исполнителя',
|
||||
artistLayoutTopTracks: 'Топ-треки',
|
||||
artistLayoutSimilar: 'Похожие исполнители',
|
||||
artistLayoutAlbums: 'Альбомы',
|
||||
artistLayoutFeatured: 'Также участвует в',
|
||||
sidebarDrag: 'Перетащите для порядка',
|
||||
sidebarFixed: 'Всегда показывать',
|
||||
randomNavSplitTitle: 'Разделить навигацию микса',
|
||||
|
||||
@@ -641,6 +641,14 @@ export const zhTranslation = {
|
||||
homeCustomizerTitle: '首页',
|
||||
sidebarTitle: '侧边栏',
|
||||
sidebarReset: '重置为默认',
|
||||
artistLayoutTitle: '艺术家页面板块',
|
||||
artistLayoutDesc: '拖动以重新排序,切换以隐藏艺术家页面的各个板块。没有数据的板块会自动跳过。',
|
||||
artistLayoutReset: '重置为默认',
|
||||
artistLayoutBio: '艺术家简介',
|
||||
artistLayoutTopTracks: '热门曲目',
|
||||
artistLayoutSimilar: '相似艺术家',
|
||||
artistLayoutAlbums: '专辑',
|
||||
artistLayoutFeatured: '也参与了',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
sidebarFixed: '始终显示',
|
||||
randomNavSplitTitle: '拆分混音导航',
|
||||
|
||||
+139
-103
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, Fragment } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
@@ -18,6 +18,7 @@ import { invalidateCoverArt } from '../utils/imageCache';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { extractCoverColors } from '../utils/dynamicColors';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -83,6 +84,9 @@ export default function ArtistDetail() {
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
// MUST stay above the loading / !artist early returns or React's hook
|
||||
// call order will mismatch between renders.
|
||||
const sectionConfig = useArtistLayoutStore(s => s.sections);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
@@ -436,6 +440,26 @@ export default function ArtistDetail() {
|
||||
(similarLoading || similarArtists.length > 0);
|
||||
const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar;
|
||||
|
||||
// ── User-customisable section order + visibility ────────────────────────────
|
||||
// (`sectionConfig` is read at the top of the component — see comment there)
|
||||
const sectionHasData = (id: ArtistSectionId): boolean => {
|
||||
switch (id) {
|
||||
case 'bio': return !!info?.biography;
|
||||
case 'topTracks': return topSongs.length > 0;
|
||||
case 'similar': return showSimilarSection;
|
||||
case 'albums': return true; // always renders (empty state included)
|
||||
case 'featured': return featuredLoading || featuredAlbums.length > 0;
|
||||
}
|
||||
};
|
||||
// The order the user actually sees: hidden-via-toggle and empty sections
|
||||
// are filtered out, so the "first rendered section gets marginTop: 0" rule
|
||||
// works regardless of the configured order.
|
||||
const renderableSectionIds = sectionConfig
|
||||
.filter(s => s.visible)
|
||||
.map(s => s.id)
|
||||
.filter(sectionHasData);
|
||||
const sectionMt = (id: ArtistSectionId) => renderableSectionIds[0] === id ? '0' : '2rem';
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<button
|
||||
@@ -598,40 +622,47 @@ export default function ArtistDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Biography — sanitized HTML from server */}
|
||||
{info?.biography && (
|
||||
<div className="np-info-card artist-bio-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
||||
</div>
|
||||
<div className="np-artist-bio-row">
|
||||
{(info.largeImageUrl || coverId) && (
|
||||
<img
|
||||
src={info.largeImageUrl || buildCoverArtUrl(coverId, 80)}
|
||||
alt={artist.name}
|
||||
className="np-artist-thumb"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div className="np-bio-wrap">
|
||||
<div
|
||||
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
|
||||
/>
|
||||
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
||||
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
|
||||
</button>
|
||||
{/* User-reorderable sections — order + visibility configured in Settings.
|
||||
* Each case renders the same JSX it did pre-refactor; only `marginTop`
|
||||
* (now derived from the actual render order) and the outer wrapper changed. */}
|
||||
{renderableSectionIds.map(sectionId => {
|
||||
switch (sectionId) {
|
||||
case 'bio': return (
|
||||
<div
|
||||
key="bio"
|
||||
className="np-info-card artist-bio-card"
|
||||
style={{ marginTop: sectionMt('bio') }}
|
||||
>
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
||||
</div>
|
||||
<div className="np-artist-bio-row">
|
||||
{(info?.largeImageUrl || coverId) && (
|
||||
<img
|
||||
src={info?.largeImageUrl || buildCoverArtUrl(coverId, 80)}
|
||||
alt={artist.name}
|
||||
className="np-artist-thumb"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div className="np-bio-wrap">
|
||||
<div
|
||||
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info!.biography!) }}
|
||||
/>
|
||||
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
||||
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
|
||||
{/* Top Songs */}
|
||||
{topSongs.length > 0 && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: info?.biography ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.topTracks')}
|
||||
</h2>
|
||||
case 'topTracks': return (
|
||||
<Fragment key="topTracks">
|
||||
<h2 className="section-title" style={{ marginTop: sectionMt('topTracks'), marginBottom: '1rem' }}>
|
||||
{t('artistDetail.topTracks')}
|
||||
</h2>
|
||||
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
@@ -684,80 +715,85 @@ export default function ArtistDetail() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
{showSimilarSection && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
{t('artistDetail.similarArtists')}
|
||||
</h2>
|
||||
{isMobile && (() => {
|
||||
const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists;
|
||||
return list.length > 5 ? (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSimilarCollapsed(v => !v)}>
|
||||
{similarCollapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
{similarCollapsed ? t('nowPlaying.readMore') : t('nowPlaying.showLess')}
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{showLastfmSimilar && similarLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
{t('artistDetail.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
|
||||
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
|
||||
.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="artist-ext-link"
|
||||
onClick={() => navigate(`/artist/${a.id}`)}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
case 'similar': return (
|
||||
<Fragment key="similar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: sectionMt('similar'), marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
{t('artistDetail.similarArtists')}
|
||||
</h2>
|
||||
{isMobile && (() => {
|
||||
const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists;
|
||||
return list.length > 5 ? (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSimilarCollapsed(v => !v)}>
|
||||
{similarCollapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
{similarCollapsed ? t('nowPlaying.readMore') : t('nowPlaying.showLess')}
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{showLastfmSimilar && similarLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
{t('artistDetail.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
|
||||
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
|
||||
.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="artist-ext-link"
|
||||
onClick={() => navigate(`/artist/${a.id}`)}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
{/* Albums */}
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || showSimilarSection) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
case 'albums': return (
|
||||
<Fragment key="albums">
|
||||
<h2 className="section-title" style={{ marginTop: sectionMt('albums'), marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
{albums.length > 0 ? (
|
||||
<div className="album-grid-wrap album-grid-wrap--artist">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
{albums.length > 0 ? (
|
||||
<div className="album-grid-wrap album-grid-wrap--artist">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
||||
)}
|
||||
case 'featured': return (
|
||||
<Fragment key="featured">
|
||||
<h2 className="section-title" style={{ marginTop: sectionMt('featured'), marginBottom: '1rem' }}>
|
||||
{t('artistDetail.featuredOn')}
|
||||
</h2>
|
||||
{featuredLoading ? (
|
||||
<div className="album-grid-wrap">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} style={{ flex: '0 0 clamp(140px, 15vw, 180px)', borderRadius: '8px', background: 'var(--bg-card)', aspectRatio: '1', opacity: 0.5 }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap album-grid-wrap--artist" style={{ animation: 'fadeIn 0.3s ease' }}>
|
||||
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
{/* Also Featured On */}
|
||||
{(featuredLoading || featuredAlbums.length > 0) && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.featuredOn')}
|
||||
</h2>
|
||||
{featuredLoading ? (
|
||||
<div className="album-grid-wrap">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} style={{ flex: '0 0 clamp(140px, 15vw, 180px)', borderRadius: '8px', background: 'var(--bg-card)', aspectRatio: '1', opacity: 0.5 }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap album-grid-wrap--artist" style={{ animation: 'fadeIn 0.3s ease' }}>
|
||||
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useFontStore, FontId } from '../store/fontStore';
|
||||
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
|
||||
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
|
||||
import { useArtistLayoutStore, type ArtistSectionId, type ArtistSectionConfig } from '../store/artistLayoutStore';
|
||||
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
@@ -2560,6 +2561,8 @@ export default function Settings() {
|
||||
</section>
|
||||
|
||||
<SidebarCustomizer />
|
||||
|
||||
<ArtistLayoutCustomizer />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3592,6 +3595,144 @@ function SidebarCustomizer() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Artist Page Sections Customizer ────────────────────────────────────────
|
||||
|
||||
const ARTIST_SECTION_LABEL_KEYS: Record<ArtistSectionId, string> = {
|
||||
bio: 'settings.artistLayoutBio',
|
||||
topTracks: 'settings.artistLayoutTopTracks',
|
||||
similar: 'settings.artistLayoutSimilar',
|
||||
albums: 'settings.artistLayoutAlbums',
|
||||
featured: 'settings.artistLayoutFeatured',
|
||||
};
|
||||
|
||||
type ArtistDropTarget = { idx: number; before: boolean } | null;
|
||||
|
||||
function ArtistSectionGripHandle({ idx, label }: { idx: number; label: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { onMouseDown } = useDragSource(() => ({
|
||||
data: JSON.stringify({ type: 'artist_section_reorder', index: idx }),
|
||||
label,
|
||||
}));
|
||||
return (
|
||||
<span
|
||||
className="sidebar-customizer-grip"
|
||||
data-tooltip={t('settings.sidebarDrag')}
|
||||
data-tooltip-pos="right"
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtistLayoutCustomizer() {
|
||||
const { t } = useTranslation();
|
||||
const sections = useArtistLayoutStore(s => s.sections);
|
||||
const setSections = useArtistLayoutStore(s => s.setSections);
|
||||
const toggleSection = useArtistLayoutStore(s => s.toggleSection);
|
||||
const reset = useArtistLayoutStore(s => s.reset);
|
||||
const { isDragging: isPsyDragging } = useDragDrop();
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<ArtistDropTarget>(null);
|
||||
const dropTargetRef = useRef<ArtistDropTarget>(null);
|
||||
const sectionsRef = useRef(sections);
|
||||
sectionsRef.current = sections;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||
}, [isPsyDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerEl) return;
|
||||
const onPsyDrop = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
let parsed: { type?: string; index?: number };
|
||||
try { parsed = JSON.parse(detail.data as string); } catch { return; }
|
||||
if (parsed.type !== 'artist_section_reorder' || parsed.index == null) return;
|
||||
|
||||
const fromIdx = parsed.index;
|
||||
const target = dropTargetRef.current;
|
||||
dropTargetRef.current = null; setDropTarget(null);
|
||||
if (!target) return;
|
||||
|
||||
const insertBefore = target.before ? target.idx : target.idx + 1;
|
||||
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
||||
|
||||
const next = [...sectionsRef.current];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||
setSections(next);
|
||||
};
|
||||
containerEl.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => containerEl.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [containerEl, setSections]);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isPsyDragging || !containerEl) return;
|
||||
const rows = containerEl.querySelectorAll<HTMLElement>('[data-artist-idx]');
|
||||
let target: ArtistDropTarget = null;
|
||||
for (const row of rows) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
const idx = Number(row.dataset.artistIdx);
|
||||
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
|
||||
target = { idx, before: false };
|
||||
}
|
||||
dropTargetRef.current = target;
|
||||
setDropTarget(target);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Users size={18} />
|
||||
<h2>{t('settings.artistLayoutTitle')}</h2>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
|
||||
onClick={reset}
|
||||
data-tooltip={t('settings.artistLayoutReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
|
||||
{t('settings.artistLayoutDesc')}
|
||||
</p>
|
||||
<div
|
||||
className="settings-card"
|
||||
style={{ padding: '4px 0' }}
|
||||
ref={setContainerEl}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{sections.map((section: ArtistSectionConfig, i) => {
|
||||
const label = t(ARTIST_SECTION_LABEL_KEYS[section.id]);
|
||||
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
|
||||
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
data-artist-idx={i}
|
||||
className="sidebar-customizer-row"
|
||||
style={{
|
||||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<ArtistSectionGripHandle idx={i} label={label} />
|
||||
<span style={{ flex: 1, fontSize: 14, opacity: section.visible ? 1 : 0.45 }}>{label}</span>
|
||||
<label className="toggle-switch" aria-label={label}>
|
||||
<input type="checkbox" checked={section.visible} onChange={() => toggleSection(section.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupSection() {
|
||||
const { t } = useTranslation();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type ArtistSectionId = 'bio' | 'topTracks' | 'similar' | 'albums' | 'featured';
|
||||
|
||||
export interface ArtistSectionConfig {
|
||||
id: ArtistSectionId;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default order matches the historical layout of `pages/ArtistDetail.tsx` so
|
||||
* existing users see no change until they explicitly customise it.
|
||||
*/
|
||||
export const DEFAULT_ARTIST_SECTIONS: ArtistSectionConfig[] = [
|
||||
{ id: 'bio', visible: true },
|
||||
{ id: 'topTracks', visible: true },
|
||||
{ id: 'similar', visible: true },
|
||||
{ id: 'albums', visible: true },
|
||||
{ id: 'featured', visible: true },
|
||||
];
|
||||
|
||||
interface ArtistLayoutStore {
|
||||
sections: ArtistSectionConfig[];
|
||||
setSections: (sections: ArtistSectionConfig[]) => void;
|
||||
toggleSection: (id: ArtistSectionId) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useArtistLayoutStore = create<ArtistLayoutStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sections: DEFAULT_ARTIST_SECTIONS,
|
||||
|
||||
setSections: (sections) => set({ sections }),
|
||||
|
||||
toggleSection: (id) => set((s) => ({
|
||||
sections: s.sections.map(sec => sec.id === id ? { ...sec, visible: !sec.visible } : sec),
|
||||
})),
|
||||
|
||||
reset: () => set({ sections: DEFAULT_ARTIST_SECTIONS }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_artist_layout',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
// Sanitize: drop null/corrupt entries, append any new sections that
|
||||
// were added in a later release so they don't silently disappear.
|
||||
const knownIds = new Set(DEFAULT_ARTIST_SECTIONS.map(s => s.id));
|
||||
const safe = (state.sections ?? [])
|
||||
.filter((s): s is ArtistSectionConfig => s != null && typeof s.id === 'string' && knownIds.has(s.id as ArtistSectionId));
|
||||
const seen = new Set(safe.map(s => s.id));
|
||||
const missing = DEFAULT_ARTIST_SECTIONS.filter(s => !seen.has(s.id));
|
||||
state.sections = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user