From e23280c013ebe97608e8c0e88023999a73da9583 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Wed, 15 Apr 2026 22:27:53 +0200 Subject: [PATCH] feat(sidebar): add split Mix navigation mode with separate sidebar entries Extract ALL_NAV_ITEMS to src/config/navItems.ts and add randomMix/randomAlbums nav items. A new toggle in Settings switches between a single "Build a Mix" hub and separate "Random Mix" / "Random Albums" sidebar entries (randomNavMode in authStore). Fixes reorder crash caused by hidden items being overwritten with undefined during the merge step. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 54 -------------------------------------- src/components/Sidebar.tsx | 37 +++++++++----------------- src/config/navItems.ts | 33 +++++++++++++++++++++++ src/locales/de.ts | 2 ++ src/locales/en.ts | 2 ++ src/locales/es.ts | 2 ++ src/locales/fr.ts | 2 ++ src/locales/nb.ts | 2 ++ src/locales/nl.ts | 2 ++ src/locales/ru.ts | 2 ++ src/locales/zh.ts | 2 ++ src/main.tsx | 6 ++--- src/pages/Settings.tsx | 34 +++++++++++++++++++++--- src/store/authStore.ts | 7 +++++ src/store/sidebarStore.ts | 10 ++++--- 15 files changed, 108 insertions(+), 89 deletions(-) create mode 100644 src/config/navItems.ts diff --git a/package-lock.json b/package-lock.json index 58bd8ce1..d421d022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1033,9 +1033,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1050,9 +1047,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1067,9 +1061,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1084,9 +1075,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1101,9 +1089,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1118,9 +1103,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1135,9 +1117,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1152,9 +1131,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1169,9 +1145,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1186,9 +1159,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1203,9 +1173,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1220,9 +1187,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1237,9 +1201,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1463,9 +1424,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1483,9 +1441,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1503,9 +1458,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1523,9 +1475,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1543,9 +1492,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 5f0f599a..44bd4286 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,33 +9,15 @@ import { useSidebarStore } from '../store/sidebarStore'; import { NavLink } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { - Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, - PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast, - ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle, HardDriveUpload, + Settings, + PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload, + ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; import { getPlaylists } from '../api/subsonic'; import { usePlaylistStore } from '../store/playlistStore'; - -// All configurable nav items — order and visibility controlled by sidebarStore. -// Exported so Settings can render the same item metadata. -export const ALL_NAV_ITEMS: Record = { - mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' }, - newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' }, - allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' }, - randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' }, - artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' }, - genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' }, - favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' }, - playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' }, - mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' }, - radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, - folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' }, - deviceSync: { icon: HardDriveUpload, labelKey: 'sidebar.deviceSync', to: '/device-sync', section: 'library' }, - statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' }, - help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' }, -}; +import { ALL_NAV_ITEMS } from '../config/navItems'; export default function Sidebar({ @@ -65,6 +47,7 @@ export default function Sidebar({ const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const sidebarItems = useSidebarStore(s => s.items); + const randomNavMode = useAuthStore(s => s.randomNavMode); const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false); const [playlistsExpanded, setPlaylistsExpanded] = useState(false); const playlistsRaw = usePlaylistStore(s => s.playlists); @@ -139,10 +122,16 @@ export default function Sidebar({ // Resolve ordered, visible items per section from store config const visibleLibrary = sidebarItems - .filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library') + .filter(cfg => { + if (cfg == null || !cfg.visible || ALL_NAV_ITEMS[cfg.id]?.section !== 'library') return false; + // Hide mode-inactive mix entries so the active mode controls what's shown + if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false; + if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; + return true; + }) .map(cfg => ALL_NAV_ITEMS[cfg.id]); const visibleSystem = sidebarItems - .filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system') + .filter(cfg => cfg != null && cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system') .map(cfg => ALL_NAV_ITEMS[cfg.id]); diff --git a/src/config/navItems.ts b/src/config/navItems.ts new file mode 100644 index 00000000..3cf474c2 --- /dev/null +++ b/src/config/navItems.ts @@ -0,0 +1,33 @@ +import React from 'react'; +import { + Disc3, Users, Music4, Radio, Heart, BarChart3, + HelpCircle, Tags, ListMusic, Cast, TrendingUp, + FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, +} from 'lucide-react'; + +export interface NavItemMeta { + icon: React.ElementType; + labelKey: string; + to: string; + section: 'library' | 'system'; +} + +// All configurable nav items — order and visibility controlled by sidebarStore. +export const ALL_NAV_ITEMS: Record = { + mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' }, + newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' }, + allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' }, + randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' }, + randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' }, + randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' }, + artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' }, + genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' }, + favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' }, + playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' }, + mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' }, + radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, + folderBrowser:{ icon: FolderOpen, labelKey: 'sidebar.folderBrowser',to: '/folders', section: 'library' }, + deviceSync: { icon: HardDriveUpload,labelKey: 'sidebar.deviceSync', to: '/device-sync', section: 'library' }, + statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' }, + help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' }, +}; diff --git a/src/locales/de.ts b/src/locales/de.ts index e1476561..1ebb53db 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -584,6 +584,8 @@ export const deTranslation = { sidebarReset: 'Zurücksetzen', sidebarDrag: 'Ziehen zum Umsortieren', sidebarFixed: 'Immer sichtbar', + randomNavSplitTitle: 'Mix-Navigation aufteilen', + randomNavSplitDesc: '"Zufallsmix" und "Zufallsalben" als separate Sidebar-Einträge statt als "Mix erstellen"-Hub anzeigen.', tabInput: 'Eingabe', tabServer: 'Server', shortcutsReset: 'Auf Standard zurücksetzen', diff --git a/src/locales/en.ts b/src/locales/en.ts index 44dd8df8..29bdffc4 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -586,6 +586,8 @@ export const enTranslation = { sidebarReset: 'Reset to default', sidebarDrag: 'Drag to reorder', sidebarFixed: 'Always visible', + randomNavSplitTitle: 'Split Mix navigation', + randomNavSplitDesc: 'Show "Random Mix" and "Random Albums" as separate sidebar entries instead of the "Build a Mix" hub.', tabInput: 'Input', tabServer: 'Server', tabSystem: 'System', diff --git a/src/locales/es.ts b/src/locales/es.ts index 3714bbb8..6f31c469 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -587,6 +587,8 @@ export const esTranslation = { sidebarReset: 'Restablecer a predeterminado', sidebarDrag: 'Arrastra para reordenar', sidebarFixed: 'Siempre visible', + randomNavSplitTitle: 'Dividir navegación Mix', + randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria" y "Álbumes Aleatorios" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".', tabInput: 'Entrada', tabServer: 'Servidor', tabSystem: 'Sistema', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 1731c21c..82485f9c 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -582,6 +582,8 @@ export const frTranslation = { sidebarReset: 'Réinitialiser', sidebarDrag: 'Glisser pour réorganiser', sidebarFixed: 'Toujours visible', + randomNavSplitTitle: 'Diviser la navigation Mix', + randomNavSplitDesc: 'Afficher "Mix Aléatoire" et "Albums Aléatoires" comme entrées séparées dans la barre latérale plutôt que le hub "Créer un Mix".', tabInput: 'Entrée', tabServer: 'Serveur', shortcutsReset: 'Réinitialiser', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 3435bbc1..44bfada7 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -581,6 +581,8 @@ export const nbTranslation = { sidebarReset: 'Tilbakestill til standard', sidebarDrag: 'Dra for å endre rekkefølge', sidebarFixed: 'Alltid synlig', + randomNavSplitTitle: 'Del Mix-navigasjon', + randomNavSplitDesc: 'Vis "Tilfeldig miks" og "Tilfeldige album" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.', tabShortcuts: 'Snarveier', tabServer: 'Tjener', tabSystem: 'System', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 0fa6841c..0863a858 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -581,6 +581,8 @@ export const nlTranslation = { sidebarReset: 'Standaard herstellen', sidebarDrag: 'Slepen om te herordenen', sidebarFixed: 'Altijd zichtbaar', + randomNavSplitTitle: 'Mix-navigatie splitsen', + randomNavSplitDesc: 'Toon "Willekeurige mix" en "Willekeurige albums" als afzonderlijke zijbalkitems in plaats van de "Mix samenstellen"-hub.', tabInput: 'Invoer', tabServer: 'Server', shortcutsReset: 'Standaard herstellen', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index b6285ea2..6c0be378 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -607,6 +607,8 @@ export const ruTranslation = { sidebarReset: 'Сбросить порядок', sidebarDrag: 'Перетащите для порядка', sidebarFixed: 'Всегда показывать', + randomNavSplitTitle: 'Разделить навигацию микса', + randomNavSplitDesc: 'Показывать «Случайный микс» и «Случайные альбомы» как отдельные пункты боковой панели вместо хаба «Собрать микс».', tabInput: 'Ввод', tabServer: 'Сервер', tabSystem: 'Система', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 2459f108..bd5797ac 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -577,6 +577,8 @@ export const zhTranslation = { sidebarReset: '重置为默认', sidebarDrag: '拖动以重新排序', sidebarFixed: '始终显示', + randomNavSplitTitle: '拆分混音导航', + randomNavSplitDesc: '在侧边栏中将"随机混音"和"随机专辑"显示为独立条目,而非"创建混音"合并入口。', tabInput: '输入', tabServer: '服务器', tabSystem: '系统', diff --git a/src/main.tsx b/src/main.tsx index 3cae3682..87869545 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './i18n'; @@ -7,7 +7,7 @@ import './styles/layout.css'; import './styles/components.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + - + ); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 2a465932..12f35c20 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -31,7 +31,7 @@ import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGloba import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore'; import { useHomeStore, HomeSectionId } from '../store/homeStore'; import { useDragDrop, useDragSource } from '../contexts/DragDropContext'; -import { ALL_NAV_ITEMS } from '../components/Sidebar'; +import { ALL_NAV_ITEMS } from '../config/navItems'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { switchActiveServer } from '../utils/switchActiveServer'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; @@ -2577,8 +2577,15 @@ function SidebarCustomizer() { const dropTargetRef = useRef(null); const itemsRef = useRef(items); itemsRef.current = items; + const randomNavMode = useAuthStore(s => s.randomNavMode); + const setRandomNavMode = useAuthStore(s => s.setRandomNavMode); - const libraryItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'library'); + const libraryItems = items.filter(cfg => { + if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false; + if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false; + if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; + return true; + }); const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system'); useEffect(() => { @@ -2608,10 +2615,13 @@ function SidebarCustomizer() { const [moved] = sectionItems.splice(fromIdx, 1); sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); - // Merge reordered section back into flat items array + // Merge reordered section back into flat items array. + // Only update positions of the *visible* items (same filter as libraryItems/systemItems) + // so hidden entries like randomMix/randomAlbums are never overwritten with undefined. const all = [...itemsRef.current]; + const visibleIds = new Set(sectionItems.map(c => c.id)); const positions = all.map((cfg, i) => ({ cfg, i })) - .filter(({ cfg }) => ALL_NAV_ITEMS[cfg.id]?.section === fromSection) + .filter(({ cfg }) => visibleIds.has(cfg.id)) .map(({ i }) => i); positions.forEach((pos, i) => { all[pos] = sectionItems[i]; }); setItems(all); @@ -2677,6 +2687,22 @@ function SidebarCustomizer() { +
+
+
+
{t('settings.randomNavSplitTitle')}
+
{t('settings.randomNavSplitDesc')}
+
+ +
+
{/* Library block */}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 1c1c0546..24c0794f 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -212,6 +212,11 @@ interface AuthState { setMixMinRatingArtist: (v: number) => void; setMusicFolders: (folders: Array<{ id: string; name: string }>) => void; setMusicLibraryFilter: (folderId: 'all' | string) => void; + + /** Navigation style for Mix pages: single hub ('hub') or separate sidebar entries ('separate'). */ + randomNavMode: 'hub' | 'separate'; + setRandomNavMode: (v: 'hub' | 'separate') => void; + logout: () => void; // Derived @@ -304,6 +309,7 @@ export const useAuthStore = create()( mixMinRatingSong: 0, mixMinRatingAlbum: 0, mixMinRatingArtist: 0, + randomNavMode: 'hub', musicFolders: [], musicLibraryFilterByServer: {}, musicLibraryFilterVersion: 0, @@ -457,6 +463,7 @@ export const useAuthStore = create()( setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }), setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }), setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }), + setRandomNavMode: (v) => set({ randomNavMode: v }), setMusicFolders: (folders) => { const sid = get().activeServerId; diff --git a/src/store/sidebarStore.ts b/src/store/sidebarStore.ts index e0e60d3d..70b8ac45 100644 --- a/src/store/sidebarStore.ts +++ b/src/store/sidebarStore.ts @@ -13,6 +13,8 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [ { id: 'newReleases', visible: true }, { id: 'allAlbums', visible: true }, { id: 'randomPicker', visible: true }, + { id: 'randomMix', visible: true }, + { id: 'randomAlbums', visible: true }, { id: 'artists', visible: true }, { id: 'genres', visible: true }, { id: 'favorites', visible: true }, @@ -49,11 +51,11 @@ export const useSidebarStore = create()( name: 'psysonic_sidebar', onRehydrateStorage: () => (state) => { if (!state) return; - const known = new Set(state.items.map(i => i.id)); + // Sanitize: remove any null/corrupted entries that may have been persisted + const safe = (state.items ?? []).filter((i): i is SidebarItemConfig => i != null && typeof i.id === 'string'); + const known = new Set(safe.map(i => i.id)); const missing = DEFAULT_SIDEBAR_ITEMS.filter(i => !known.has(i.id)); - if (missing.length > 0) { - state.items = [...state.items, ...missing]; - } + state.items = missing.length > 0 ? [...safe, ...missing] : safe; }, } )