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
@@ -0,0 +1,111 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, Folder, FolderMinus, Plus } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '../../store/playlistFolderStore';
interface Props {
playlistId: string;
onDone: () => void;
triggerId?: string;
}
/**
* Submenu for assigning a single playlist to a (local) folder. Mirrors the
* "Add to playlist" submenu's layout/positioning so it shares the context-menu
* hover machinery. Folder assignment is purely local state, so it stays
* available offline.
*/
export default function MoveToFolderSubmenu({ playlistId, onDone, triggerId }: Props) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const newNameRef = useRef<HTMLInputElement>(null);
const [flipLeft, setFlipLeft] = useState(false);
const [flipUp, setFlipUp] = useState(false);
const serverId = useAuthStore(s => s.activeServerId);
const bucket =
usePlaylistFolderStore(s => (serverId ? s.byServer[serverId] : undefined)) ?? EMPTY_SERVER_FOLDERS;
const createFolder = usePlaylistFolderStore(s => s.createFolder);
const setPlaylistFolder = usePlaylistFolderStore(s => s.setPlaylistFolder);
const currentFolderId = bucket.assignments[playlistId];
const folders = useMemo(
() => [...bucket.folders].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)),
[bucket.folders],
);
useLayoutEffect(() => {
if (subRef.current) {
const rect = subRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
if (rect.bottom > window.innerHeight - 8) setFlipUp(true);
}
}, []);
useEffect(() => {
if (creating && newNameRef.current) newNameRef.current.focus();
}, [creating]);
const assign = (folderId: string | null) => {
if (!serverId) return;
setPlaylistFolder(serverId, playlistId, folderId);
onDone();
};
const handleCreate = () => {
const name = newName.trim();
if (!name || !serverId) return;
const id = createFolder(serverId, name);
setPlaylistFolder(serverId, playlistId, id);
setCreating(false);
setNewName('');
onDone();
};
const subStyle: React.CSSProperties = flipLeft
? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' };
return (
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
{!creating ? (
<div className="context-menu-item context-submenu-new" onClick={e => { e.stopPropagation(); setCreating(true); }}>
<Plus size={13} /> {t('playlists.folders.newFolder')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.folders.namePlaceholder')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{currentFolderId != null && (
<div className="context-menu-item" onClick={() => assign(null)}>
<FolderMinus size={13} /> {t('playlists.folders.removeFromFolder')}
</div>
)}
{folders.map(f => (
<div key={f.id} className="context-menu-item" onClick={() => assign(f.id)}>
<Folder size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{f.name}</span>
{currentFolderId === f.id && <Check size={13} style={{ marginLeft: 'auto' }} />}
</div>
))}
</div>
);
}
@@ -1,10 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Play, ChevronRight, ListMusic, Trash2 } from 'lucide-react';
import { Play, ChevronRight, FolderTree, ListMusic, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import { usePlaylistStore } from '../../store/playlistStore';
import { MultiPlaylistToPlaylistSubmenu, SinglePlaylistToPlaylistSubmenu } from './PlaylistToPlaylistSubmenus';
import MoveToFolderSubmenu from './MoveToFolderSubmenu';
import type { ContextMenuItemsProps } from './contextMenuItemTypes';
export default function PlaylistContextItems(props: ContextMenuItemsProps) {
@@ -48,6 +49,19 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
)}
</div>
)}
{/* Folder assignment is local-only state, so it stays available offline. */}
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `folder:${playlist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`folder:${playlist.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`folder:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<FolderTree size={14} /> {t('playlists.folders.moveToFolder')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `folder:${playlist.id}` && (
<MoveToFolderSubmenu playlistId={playlist.id} triggerId={`folder:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{offlinePolicy.canEditPlaylist && (
<>
<div className="context-menu-divider" />
+11 -2
View File
@@ -8,11 +8,14 @@ import {
displayPlaylistName, isSmartPlaylistName, type PendingSmartPlaylist,
} from '../../utils/playlist/playlistsSmart';
import { formatHumanHoursMinutes } from '../../utils/format/formatHumanDuration';
import { useDragSource } from '../../contexts/DragDropContext';
import { PlaylistCardMainCover, PlaylistSmartCoverCell } from './PlaylistCoverImages';
interface Props {
pl: SubsonicPlaylist;
selectionMode: boolean;
/** Enables dragging the card onto a folder drop target (folder view only). */
draggable?: boolean;
selectedIds: Set<string>;
selectedPlaylists: SubsonicPlaylist[];
toggleSelect: (id: string, opts?: { shiftKey?: boolean }) => void;
@@ -30,7 +33,7 @@ interface Props {
}
export default function PlaylistCard({
pl, selectionMode, selectedIds, selectedPlaylists,
pl, selectionMode, draggable, selectedIds, selectedPlaylists,
toggleSelect, isPlaylistDeletable,
deleteConfirmId, setDeleteConfirmId,
handleOpenSmartEditor, handleDelete, handlePlay, playingId,
@@ -40,10 +43,16 @@ export default function PlaylistCard({
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const dragHandlers = useDragSource(() => ({
data: JSON.stringify({ type: 'playlist', id: pl.id }),
label: displayPlaylistName(pl.name),
}));
const dragEnabled = Boolean(draggable) && !selectionMode;
return (
<div
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' album-card--selected' : ''}`}
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' album-card--selected' : ''}${dragEnabled ? ' album-card--draggable' : ''}`}
{...(dragEnabled ? dragHandlers : {})}
onClick={(e) => {
if (selectionMode) {
toggleSelect(pl.id, { shiftKey: e.shiftKey });
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import PlaylistFolderSection from './PlaylistFolderSection';
import { usePlaylistFolderStore } from '@/store/playlistFolderStore';
import type { PlaylistFolder } from '@/utils/playlist/playlistFolders';
const store = () => usePlaylistFolderStore.getState();
const assignments = (serverId: string) => store().byServer[serverId]?.assignments ?? {};
beforeEach(() => {
usePlaylistFolderStore.setState({ byServer: {}, groupView: true });
});
/** Simulate the shared mouse-DnD system releasing a playlist over an element. */
function dropPlaylist(el: Element, playlistId: string) {
el.dispatchEvent(
new CustomEvent('psy-drop', {
bubbles: true,
detail: { data: JSON.stringify({ type: 'playlist', id: playlistId }) },
}),
);
}
describe('PlaylistFolderSection — drop target', () => {
it('files a dropped playlist into the folder', () => {
const id = store().createFolder('s1', 'Rock');
const folder: PlaylistFolder = { id, name: 'Rock', order: 0, collapsed: false };
const { container } = renderWithProviders(
<PlaylistFolderSection
serverId="s1"
folder={folder}
items={[]}
renderCard={() => null}
disableVirtualization
/>,
);
dropPlaylist(container.querySelector('.playlist-folder')!, 'p1');
expect(assignments('s1').p1).toBe(id);
});
it('unfiles a dropped playlist in the ungrouped section', () => {
const id = store().createFolder('s1', 'Rock');
store().setPlaylistFolder('s1', 'p1', id);
const { container } = renderWithProviders(
<PlaylistFolderSection
serverId="s1"
folder={null}
items={[]}
renderCard={() => null}
disableVirtualization
/>,
);
dropPlaylist(container.querySelector('.playlist-folder--ungrouped')!, 'p1');
expect(assignments('s1').p1).toBeUndefined();
});
});
@@ -0,0 +1,170 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight, Folder, Pencil, Trash2 } from 'lucide-react';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { usePlaylistFolderStore } from '../../store/playlistFolderStore';
import type { PlaylistFolder } from '../../utils/playlist/playlistFolders';
import { useDragDrop } from '../../contexts/DragDropContext';
import { VirtualCardGrid } from '../VirtualCardGrid';
interface Props {
serverId: string;
/** The folder this section renders, or null for the ungrouped remainder. */
folder: PlaylistFolder | null;
items: SubsonicPlaylist[];
renderCard: (pl: SubsonicPlaylist) => React.ReactNode;
disableVirtualization: boolean;
}
/**
* One folder section (header + card grid), or the ungrouped remainder when
* `folder` is null. The header is a `psy-drop` target: dropping a dragged
* playlist card here assigns it to this folder (or unfiles it for ungrouped).
* Uses the shared mouse-based DnD system (HTML5 DnD is unusable in WebKitGTK).
*/
export default function PlaylistFolderSection({
serverId, folder, items, renderCard, disableVirtualization,
}: Props) {
const { t } = useTranslation();
const { isDragging } = useDragDrop();
const renameFolder = usePlaylistFolderStore(s => s.renameFolder);
const deleteFolder = usePlaylistFolderStore(s => s.deleteFolder);
const toggleFolderCollapsed = usePlaylistFolderStore(s => s.toggleFolderCollapsed);
const setPlaylistFolder = usePlaylistFolderStore(s => s.setPlaylistFolder);
const sectionRef = useRef<HTMLElement>(null);
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
const [isOver, setIsOver] = useState(false);
const targetId = folder ? folder.id : null;
// The whole section is the drop zone: a `psy-drop` released anywhere inside it
// (header or a card in the grid — events bubble up) files the playlist here.
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const handler = (e: Event) => {
setIsOver(false);
try {
const data = JSON.parse((e as CustomEvent).detail?.data ?? '{}');
if (data.type === 'playlist' && data.id) setPlaylistFolder(serverId, data.id, targetId);
} catch { /* ignore non-playlist drops */ }
};
el.addEventListener('psy-drop', handler);
return () => el.removeEventListener('psy-drop', handler);
}, [serverId, targetId, setPlaylistFolder]);
// Highlight while a drag hovers the section (mouse events still fire during the
// custom drag, unlike native HTML5 DnD).
const hoverProps = {
onMouseMove: () => { if (isDragging && !isOver) setIsOver(true); },
onMouseLeave: () => setIsOver(false),
};
const grid = items.length > 0 && (
<VirtualCardGrid
items={items}
itemKey={pl => pl.id}
rowVariant="playlist"
disableVirtualization={disableVirtualization}
layoutSignal={items.length}
renderItem={renderCard}
/>
);
if (!folder) {
return (
<section
ref={sectionRef}
className={`playlist-folder playlist-folder--ungrouped${isOver ? ' drag-over' : ''}`}
{...hoverProps}
>
<div className="playlist-folder-header playlist-folder-header--static">
<Folder size={16} className="playlist-folder-icon" />
<span className="playlist-folder-name playlist-folder-name--static">
{t('playlists.folders.ungrouped')}
</span>
<span className="playlist-folder-count">{t('playlists.folders.count', { count: items.length })}</span>
</div>
{items.length > 0 ? grid : (
<div className="playlist-folder-dropzone">{t('playlists.folders.removeFromFolder')}</div>
)}
</section>
);
}
const commitRename = () => {
if (draft.trim()) renameFolder(serverId, folder.id, draft.trim());
setRenaming(false);
setDraft('');
};
return (
<section
ref={sectionRef}
className={`playlist-folder${isOver ? ' drag-over' : ''}`}
{...hoverProps}
>
<div className="playlist-folder-header">
<button
className={`playlist-folder-toggle${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={16} />
</button>
<Folder size={16} className="playlist-folder-icon" />
{renaming ? (
<input
autoFocus
className="playlist-folder-rename-input"
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={e => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') { setRenaming(false); setDraft(''); }
}}
/>
) : (
<button className="playlist-folder-name" onClick={() => toggleFolderCollapsed(serverId, folder.id)}>
{folder.name}
</button>
)}
<span className="playlist-folder-count">{t('playlists.folders.count', { count: items.length })}</span>
<div className="playlist-folder-actions">
<button
className="playlist-folder-action"
data-tooltip={t('playlists.folders.rename')}
aria-label={t('playlists.folders.rename')}
onClick={() => { setRenaming(true); setDraft(folder.name); }}
>
<Pencil size={14} />
</button>
<button
className={`playlist-folder-action playlist-folder-action--delete${confirmDelete ? ' is-confirm' : ''}`}
data-tooltip={confirmDelete ? t('playlists.confirmDelete') : t('playlists.folders.delete')}
aria-label={t('playlists.folders.delete')}
onClick={() => {
if (confirmDelete) { deleteFolder(serverId, folder.id); setConfirmDelete(false); }
else setConfirmDelete(true);
}}
onMouseLeave={() => setConfirmDelete(false)}
>
<Trash2 size={14} />
</button>
</div>
</div>
{!folder.collapsed && (
items.length === 0 ? (
<div className="playlist-folder-empty">{t('playlists.empty')}</div>
) : (
grid
)
)}
</section>
);
}
@@ -0,0 +1,52 @@
import React from 'react';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '../../store/playlistFolderStore';
import { groupPlaylistsByFolder } from '../../utils/playlist/playlistFolders';
import { useDragDrop } from '../../contexts/DragDropContext';
import PlaylistFolderSection from './PlaylistFolderSection';
interface Props {
serverId: string;
playlists: SubsonicPlaylist[];
renderCard: (pl: SubsonicPlaylist) => React.ReactNode;
disableVirtualization: boolean;
}
/**
* Playlists page rendered as collapsible folder sections + an ungrouped
* remainder. Each section reuses `VirtualCardGrid`, so the card layout and
* virtualisation match the flat grid; only the grouping differs. Rendered only
* when at least one folder exists (the page falls back to the plain grid).
*/
export default function PlaylistsFolderView({ serverId, playlists, renderCard, disableVirtualization }: Props) {
const bucket = usePlaylistFolderStore(s => s.byServer[serverId]) ?? EMPTY_SERVER_FOLDERS;
const { isDragging } = useDragDrop();
const grouped = groupPlaylistsByFolder(playlists, bucket.folders, bucket.assignments);
// Keep the ungrouped section as a drop target during a drag even when empty,
// so a playlist filed into a folder can always be dragged back out to root.
const showUngrouped = grouped.ungrouped.length > 0 || isDragging;
return (
<div className="playlist-folder-view">
{grouped.folders.map(({ folder, playlists: items }) => (
<PlaylistFolderSection
key={folder.id}
serverId={serverId}
folder={folder}
items={items}
renderCard={renderCard}
disableVirtualization={disableVirtualization}
/>
))}
{showUngrouped && (
<PlaylistFolderSection
serverId={serverId}
folder={null}
items={grouped.ungrouped}
renderCard={renderCard}
disableVirtualization={disableVirtualization}
/>
)}
</div>
);
}
@@ -0,0 +1,35 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FolderTree } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { usePlaylistFolderStore } from '../../store/playlistFolderStore';
/**
* Header toggle to switch the Playlists page between the grouped folder view
* and a single flat grid. Hidden until the active server has at least one
* folder (nothing to switch otherwise).
*/
export default function PlaylistsFolderViewToggle() {
const { t } = useTranslation();
const activeServerId = useAuthStore(s => s.activeServerId);
const folderCount = usePlaylistFolderStore(
s => (activeServerId ? s.byServer[activeServerId]?.folders.length ?? 0 : 0),
);
const groupView = usePlaylistFolderStore(s => s.groupView);
const toggleGroupView = usePlaylistFolderStore(s => s.toggleGroupView);
if (folderCount === 0) return null;
return (
<button
className={`btn btn-surface${groupView ? ' btn-sort-active' : ''}`}
onClick={toggleGroupView}
aria-pressed={groupView}
data-tooltip={t('playlists.folders.groupByFolders')}
data-tooltip-pos="bottom"
style={groupView ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<FolderTree size={15} /> {t('playlists.folders.groupByFolders')}
</button>
);
}
@@ -6,6 +6,8 @@ import {
defaultSmartFilters, type SmartFilters,
} from '../../utils/playlist/playlistsSmart';
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
import PlaylistsNewFolderButton from './PlaylistsNewFolderButton';
import PlaylistsFolderViewToggle from './PlaylistsFolderViewToggle';
interface Props {
selectionMode: boolean;
@@ -87,6 +89,8 @@ export default function PlaylistsHeader({
)}
</>
)}
{!(selectionMode && selectedIds.size > 0) && <PlaylistsFolderViewToggle />}
{!(selectionMode && selectedIds.size > 0) && <PlaylistsNewFolderButton />}
{selectionMode && selectedIds.size > 0 && (() => {
const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length;
return (
@@ -0,0 +1,68 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FolderPlus } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { usePlaylistFolderStore } from '../../store/playlistFolderStore';
/**
* "New folder" action for the Playlists header row. Self-contained: toggles an
* inline name input and creates a local folder for the active server. Folder
* state is local-only, so this stays available offline.
*/
export default function PlaylistsNewFolderButton() {
const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId);
const createFolder = usePlaylistFolderStore(s => s.createFolder);
const folderCount = usePlaylistFolderStore(
s => (serverId ? s.byServer[serverId]?.folders.length ?? 0 : 0),
);
const groupView = usePlaylistFolderStore(s => s.groupView);
const [creating, setCreating] = useState(false);
const [name, setName] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (creating) inputRef.current?.focus();
}, [creating]);
if (!serverId) return null;
// Only offer folder creation in the grouped view; keep it available before the
// first folder exists (the toggle is hidden then, so this is the only entry).
if (folderCount > 0 && !groupView) return null;
const submit = () => {
const trimmed = name.trim();
if (trimmed) createFolder(serverId, trimmed);
setName('');
setCreating(false);
};
if (creating) {
return (
<>
<input
ref={inputRef}
className="input"
style={{ width: 180 }}
placeholder={t('playlists.folders.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') { setCreating(false); setName(''); }
}}
/>
<button className="btn btn-primary" onClick={submit}>{t('playlists.folders.create')}</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setName(''); }}>
{t('playlists.cancel')}
</button>
</>
);
}
return (
<button className="btn btn-surface" onClick={() => setCreating(true)}>
<FolderPlus size={15} /> {t('playlists.folders.newFolder')}
</button>
);
}
+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>
);
}