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:
Psychotoxical
2026-06-17 21:22:30 +02:00
committed by GitHub
parent ccb2d11fc4
commit ad74578ef6
28 changed files with 1350 additions and 50 deletions
+3 -22
View File
@@ -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>
);
}