mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25: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,62 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { usePlaylistFolderStore } from './playlistFolderStore';
|
||||
|
||||
const get = () => usePlaylistFolderStore.getState();
|
||||
const server = (id: string) => get().byServer[id] ?? { folders: [], assignments: {} };
|
||||
|
||||
beforeEach(() => {
|
||||
usePlaylistFolderStore.setState({ byServer: {}, groupView: true });
|
||||
});
|
||||
|
||||
describe('playlistFolderStore', () => {
|
||||
it('creates a folder and returns its id', () => {
|
||||
const id = get().createFolder('s1', ' Rock ');
|
||||
const { folders } = server('s1');
|
||||
expect(folders).toHaveLength(1);
|
||||
expect(folders[0]).toMatchObject({ id, name: 'Rock', collapsed: false });
|
||||
});
|
||||
|
||||
it('assigns and reassigns a playlist, and clears assignment with null', () => {
|
||||
const f1 = get().createFolder('s1', 'A');
|
||||
const f2 = get().createFolder('s1', 'B');
|
||||
get().setPlaylistFolder('s1', 'p1', f1);
|
||||
expect(server('s1').assignments.p1).toBe(f1);
|
||||
get().setPlaylistFolder('s1', 'p1', f2);
|
||||
expect(server('s1').assignments.p1).toBe(f2);
|
||||
get().setPlaylistFolder('s1', 'p1', null);
|
||||
expect(server('s1').assignments.p1).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deleting a folder drops its assignments (playlists become ungrouped)', () => {
|
||||
const f1 = get().createFolder('s1', 'A');
|
||||
get().setPlaylistFolder('s1', 'p1', f1);
|
||||
get().setPlaylistFolder('s1', 'p2', f1);
|
||||
get().deleteFolder('s1', f1);
|
||||
expect(server('s1').folders).toHaveLength(0);
|
||||
expect(server('s1').assignments).toEqual({});
|
||||
});
|
||||
|
||||
it('renames and toggles collapse', () => {
|
||||
const f1 = get().createFolder('s1', 'A');
|
||||
get().renameFolder('s1', f1, ' Jazz ');
|
||||
get().toggleFolderCollapsed('s1', f1);
|
||||
expect(server('s1').folders[0]).toMatchObject({ name: 'Jazz', collapsed: true });
|
||||
});
|
||||
|
||||
it('toggles the grouped view (default on, global)', () => {
|
||||
expect(get().groupView).toBe(true);
|
||||
get().toggleGroupView();
|
||||
expect(get().groupView).toBe(false);
|
||||
get().toggleGroupView();
|
||||
expect(get().groupView).toBe(true);
|
||||
});
|
||||
|
||||
it('scopes folders per server', () => {
|
||||
get().createFolder('s1', 'A');
|
||||
get().createFolder('s2', 'B');
|
||||
expect(server('s1').folders).toHaveLength(1);
|
||||
expect(server('s2').folders).toHaveLength(1);
|
||||
expect(server('s1').folders[0].name).toBe('A');
|
||||
expect(server('s2').folders[0].name).toBe('B');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { nextFolderOrder, type PlaylistFolder } from '../utils/playlist/playlistFolders';
|
||||
|
||||
/** Folder state for a single server (playlist ids are server-scoped). */
|
||||
export interface ServerPlaylistFolders {
|
||||
folders: PlaylistFolder[];
|
||||
/** playlistId → folderId */
|
||||
assignments: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PlaylistFolderState {
|
||||
byServer: Record<string, ServerPlaylistFolders>;
|
||||
/** Whether the Playlists page / sidebar group playlists into folders. */
|
||||
groupView: boolean;
|
||||
toggleGroupView: () => void;
|
||||
createFolder: (serverId: string, name: string) => string;
|
||||
renameFolder: (serverId: string, folderId: string, name: string) => void;
|
||||
/** Removes the folder; its playlists fall back to ungrouped. */
|
||||
deleteFolder: (serverId: string, folderId: string) => void;
|
||||
/** Assign a playlist to a folder, or pass `null` to move it back to ungrouped. */
|
||||
setPlaylistFolder: (serverId: string, playlistId: string, folderId: string | null) => void;
|
||||
toggleFolderCollapsed: (serverId: string, folderId: string) => void;
|
||||
}
|
||||
|
||||
const EMPTY_SERVER: ServerPlaylistFolders = { folders: [], assignments: {} };
|
||||
|
||||
function mutateServer(
|
||||
byServer: Record<string, ServerPlaylistFolders>,
|
||||
serverId: string,
|
||||
fn: (s: ServerPlaylistFolders) => ServerPlaylistFolders,
|
||||
): Record<string, ServerPlaylistFolders> {
|
||||
return { ...byServer, [serverId]: fn(byServer[serverId] ?? EMPTY_SERVER) };
|
||||
}
|
||||
|
||||
export const usePlaylistFolderStore = create<PlaylistFolderState>()(
|
||||
persist(
|
||||
set => ({
|
||||
byServer: {},
|
||||
groupView: true,
|
||||
|
||||
toggleGroupView: () => set(state => ({ groupView: !state.groupView })),
|
||||
|
||||
createFolder: (serverId, name) => {
|
||||
const id = crypto.randomUUID();
|
||||
set(state => ({
|
||||
byServer: mutateServer(state.byServer, serverId, s => ({
|
||||
...s,
|
||||
folders: [
|
||||
...s.folders,
|
||||
{ id, name: name.trim(), order: nextFolderOrder(s.folders), collapsed: false },
|
||||
],
|
||||
})),
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
renameFolder: (serverId, folderId, name) =>
|
||||
set(state => ({
|
||||
byServer: mutateServer(state.byServer, serverId, s => ({
|
||||
...s,
|
||||
folders: s.folders.map(f => (f.id === folderId ? { ...f, name: name.trim() } : f)),
|
||||
})),
|
||||
})),
|
||||
|
||||
deleteFolder: (serverId, folderId) =>
|
||||
set(state => ({
|
||||
byServer: mutateServer(state.byServer, serverId, s => ({
|
||||
folders: s.folders.filter(f => f.id !== folderId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(s.assignments).filter(([, fid]) => fid !== folderId),
|
||||
),
|
||||
})),
|
||||
})),
|
||||
|
||||
setPlaylistFolder: (serverId, playlistId, folderId) =>
|
||||
set(state => ({
|
||||
byServer: mutateServer(state.byServer, serverId, s => {
|
||||
const assignments = { ...s.assignments };
|
||||
if (folderId == null) delete assignments[playlistId];
|
||||
else assignments[playlistId] = folderId;
|
||||
return { ...s, assignments };
|
||||
}),
|
||||
})),
|
||||
|
||||
toggleFolderCollapsed: (serverId, folderId) =>
|
||||
set(state => ({
|
||||
byServer: mutateServer(state.byServer, serverId, s => ({
|
||||
...s,
|
||||
folders: s.folders.map(f =>
|
||||
f.id === folderId ? { ...f, collapsed: !f.collapsed } : f,
|
||||
),
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
{ name: 'psysonic_playlist_folders' },
|
||||
),
|
||||
);
|
||||
|
||||
/** Stable empty fallback so selectors don't churn refs for serverless states. */
|
||||
export const EMPTY_SERVER_FOLDERS: ServerPlaylistFolders = EMPTY_SERVER;
|
||||
Reference in New Issue
Block a user