From f7a721b7b1da32fa0150343d25a902aca448d0c0 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher Date: Tue, 21 Apr 2026 22:08:39 +0200 Subject: [PATCH] feat(artist-detail): user-configurable visibility and order of page sections (closes #252) (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Opus 4.7 (1M context) --- src/locales/de.ts | 8 ++ src/locales/en.ts | 8 ++ src/locales/es.ts | 8 ++ src/locales/fr.ts | 8 ++ src/locales/nb.ts | 8 ++ src/locales/nl.ts | 8 ++ src/locales/ru.ts | 8 ++ src/locales/zh.ts | 8 ++ src/pages/ArtistDetail.tsx | 242 +++++++++++++++++++-------------- src/pages/Settings.tsx | 141 +++++++++++++++++++ src/store/artistLayoutStore.ts | 58 ++++++++ 11 files changed, 402 insertions(+), 103 deletions(-) create mode 100644 src/store/artistLayoutStore.ts diff --git a/src/locales/de.ts b/src/locales/de.ts index e0f5a298..0258793a 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index c49c200d..4dc93ead 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/es.ts b/src/locales/es.ts index d39073fb..98d8b944 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b3816b2b..7c4112ca 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 9f6399f7..363b232e 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 321ea3d5..7f721f83 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index b22aa975..d79dce5b 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -673,6 +673,14 @@ export const ruTranslation = { homeCustomizerTitle: 'Главная', sidebarTitle: 'Боковая панель', sidebarReset: 'Сбросить порядок', + artistLayoutTitle: 'Разделы страницы исполнителя', + artistLayoutDesc: 'Перетаскивайте, чтобы изменить порядок, переключайте, чтобы скрыть отдельные разделы страницы исполнителя. Разделы без данных пропускаются автоматически.', + artistLayoutReset: 'Сбросить', + artistLayoutBio: 'Биография исполнителя', + artistLayoutTopTracks: 'Топ-треки', + artistLayoutSimilar: 'Похожие исполнители', + artistLayoutAlbums: 'Альбомы', + artistLayoutFeatured: 'Также участвует в', sidebarDrag: 'Перетащите для порядка', sidebarFixed: 'Всегда показывать', randomNavSplitTitle: 'Разделить навигацию микса', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index d25feb67..a54eb3c7 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -641,6 +641,14 @@ export const zhTranslation = { homeCustomizerTitle: '首页', sidebarTitle: '侧边栏', sidebarReset: '重置为默认', + artistLayoutTitle: '艺术家页面板块', + artistLayoutDesc: '拖动以重新排序,切换以隐藏艺术家页面的各个板块。没有数据的板块会自动跳过。', + artistLayoutReset: '重置为默认', + artistLayoutBio: '艺术家简介', + artistLayoutTopTracks: '热门曲目', + artistLayoutSimilar: '相似艺术家', + artistLayoutAlbums: '专辑', + artistLayoutFeatured: '也参与了', sidebarDrag: '拖动以重新排序', sidebarFixed: '始终显示', randomNavSplitTitle: '拆分混音导航', diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 55a53535..de0cc086 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -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 (
- {/* Biography — sanitized HTML from server */} - {info?.biography && ( -
-
-

{t('nowPlaying.aboutArtist')}

-
-
- {(info.largeImageUrl || coverId) && ( - {artist.name} { (e.target as HTMLImageElement).style.display = 'none'; }} - /> - )} -
-
- + {/* 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 ( +
+
+

{t('nowPlaying.aboutArtist')}

+
+
+ {(info?.largeImageUrl || coverId) && ( + {artist.name} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + )} +
+
+ +
+
-
-
- )} + ); - {/* Top Songs */} - {topSongs.length > 0 && ( - <> -

- {t('artistDetail.topTracks')} -

+ case 'topTracks': return ( + +

+ {t('artistDetail.topTracks')} +

#
@@ -684,80 +715,85 @@ export default function ArtistDetail() { ); })}
- - )} + + ); - {showSimilarSection && ( - <> -
-

- {t('artistDetail.similarArtists')} -

- {isMobile && (() => { - const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists; - return list.length > 5 ? ( - - ) : null; - })()} -
- {showLastfmSimilar && similarLoading ? ( -
-
- {t('artistDetail.loading')} -
- ) : ( -
- {(showAudiomuseSimilar ? serverSimilarArtists : similarArtists) - .slice(0, isMobile && similarCollapsed ? 5 : undefined) - .map(a => ( - - ))} -
- )} - - )} + case 'similar': return ( + +
+

+ {t('artistDetail.similarArtists')} +

+ {isMobile && (() => { + const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists; + return list.length > 5 ? ( + + ) : null; + })()} +
+ {showLastfmSimilar && similarLoading ? ( +
+
+ {t('artistDetail.loading')} +
+ ) : ( +
+ {(showAudiomuseSimilar ? serverSimilarArtists : similarArtists) + .slice(0, isMobile && similarCollapsed ? 5 : undefined) + .map(a => ( + + ))} +
+ )} + + ); - {/* Albums */} -

0 || showSimilarSection) ? '2rem' : '0', marginBottom: '1rem' }}> - {t('artistDetail.albumsBy', { name: artist.name })} -

+ case 'albums': return ( + +

+ {t('artistDetail.albumsBy', { name: artist.name })} +

+ {albums.length > 0 ? ( +
+ {albums.map(a => )} +
+ ) : ( +

{t('artistDetail.noAlbums')}

+ )} +
+ ); - {albums.length > 0 ? ( -
- {albums.map(a => )} -
- ) : ( -

{t('artistDetail.noAlbums')}

- )} + case 'featured': return ( + +

+ {t('artistDetail.featuredOn')} +

+ {featuredLoading ? ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {featuredAlbums.map(a => )} +
+ )} + + ); - {/* Also Featured On */} - {(featuredLoading || featuredAlbums.length > 0) && ( - <> -

- {t('artistDetail.featuredOn')} -

- {featuredLoading ? ( -
- {[...Array(4)].map((_, i) => ( -
- ))} -
- ) : ( -
- {featuredAlbums.map(a => )} -
- )} - - )} + default: return null; + } + })}
); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 2d5331ec..65b74e54 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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() { + + )} @@ -3592,6 +3595,144 @@ function SidebarCustomizer() { ); } +// ── Artist Page Sections Customizer ──────────────────────────────────────── + +const ARTIST_SECTION_LABEL_KEYS: Record = { + 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 ( + + + + ); +} + +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(null); + const [dropTarget, setDropTarget] = useState(null); + const dropTargetRef = useRef(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('[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 ( +
+
+ +

{t('settings.artistLayoutTitle')}

+ +
+

+ {t('settings.artistLayoutDesc')} +

+
+ {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 ( +
+ + {label} + +
+ ); + })} +
+
+ ); +} + function BackupSection() { const { t } = useTranslation(); const [exporting, setExporting] = useState(false); diff --git a/src/store/artistLayoutStore.ts b/src/store/artistLayoutStore.ts new file mode 100644 index 00000000..240c741b --- /dev/null +++ b/src/store/artistLayoutStore.ts @@ -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()( + 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; + }, + } + ) +);