feat(artist-detail): user-configurable visibility and order of page sections (closes #252) (#254)

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:
Frank Stellmacher
2026-04-21 22:08:39 +02:00
committed by GitHub
parent c40243dfea
commit f7a721b7b1
11 changed files with 402 additions and 103 deletions
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -673,6 +673,14 @@ export const ruTranslation = {
homeCustomizerTitle: 'Главная',
sidebarTitle: 'Боковая панель',
sidebarReset: 'Сбросить порядок',
artistLayoutTitle: 'Разделы страницы исполнителя',
artistLayoutDesc: 'Перетаскивайте, чтобы изменить порядок, переключайте, чтобы скрыть отдельные разделы страницы исполнителя. Разделы без данных пропускаются автоматически.',
artistLayoutReset: 'Сбросить',
artistLayoutBio: 'Биография исполнителя',
artistLayoutTopTracks: 'Топ-треки',
artistLayoutSimilar: 'Похожие исполнители',
artistLayoutAlbums: 'Альбомы',
artistLayoutFeatured: 'Также участвует в',
sidebarDrag: 'Перетащите для порядка',
sidebarFixed: 'Всегда показывать',
randomNavSplitTitle: 'Разделить навигацию микса',
+8
View File
@@ -641,6 +641,14 @@ export const zhTranslation = {
homeCustomizerTitle: '首页',
sidebarTitle: '侧边栏',
sidebarReset: '重置为默认',
artistLayoutTitle: '艺术家页面板块',
artistLayoutDesc: '拖动以重新排序,切换以隐藏艺术家页面的各个板块。没有数据的板块会自动跳过。',
artistLayoutReset: '重置为默认',
artistLayoutBio: '艺术家简介',
artistLayoutTopTracks: '热门曲目',
artistLayoutSimilar: '相似艺术家',
artistLayoutAlbums: '专辑',
artistLayoutFeatured: '也参与了',
sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示',
randomNavSplitTitle: '拆分混音导航',
+139 -103
View File
@@ -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>
);
}
+141
View File
@@ -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);
+58
View File
@@ -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;
},
}
)
);