mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
ad74578ef6
* 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)
102 lines
3.6 KiB
TypeScript
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;
|