Files
Psychotoxical-psysonic/src/store/playlistFolderStore.ts
T
Psychotoxical ad74578ef6 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)
2026-06-17 21:22:30 +02:00

102 lines
3.6 KiB
TypeScript

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;