From 651a3f276af164e84d5eb3ac45d4300699917273 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 15 May 2026 15:55:11 +0200 Subject: [PATCH] feat(settings): global Advanced Mode toggle + playlist page layout (#556) (#720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-element visibility toggle for the playlist detail page (Add Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) and reworks the way uncommon options are surfaced: instead of a per-tab collapsible group, a global "Advanced" toggle in the Settings header reveals all `advanced` sub-sections across every tab and marks each one with a small badge. Sets the pattern up so any future advanced option lives in its natural tab, gated by the same switch. - New `advancedSettingsEnabled` boolean on `authStore` (UiAppearance slice, persisted with the rest of the store). - `SettingsSubSection` gains an `advanced?: boolean` prop. Hidden when the toggle is off; renders an "Advanced" pill in the header when on. - Settings header gets a Toggle-Switch next to the search lupe. - `PersonalisationTab` flattens — Sidebar + Home stay always visible; Artist sections, Queue Toolbar, and the new Playlist layout get `advanced` and disappear by default. `PersonalisationAdvancedGroup` component + CSS removed. - New `playlistLayoutStore` (Zustand + persist, items[{id,visible}] + rehydrate sanitize) following the queueToolbarStore pattern. - `PlaylistHero` and `PlaylistSuggestions` gate the four toolbar buttons and the suggestions rail on the store directly. - One-time migration in MainApp on mount: if the user had opened the old per-tab Advanced group (`psysonic_personalisation_advanced_open === 'true'`) OR already customised any of the three sub-sections, Advanced Mode auto-enables on first launch. Idempotent via a localStorage flag; legacy key removed afterwards. - New i18n keys `settings.advancedMode`, `settings.advancedModeTooltip`, `settings.advancedBadge`, `settings.playlistLayout*` in all 9 locales. Reuses kveld9's design from PR #556; not merged because the locale split landed afterwards. Credited under the existing kveld9 entry in settingsCredits.ts. Co-authored-by: Kveld. --- CHANGELOG.md | 9 +++ src/app/MainApp.tsx | 5 ++ src/components/SettingsSubSection.tsx | 16 +++++ src/components/playlist/PlaylistHero.tsx | 43 +++++++----- .../playlist/PlaylistSuggestions.tsx | 5 ++ .../settings/PersonalisationTab.tsx | 64 +++++++++++------ .../settings/PlaylistLayoutCustomizer.tsx | 44 ++++++++++++ src/config/settingsCredits.ts | 1 + src/locales/de/settings.ts | 6 ++ src/locales/en/settings.ts | 6 ++ src/locales/es/settings.ts | 6 ++ src/locales/fr/settings.ts | 6 ++ src/locales/nb/settings.ts | 6 ++ src/locales/nl/settings.ts | 6 ++ src/locales/ro/settings.ts | 6 ++ src/locales/ru/settings.ts | 6 ++ src/locales/zh/settings.ts | 6 ++ src/pages/Settings.tsx | 20 +++++- src/store/authStore.ts | 1 + src/store/authStoreTypes.ts | 3 + src/store/authUiAppearanceActions.ts | 2 + src/store/playlistLayoutStore.ts | 68 +++++++++++++++++++ src/styles/components/settings.css | 51 ++++++++++++++ src/utils/migrations/advancedModeMigration.ts | 57 ++++++++++++++++ 24 files changed, 404 insertions(+), 39 deletions(-) create mode 100644 src/components/settings/PlaylistLayoutCustomizer.tsx create mode 100644 src/store/playlistLayoutStore.ts create mode 100644 src/utils/migrations/advancedModeMigration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19080ce7..970d418d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,15 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * Shared tracks and queues resolve against the matching saved server via explicit credentials; `orbitBulkGuard` applies before bulk enqueue. * i18n: `search.share*` keys across all 9 locales. +### Settings — Advanced Mode toggle + playlist page layout + +**By [@kveld9](https://github.com/kveld9) + [@Psychotoxical](https://github.com/Psychotoxical), PR [#556](https://github.com/Psychotoxical/psysonic/pull/556)** + +* **Advanced Mode.** A new toggle in the Settings header reveals advanced sub-sections across all tabs. Sub-sections marked advanced are hidden by default and labelled with a small badge when shown — community-contributed options that do not necessarily reflect the design philosophy of the Psysonic maintainers, kept available but out of the way. The three current advanced sub-sections live under **Personalisation**: Artist page sections, Queue Toolbar, and the new Playlist page layout. +* **Playlist page layout.** New sub-section that hides individual elements on the playlist page: **Add Songs**, **Import CSV**, **Download ZIP**, **Cache Offline**, and the **Suggestions** rail at the bottom. Persisted via a new `playlistLayoutStore` (Zustand + localStorage); all toggles on by default so existing playlists look unchanged. +* **One-time migration:** if you had previously opened the per-tab Advanced group or customised any of the three sub-sections, Advanced Mode is auto-enabled on first launch — your existing tweaks stay visible. +* i18n: `settings.advancedMode`, `settings.advancedModeTooltip`, `settings.advancedBadge`, and `settings.playlistLayout*` across all 9 locales. + ## Changed ### Backend — Cargo workspace with 5 domain crates (Rust refactor) diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 3439c279..57987d68 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -13,6 +13,7 @@ import { useAuthStore } from '../store/authStore'; import { useGlobalShortcutsStore } from '../store/globalShortcutsStore'; import { initHotCachePrefetch } from '../hotCachePrefetch'; import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; +import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration'; import { IS_WINDOWS } from '../utils/platform'; import TauriEventBridge from './TauriEventBridge'; import AppShell from './AppShell'; @@ -29,6 +30,10 @@ const Login = lazy(() => import('../pages/Login')); export default function MainApp() { const [exportPickerOpen, setExportPickerOpen] = useState(false); + // One-time bridge from the per-tab Advanced group (v1.46) to the global + // Advanced Mode toggle. Idempotent — flagged in localStorage. + useEffect(() => { runAdvancedModeMigration(); }, []); + // Push playback state to mini window + handle control events. useEffect(() => { return initMiniPlayerBridgeOnMain(); diff --git a/src/components/SettingsSubSection.tsx b/src/components/SettingsSubSection.tsx index f0a37df6..55cad140 100644 --- a/src/components/SettingsSubSection.tsx +++ b/src/components/SettingsSubSection.tsx @@ -1,5 +1,7 @@ import React, { useId } from 'react'; import { ChevronDown } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../store/authStore'; interface SettingsSubSectionProps { title: string; @@ -10,6 +12,8 @@ interface SettingsSubSectionProps { // Rechts im Summary neben dem Chevron (z.B. Reset-Button). Clicks werden // gestoppt, damit sie das Accordion nicht togglen. action?: React.ReactNode; + /** Hidden unless the global Advanced Mode toggle is on; renders a small badge when visible. */ + advanced?: boolean; children: React.ReactNode; } @@ -23,13 +27,20 @@ export default function SettingsSubSection({ description, searchText, action, + advanced = false, children, }: SettingsSubSectionProps) { + const { t } = useTranslation(); + const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled); const headingId = useId(); + + if (advanced && !advancedSettingsEnabled) return null; + return (
{icon && {icon}} {title} + {advanced && ( + + )} {action && ( s.enableCoverArtBackground); const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto); + const layoutItems = usePlaylistLayoutStore(s => s.items); + const isLayoutVisible = (id: PlaylistLayoutItemId) => + layoutItems.find(i => i.id === id)?.visible !== false; return (
@@ -158,23 +162,26 @@ export default function PlaylistHero({
- - - {/* search close resets selection */} - {songs.length > 0 && ( + {isLayoutVisible('addSongs') && ( + + )} + {isLayoutVisible('importCsv') && ( + + )} + {isLayoutVisible('downloadZip') && songs.length > 0 && ( activeZip && !activeZip.done && !activeZip.error ? (
@@ -189,7 +196,7 @@ export default function PlaylistHero({ ) )} - {songs.length > 0 && id && ( + {isLayoutVisible('offlineCache') && songs.length > 0 && id && ( - } - > - - - } @@ -71,9 +54,30 @@ export function PersonalisationTab() { + } + advanced + action={ + + } + > + + + } + advanced action={ + } + > + + ); } diff --git a/src/components/settings/PlaylistLayoutCustomizer.tsx b/src/components/settings/PlaylistLayoutCustomizer.tsx new file mode 100644 index 00000000..0ed1606d --- /dev/null +++ b/src/components/settings/PlaylistLayoutCustomizer.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next'; +import { Download, FileUp, HardDrive, Lightbulb, Search } from 'lucide-react'; +import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore'; + +const PLAYLIST_LAYOUT_ICONS: Record = { + addSongs: Search, + importCsv: FileUp, + downloadZip: Download, + offlineCache: HardDrive, + suggestions: Lightbulb, +}; + +const PLAYLIST_LAYOUT_LABEL_KEYS: Record = { + addSongs: 'playlists.addSongs', + importCsv: 'playlists.importCSV', + downloadZip: 'playlists.downloadZip', + offlineCache: 'playlists.cacheOffline', + suggestions: 'playlists.suggestions', +}; + +export function PlaylistLayoutCustomizer() { + const { t } = useTranslation(); + const items = usePlaylistLayoutStore(s => s.items); + const toggleItem = usePlaylistLayoutStore(s => s.toggleItem); + + return ( +
+ {items.map((it) => { + const Icon = PLAYLIST_LAYOUT_ICONS[it.id]; + const label = t(PLAYLIST_LAYOUT_LABEL_KEYS[it.id]); + return ( +
+ + {label} + +
+ ); + })} +
+ ); +} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index d0c4c56d..a24fe3bc 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -168,6 +168,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Queue Panel — position counter, tri-state duration toggle (total/remaining/ETA), persistent Now Playing collapse, animated EQ indicator (PR #419)', 'Community themes — redesign pass: removed 5 overlapping / eye-straining palettes, added 8 new dark themes (Obsidian Black, Carbon Grey, Volcanic Dark, Forest Green, Violet Haze, Copper Oxide, Sakura Night, Obsidian Gold) (PR #490)', 'Customizable queue toolbar — drag-and-drop button reordering, per-button visibility toggles, optional separator, persisted across restarts (PR #534)', + 'Playlist page layout — per-element visibility toggles (Add Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) under Settings → Personalisation (PR #556)', ], }, { diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index d37391e1..ff9d51a7 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -270,6 +270,12 @@ export const settings = { artistLayoutSimilar: 'Ähnliche Künstler*innen', artistLayoutAlbums: 'Alben', artistLayoutFeatured: 'Auch enthalten auf', + playlistLayoutTitle: 'Playlist-Seitenlayout', + playlistLayoutDesc: 'Einzelne Elemente auf der Playlist-Seite ein- oder ausblenden.', + playlistLayoutReset: 'Zurücksetzen', + advancedMode: 'Erweitert', + advancedModeTooltip: 'Erweiterte Optionen in allen Settings-Tabs anzeigen. Community-Beiträge, die nicht zwingend der Design-Philosophie der Maintainer von Psysonic entsprechen.', + advancedBadge: 'Erweitert', sidebarDrag: 'Ziehen zum Umsortieren', sidebarFixed: 'Immer sichtbar', randomNavSplitTitle: 'Mix-Navigation aufteilen', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 02ddb3f8..0537da93 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -273,6 +273,12 @@ export const settings = { artistLayoutSimilar: 'Similar artists', artistLayoutAlbums: 'Albums', artistLayoutFeatured: 'Also featured on', + playlistLayoutTitle: 'Playlist page layout', + playlistLayoutDesc: 'Show or hide individual elements on the playlist page.', + playlistLayoutReset: 'Reset to default', + advancedMode: 'Advanced', + advancedModeTooltip: 'Show advanced options across all Settings tabs. Community contributions that do not necessarily reflect the design philosophy of the Psysonic maintainers.', + advancedBadge: 'Advanced', sidebarDrag: 'Drag to reorder', sidebarFixed: 'Always visible', randomNavSplitTitle: 'Split Mix navigation', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 86718e1e..3f90cdb9 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -270,6 +270,12 @@ export const settings = { artistLayoutSimilar: 'Artistas similares', artistLayoutAlbums: 'Álbumes', artistLayoutFeatured: 'También presente en', + playlistLayoutTitle: 'Diseño de la página de playlists', + playlistLayoutDesc: 'Mostrar u ocultar elementos individuales en la página de playlists.', + playlistLayoutReset: 'Restablecer a predeterminado', + advancedMode: 'Avanzado', + advancedModeTooltip: 'Mostrar opciones avanzadas en todas las pestañas de ajustes. Aportes de la comunidad que no reflejan necesariamente la filosofía de diseño de los mantenedores de Psysonic.', + advancedBadge: 'Avanzado', sidebarDrag: 'Arrastra para reordenar', sidebarFixed: 'Siempre visible', randomNavSplitTitle: 'Dividir navegación Mix', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index d6384edb..1492db6b 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -268,6 +268,12 @@ export const settings = { artistLayoutSimilar: 'Artistes similaires', artistLayoutAlbums: 'Albums', artistLayoutFeatured: 'Apparaît aussi sur', + playlistLayoutTitle: 'Mise en page de la playlist', + playlistLayoutDesc: 'Afficher ou masquer des éléments individuels sur la page de la playlist.', + playlistLayoutReset: 'Réinitialiser', + advancedMode: 'Avancé', + advancedModeTooltip: 'Afficher les options avancées dans tous les onglets des paramètres. Contributions de la communauté qui ne reflètent pas nécessairement la philosophie de conception des mainteneurs de Psysonic.', + advancedBadge: 'Avancé', sidebarDrag: 'Glisser pour réorganiser', sidebarFixed: 'Toujours visible', randomNavSplitTitle: 'Diviser la navigation Mix', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index ca7fd64b..249e1df8 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -267,6 +267,12 @@ export const settings = { artistLayoutSimilar: 'Lignende artister', artistLayoutAlbums: 'Album', artistLayoutFeatured: 'Også med på', + playlistLayoutTitle: 'Sideoppsett for spilleliste', + playlistLayoutDesc: 'Vis eller skjul individuelle elementer på spillelistesiden.', + playlistLayoutReset: 'Tilbakestill til standard', + advancedMode: 'Avansert', + advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.', + advancedBadge: 'Avansert', sidebarDrag: 'Dra for å endre rekkefølge', sidebarFixed: 'Alltid synlig', randomNavSplitTitle: 'Del Mix-navigasjon', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 037d5abf..1f7f358a 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -268,6 +268,12 @@ export const settings = { artistLayoutSimilar: 'Soortgelijke artiesten', artistLayoutAlbums: 'Albums', artistLayoutFeatured: 'Ook te horen op', + playlistLayoutTitle: 'Afspeellijst pagina-indeling', + playlistLayoutDesc: 'Individuele elementen op de afspeellijstpagina tonen of verbergen.', + playlistLayoutReset: 'Standaard herstellen', + advancedMode: 'Geavanceerd', + advancedModeTooltip: 'Toon geavanceerde opties in alle instellingstabbladen. Bijdragen van de community die niet noodzakelijk de ontwerpfilosofie van de Psysonic-onderhouders weerspiegelen.', + advancedBadge: 'Geavanceerd', sidebarDrag: 'Slepen om te herordenen', sidebarFixed: 'Altijd zichtbaar', randomNavSplitTitle: 'Mix-navigatie splitsen', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index c1535c83..558a2f96 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -273,6 +273,12 @@ export const settings = { artistLayoutSimilar: 'Artiști similari', artistLayoutAlbums: 'Albume', artistLayoutFeatured: 'Prezentat și pe', + playlistLayoutTitle: 'Aspect pagină playlist', + playlistLayoutDesc: 'Afișează sau ascunde elemente individuale pe pagina playlistului.', + playlistLayoutReset: 'Resetare la implicit', + advancedMode: 'Avansat', + advancedModeTooltip: 'Afișează opțiuni avansate în toate filele de setări. Contribuții ale comunității care nu reflectă neapărat filozofia de design a mentenanților Psysonic.', + advancedBadge: 'Avansat', sidebarDrag: 'Trage pentru a reordona', sidebarFixed: 'Mereu vizibil', randomNavSplitTitle: 'Navigare Mix împărțită', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index cf59ae2a..f5f79457 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -284,6 +284,12 @@ export const settings = { artistLayoutSimilar: 'Похожие исполнители', artistLayoutAlbums: 'Альбомы', artistLayoutFeatured: 'Также участвует в', + playlistLayoutTitle: 'Макет страницы плейлиста', + playlistLayoutDesc: 'Показать или скрыть отдельные элементы на странице плейлиста.', + playlistLayoutReset: 'Сбросить', + advancedMode: 'Дополнительно', + advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.', + advancedBadge: 'Дополнительно', sidebarDrag: 'Перетащите для порядка', sidebarFixed: 'Всегда показывать', randomNavSplitTitle: 'Разделить навигацию микса', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index d59b189b..63c147e6 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -268,6 +268,12 @@ export const settings = { artistLayoutSimilar: '相似艺术家', artistLayoutAlbums: '专辑', artistLayoutFeatured: '也参与了', + playlistLayoutTitle: '播放列表页面布局', + playlistLayoutDesc: '在播放列表页面上显示或隐藏各个元素。', + playlistLayoutReset: '重置为默认', + advancedMode: '高级', + advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。', + advancedBadge: '高级', sidebarDrag: '拖动以重新排序', sidebarFixed: '始终显示', randomNavSplitTitle: '拆分混音导航', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index bddcc487..1982a59c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -178,7 +178,7 @@ export default function Settings() { onClick={() => setSearchOpen(true)} aria-label={t('settings.searchPlaceholder')} data-tooltip={t('settings.searchPlaceholder')} - data-tooltip-pos="left" + data-tooltip-pos="bottom" > @@ -229,6 +229,24 @@ export default function Settings() {
)} + {/* Tab navigation */} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index fa786245..906db07c 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -86,6 +86,7 @@ export const useAuthStore = create()( fsPortraitDim: 28, showChangelogOnUpdate: true, lastSeenChangelogVersion: '', + advancedSettingsEnabled: false, seekbarStyle: 'truewave', queueNowPlayingCollapsed: false, enableHiRes: false, diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index b9c4a08f..3e8751fe 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -130,6 +130,8 @@ export interface AuthState { fsPortraitDim: number; showChangelogOnUpdate: boolean; lastSeenChangelogVersion: string; + /** Reveals sub-sections marked `advanced` across all Settings tabs. */ + advancedSettingsEnabled: boolean; seekbarStyle: SeekbarStyle; /** Persisted UI toggle: is the Now Playing section in queue panel collapsed */ @@ -284,6 +286,7 @@ export interface AuthState { setFsPortraitDim: (v: number) => void; setShowChangelogOnUpdate: (v: boolean) => void; setLastSeenChangelogVersion: (v: string) => void; + setAdvancedSettingsEnabled: (v: boolean) => void; setSeekbarStyle: (v: SeekbarStyle) => void; setQueueNowPlayingCollapsed: (v: boolean) => void; setEnableHiRes: (v: boolean) => void; diff --git a/src/store/authUiAppearanceActions.ts b/src/store/authUiAppearanceActions.ts index bd964c05..ad76b1bd 100644 --- a/src/store/authUiAppearanceActions.ts +++ b/src/store/authUiAppearanceActions.ts @@ -29,6 +29,7 @@ export function createUiAppearanceActions(set: SetState): Pick< | 'setFsPortraitDim' | 'setShowChangelogOnUpdate' | 'setLastSeenChangelogVersion' + | 'setAdvancedSettingsEnabled' > { return { setShowArtistImages: (v) => set({ showArtistImages: v }), @@ -48,5 +49,6 @@ export function createUiAppearanceActions(set: SetState): Pick< setFsPortraitDim: (v) => set({ fsPortraitDim: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), + setAdvancedSettingsEnabled: (v) => set({ advancedSettingsEnabled: v }), }; } diff --git a/src/store/playlistLayoutStore.ts b/src/store/playlistLayoutStore.ts new file mode 100644 index 00000000..207856f8 --- /dev/null +++ b/src/store/playlistLayoutStore.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export type PlaylistLayoutItemId = + | 'addSongs' + | 'importCsv' + | 'downloadZip' + | 'offlineCache' + | 'suggestions'; + +export interface PlaylistLayoutItemConfig { + id: PlaylistLayoutItemId; + visible: boolean; +} + +export const DEFAULT_PLAYLIST_LAYOUT_ITEMS: PlaylistLayoutItemConfig[] = [ + { id: 'addSongs', visible: true }, + { id: 'importCsv', visible: true }, + { id: 'downloadZip', visible: true }, + { id: 'offlineCache', visible: true }, + { id: 'suggestions', visible: true }, +]; + +interface PlaylistLayoutStore { + items: PlaylistLayoutItemConfig[]; + setItems: (items: PlaylistLayoutItemConfig[]) => void; + toggleItem: (id: PlaylistLayoutItemId) => void; + reset: () => void; +} + +export const usePlaylistLayoutStore = create()( + persist( + (set) => ({ + items: DEFAULT_PLAYLIST_LAYOUT_ITEMS, + + setItems: (items) => set({ items }), + + toggleItem: (id) => set((s) => ({ + items: s.items.map(it => it.id === id ? { ...it, visible: !it.visible } : it), + })), + + reset: () => set({ items: DEFAULT_PLAYLIST_LAYOUT_ITEMS }), + }), + { + name: 'psysonic_playlist_layout', + onRehydrateStorage: () => (state) => { + if (!state) return; + const knownIds = new Set(DEFAULT_PLAYLIST_LAYOUT_ITEMS.map(i => i.id)); + const safe = (state.items ?? []) + .filter((i): i is PlaylistLayoutItemConfig => + i != null && typeof i.id === 'string' && knownIds.has(i.id as PlaylistLayoutItemId)); + const seen = new Set(safe.map(i => i.id)); + const missing = DEFAULT_PLAYLIST_LAYOUT_ITEMS.filter(i => !seen.has(i.id)); + state.items = missing.length > 0 ? [...safe, ...missing] : safe; + }, + } + ) +); + +export function isPlaylistLayoutCustomized(items: PlaylistLayoutItemConfig[]): boolean { + if (items.length !== DEFAULT_PLAYLIST_LAYOUT_ITEMS.length) return true; + for (let i = 0; i < items.length; i++) { + const cur = items[i]; + const def = DEFAULT_PLAYLIST_LAYOUT_ITEMS[i]; + if (cur.id !== def.id || cur.visible !== def.visible) return true; + } + return false; +} diff --git a/src/styles/components/settings.css b/src/styles/components/settings.css index 40b552fc..5e5a2b77 100644 --- a/src/styles/components/settings.css +++ b/src/styles/components/settings.css @@ -145,6 +145,51 @@ display: none; } +/* ── Advanced Mode: Header-Toggle + Sub-Section Badge ────────────────────── + Globaler Schalter im Settings-Header schaltet das Sichtbar-Werden aller + Bloecke um. Der Badge markiert die jeweilige + Section nochmal optisch sobald sie eingeblendet ist. */ +.settings-advanced-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; + user-select: none; + flex-shrink: 0; + padding: 4px 8px; + border-radius: var(--radius-md); + transition: background 120ms ease; + /* Lupe sitzt neben dem Titel; der Toggle wird durch auto-margin ans rechte + Ende der Header-Zeile geschoben. */ + margin-left: auto; +} + +.settings-advanced-toggle:hover { + background: color-mix(in srgb, var(--accent) 6%, transparent); +} + +.settings-advanced-toggle-label { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; +} + +.settings-sub-section-advanced-badge { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 4px; + color: var(--text-muted); + background: color-mix(in srgb, var(--accent) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); + flex-shrink: 0; + margin-right: var(--space-2); +} + /* ── In-Page-Suche fuer Settings ─────────────────────────────────────────── */ .settings-header { display: flex; @@ -156,12 +201,18 @@ .settings-header .page-title { margin-bottom: 0; + line-height: 1; } .settings-search { display: flex; align-items: center; flex-shrink: 0; + /* h1.page-title hat im Display-Font einen visuellen Glyph-Mittelpunkt + unterhalb der Box-Mitte. align-items: center wirkt deshalb so, als hänge + die Lupe zu hoch. Wir verschieben sie auf die Glyph-Baseline runter. */ + align-self: end; + padding-bottom: 4px; } .settings-search-wrap { diff --git a/src/utils/migrations/advancedModeMigration.ts b/src/utils/migrations/advancedModeMigration.ts new file mode 100644 index 00000000..25297630 --- /dev/null +++ b/src/utils/migrations/advancedModeMigration.ts @@ -0,0 +1,57 @@ +import { useAuthStore } from '../../store/authStore'; +import { DEFAULT_ARTIST_SECTIONS, useArtistLayoutStore } from '../../store/artistLayoutStore'; +import { DEFAULT_QUEUE_TOOLBAR_BUTTONS, useQueueToolbarStore } from '../../store/queueToolbarStore'; +import { DEFAULT_PLAYLIST_LAYOUT_ITEMS, usePlaylistLayoutStore } from '../../store/playlistLayoutStore'; + +const MIGRATION_FLAG = 'psysonic_advanced_mode_migrated'; +const LEGACY_OPEN_KEY = 'psysonic_personalisation_advanced_open'; + +function arraysEqual(a: T[], b: T[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].id !== b[i].id || a[i].visible !== b[i].visible) return false; + } + return true; +} + +/** + * One-time bridge from the v1.46 "Personalisation → Advanced group" (per-tab + * collapsible) to the global Advanced Mode toggle. Auto-enables the new toggle + * for users who had previously opened the collapsible OR already customised any + * of the three sub-sections that now sit behind it. Idempotent via a localStorage + * flag — runs at most once per install. + */ +export function runAdvancedModeMigration(): void { + try { + if (localStorage.getItem(MIGRATION_FLAG) === '1') return; + } catch { + return; + } + + const legacyOpen = (() => { + try { return localStorage.getItem(LEGACY_OPEN_KEY) === 'true'; } catch { return false; } + })(); + const artistCustomised = !arraysEqual( + useArtistLayoutStore.getState().sections, + DEFAULT_ARTIST_SECTIONS, + ); + const queueCustomised = !arraysEqual( + useQueueToolbarStore.getState().buttons, + DEFAULT_QUEUE_TOOLBAR_BUTTONS, + ); + const playlistCustomised = !arraysEqual( + usePlaylistLayoutStore.getState().items, + DEFAULT_PLAYLIST_LAYOUT_ITEMS, + ); + + if (legacyOpen || artistCustomised || queueCustomised || playlistCustomised) { + useAuthStore.getState().setAdvancedSettingsEnabled(true); + } + + try { + localStorage.removeItem(LEGACY_OPEN_KEY); + localStorage.setItem(MIGRATION_FLAG, '1'); + } catch { + // best effort — next boot retries + } +}