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:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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