mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
groupPlaylistsByFolder,
|
||||
nextFolderOrder,
|
||||
type PlaylistFolder,
|
||||
} from './playlistFolders';
|
||||
|
||||
const folder = (id: string, order: number, name = id): PlaylistFolder =>
|
||||
({ id, name, order, collapsed: false });
|
||||
const pl = (id: string) => ({ id });
|
||||
|
||||
describe('groupPlaylistsByFolder', () => {
|
||||
it('places playlists into their assigned folder and the rest in ungrouped', () => {
|
||||
const folders = [folder('f1', 0), folder('f2', 1)];
|
||||
const result = groupPlaylistsByFolder(
|
||||
[pl('a'), pl('b'), pl('c'), pl('d')],
|
||||
folders,
|
||||
{ a: 'f1', c: 'f2' },
|
||||
);
|
||||
expect(result.folders[0].playlists.map(p => p.id)).toEqual(['a']);
|
||||
expect(result.folders[1].playlists.map(p => p.id)).toEqual(['c']);
|
||||
expect(result.ungrouped.map(p => p.id)).toEqual(['b', 'd']);
|
||||
});
|
||||
|
||||
it('returns folders in order, including empty ones', () => {
|
||||
const result = groupPlaylistsByFolder(
|
||||
[pl('a')],
|
||||
[folder('f2', 1, 'B'), folder('f1', 0, 'A')],
|
||||
{ a: 'f1' },
|
||||
);
|
||||
expect(result.folders.map(g => g.folder.id)).toEqual(['f1', 'f2']);
|
||||
expect(result.folders[1].playlists).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats assignments to a missing folder as ungrouped', () => {
|
||||
const result = groupPlaylistsByFolder([pl('a')], [folder('f1', 0)], { a: 'gone' });
|
||||
expect(result.ungrouped.map(p => p.id)).toEqual(['a']);
|
||||
expect(result.folders[0].playlists).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves input order within a bucket', () => {
|
||||
const result = groupPlaylistsByFolder(
|
||||
[pl('c'), pl('a'), pl('b')],
|
||||
[folder('f1', 0)],
|
||||
{ a: 'f1', b: 'f1', c: 'f1' },
|
||||
);
|
||||
expect(result.folders[0].playlists.map(p => p.id)).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextFolderOrder', () => {
|
||||
it('is 0 for an empty list', () => {
|
||||
expect(nextFolderOrder([])).toBe(0);
|
||||
});
|
||||
it('is one past the highest existing order', () => {
|
||||
expect(nextFolderOrder([folder('a', 0), folder('b', 5)])).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Playlist folders — a local, client-side organisation layer over the server's
|
||||
* flat playlist list. The Subsonic API has no folder concept, so folders and
|
||||
* their playlist assignments live only in Psysonic (see `playlistFolderStore`),
|
||||
* scoped per server. This module holds the shared types and the pure grouping
|
||||
* function used by every surface that renders folders (sidebar, Playlists page).
|
||||
*/
|
||||
|
||||
export interface PlaylistFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Stable sort order among folders (assigned at creation). */
|
||||
order: number;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export interface PlaylistFolderGroup<T> {
|
||||
folder: PlaylistFolder;
|
||||
playlists: T[];
|
||||
}
|
||||
|
||||
export interface GroupedPlaylists<T> {
|
||||
/** Folders in display order; each carries its playlists (possibly empty). */
|
||||
folders: PlaylistFolderGroup<T>[];
|
||||
/** Playlists not assigned to any (existing) folder, in input order. */
|
||||
ungrouped: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `playlists` into folder groups + an ungrouped remainder.
|
||||
*
|
||||
* Folders are returned in `order` (then name) order and always appear, even
|
||||
* when empty, so a freshly created folder is visible. Playlists keep their
|
||||
* incoming order within each bucket — callers sort the input upstream. An
|
||||
* assignment pointing at a folder that no longer exists falls back to ungrouped.
|
||||
*/
|
||||
export function groupPlaylistsByFolder<T extends { id: string }>(
|
||||
playlists: readonly T[],
|
||||
folders: readonly PlaylistFolder[],
|
||||
assignments: Readonly<Record<string, string>>,
|
||||
): GroupedPlaylists<T> {
|
||||
const orderedFolders = [...folders].sort(
|
||||
(a, b) => a.order - b.order || a.name.localeCompare(b.name),
|
||||
);
|
||||
const byFolder = new Map<string, T[]>();
|
||||
for (const folder of orderedFolders) byFolder.set(folder.id, []);
|
||||
|
||||
const ungrouped: T[] = [];
|
||||
for (const playlist of playlists) {
|
||||
const folderId = assignments[playlist.id];
|
||||
const bucket = folderId != null ? byFolder.get(folderId) : undefined;
|
||||
if (bucket) bucket.push(playlist);
|
||||
else ungrouped.push(playlist);
|
||||
}
|
||||
|
||||
return {
|
||||
folders: orderedFolders.map(folder => ({
|
||||
folder,
|
||||
playlists: byFolder.get(folder.id) ?? [],
|
||||
})),
|
||||
ungrouped,
|
||||
};
|
||||
}
|
||||
|
||||
/** Next stable `order` value for a new folder appended to `folders`. */
|
||||
export function nextFolderOrder(folders: readonly PlaylistFolder[]): number {
|
||||
return folders.reduce((max, f) => Math.max(max, f.order + 1), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user