mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(playlists): local playlist folders (sidebar + page, DnD, view toggle) (#1119)
* feat(playlists): playlist folder model — store + pure grouping core Local, per-server folder layer over the server's flat playlist list (the Subsonic API has no folder concept). Adds: - playlistFolders.ts: shared types + pure groupPlaylistsByFolder (used by every surface), with full unit coverage. - playlistFolderStore.ts: persisted Zustand store (create/rename/delete/ assign/collapse), per-server scoped; deleting a folder drops its assignments so playlists fall back to ungrouped. UI surfaces (sidebar + Playlists page) build on this in following commits. * i18n(playlists): folder strings across all 9 locales Adds the playlists.folders.* namespace (folder names, move/remove, expand/ collapse, group-by-folders toggle, count plurals, and the local-only notice explaining that Navidrome and the Subsonic API have no native folder support). * feat(playlists): folder views, drag-to-folder, move-to-folder menu, view toggle Surfaces the local folder layer on both the Playlists page and the sidebar: - Page: collapsible folder sections + ungrouped remainder, each reusing VirtualCardGrid; flat grid is kept verbatim when no folders exist or the group view is toggled off. - Drag-to-folder via the shared mouse-based psy-drop system (HTML5 DnD is unusable in WebKitGTK); the whole section is the drop zone, and the ungrouped zone appears during a drag so a playlist can always return to root. - "Move to folder" submenu in the playlist context menu (keyboard-accessible path; also creates folders on the fly) — stays available offline. - Header gets a "New folder" action and a "Group by folders" view toggle (both context-aware); a notice surfaces the local-only caveat. - Sidebar renders the same collapsible folder groups. - groupView preference added to the folder store. * docs(changelog): note playlist folders (#1119)
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, HardDriveDownload, PlayCircle, Settings, Sparkles } from 'lucide-react';
|
||||
import { AudioLines, ChevronRight, HardDriveDownload, Settings } from 'lucide-react';
|
||||
import type { SidebarItemConfig } from '../../store/sidebarStore';
|
||||
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||
import WhatsNewBanner from '../WhatsNewBanner';
|
||||
import ThemeUpdateBanner from '../ThemeUpdateBanner';
|
||||
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/componentHelpers/sidebarHelpers';
|
||||
import SidebarLibraryPicker from './SidebarLibraryPicker';
|
||||
import SidebarPlaylistsSection from './SidebarPlaylistsSection';
|
||||
import SidebarActiveJobs from './SidebarActiveJobs';
|
||||
|
||||
interface NavDndState {
|
||||
@@ -146,26 +146,7 @@ export default function SidebarNavBody(props: Props) {
|
||||
</button>
|
||||
</div>
|
||||
{playlistsExpanded && (
|
||||
<div className="sidebar-playlists-list">
|
||||
{playlistsLoading ? (
|
||||
<div className="sidebar-playlists-loading">
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
|
||||
) : (
|
||||
playlists.map((pl: { id: string; name: string }) => (
|
||||
<NavLink
|
||||
key={pl.id}
|
||||
to={`/playlists/${pl.id}`}
|
||||
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
|
||||
<span>{displayPlaylistName(pl.name)}</span>
|
||||
</NavLink>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<SidebarPlaylistsSection playlists={playlists} playlistsLoading={playlistsLoading} />
|
||||
)}
|
||||
</div>
|
||||
) : isCollapsed ? (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight, Folder, PlayCircle, Sparkles } from 'lucide-react';
|
||||
import { displayPlaylistName, isSmartPlaylistName } from '../../utils/componentHelpers/sidebarHelpers';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '../../store/playlistFolderStore';
|
||||
import { groupPlaylistsByFolder } from '../../utils/playlist/playlistFolders';
|
||||
|
||||
interface SidebarPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
playlists: SidebarPlaylist[];
|
||||
playlistsLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar playlist list, grouped into collapsible folders when the active
|
||||
* server has any. Folder state comes from the shared local folder store;
|
||||
* creation / rename / deletion lives on the Playlists page, while assignment
|
||||
* works here via each playlist's right-click menu ("Move to folder"). With no
|
||||
* folders this renders the original flat list (plus right-click support).
|
||||
*/
|
||||
export default function SidebarPlaylistsSection({ playlists, playlistsLoading }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const fullPlaylists = usePlaylistStore(s => s.playlists);
|
||||
const bucket =
|
||||
usePlaylistFolderStore(s => (serverId ? s.byServer[serverId] : undefined)) ?? EMPTY_SERVER_FOLDERS;
|
||||
const toggleFolderCollapsed = usePlaylistFolderStore(s => s.toggleFolderCollapsed);
|
||||
|
||||
if (playlistsLoading) {
|
||||
return (
|
||||
<div className="sidebar-playlists-list">
|
||||
<div className="sidebar-playlists-loading">
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (playlists.length === 0) {
|
||||
return (
|
||||
<div className="sidebar-playlists-list">
|
||||
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderItem = (pl: SidebarPlaylist) => (
|
||||
<NavLink
|
||||
key={pl.id}
|
||||
to={`/playlists/${pl.id}`}
|
||||
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
const full = fullPlaylists.find(p => p.id === pl.id) ?? pl;
|
||||
openContextMenu(e.clientX, e.clientY, full, 'playlist');
|
||||
}}
|
||||
>
|
||||
{isSmartPlaylistName(pl.name) ? <Sparkles size={12} /> : <PlayCircle size={12} />}
|
||||
<span>{displayPlaylistName(pl.name)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
if (!serverId || bucket.folders.length === 0) {
|
||||
return <div className="sidebar-playlists-list">{playlists.map(renderItem)}</div>;
|
||||
}
|
||||
|
||||
const grouped = groupPlaylistsByFolder(playlists, bucket.folders, bucket.assignments);
|
||||
|
||||
return (
|
||||
<div className="sidebar-playlists-list">
|
||||
{grouped.folders.map(({ folder, playlists: items }) => (
|
||||
<div key={folder.id} className="sidebar-playlist-folder">
|
||||
<button
|
||||
className={`sidebar-playlist-folder-header${folder.collapsed ? '' : ' expanded'}`}
|
||||
onClick={() => toggleFolderCollapsed(serverId, folder.id)}
|
||||
aria-expanded={!folder.collapsed}
|
||||
aria-label={folder.collapsed ? t('playlists.folders.expandFolder') : t('playlists.folders.collapseFolder')}
|
||||
>
|
||||
<ChevronRight size={12} className="sidebar-playlist-folder-chevron" />
|
||||
<Folder size={12} />
|
||||
<span className="sidebar-playlist-folder-name">{folder.name}</span>
|
||||
<span className="sidebar-playlist-folder-count">{items.length}</span>
|
||||
</button>
|
||||
{!folder.collapsed && items.length > 0 && (
|
||||
<div className="sidebar-playlist-folder-items">{items.map(renderItem)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{grouped.ungrouped.map(renderItem)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user