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:
@@ -20,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Theme versions now show in the store (next to the author) and under each installed community theme; when an update is available, the store shows the installed → available version.
|
||||
* New store filter to show only animated themes or only static ones, next to the existing mode and sort controls.
|
||||
|
||||
### Playlist folders
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1119](https://github.com/Psychotoxical/psysonic/pull/1119)**, suggested by [@SilverWolf24](https://github.com/SilverWolf24)
|
||||
|
||||
* Organise your playlists into folders on the Playlists page and in the sidebar — create folders, drag playlists into them (or use the right-click "Move to folder" menu), rename, collapse and switch between the folder view and a single flat list. Folders are saved locally on this device only, since the Subsonic API has no folder support.
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -101,4 +101,22 @@ export const playlists = {
|
||||
csvImportToast: '{{added}} hinzugefügt, {{notFound}} nicht gefunden, {{duplicates}} Duplikate',
|
||||
csvImportDownloadSuccess: 'Bericht erfolgreich heruntergeladen',
|
||||
csvImportDownloadError: 'Bericht konnte nicht heruntergeladen werden',
|
||||
// Playlist folders (local, per-server organisation layer)
|
||||
folders: {
|
||||
newFolder: 'Neuer Ordner',
|
||||
namePlaceholder: 'Ordnername…',
|
||||
create: 'Erstellen',
|
||||
rename: 'Umbenennen',
|
||||
delete: 'Ordner löschen',
|
||||
ungrouped: 'Ohne Ordner',
|
||||
moveToFolder: 'In Ordner verschieben',
|
||||
removeFromFolder: 'Aus Ordner entfernen',
|
||||
expandFolder: 'Ordner aufklappen',
|
||||
collapseFolder: 'Ordner einklappen',
|
||||
groupByFolders: 'Nach Ordnern gruppieren',
|
||||
count_one: '{{count}} Playlist',
|
||||
count_other: '{{count}} Playlists',
|
||||
localOnlyNotice:
|
||||
'Ordner werden nur in Psysonic auf diesem Gerät gespeichert. Navidrome und die Subsonic-API unterstützen keine Playlist-Ordner, daher wird die Ordnerstruktur nicht auf deinem Server gespeichert und nicht mit deinen anderen Geräten und Apps synchronisiert.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,4 +101,22 @@ export const playlists = {
|
||||
csvImportToast: '{{added}} added, {{notFound}} not found, {{duplicates}} duplicates',
|
||||
csvImportDownloadSuccess: 'Report downloaded successfully',
|
||||
csvImportDownloadError: 'Failed to download report',
|
||||
// Playlist folders (local, per-server organisation layer)
|
||||
folders: {
|
||||
newFolder: 'New folder',
|
||||
namePlaceholder: 'Folder name…',
|
||||
create: 'Create',
|
||||
rename: 'Rename',
|
||||
delete: 'Delete folder',
|
||||
ungrouped: 'Ungrouped',
|
||||
moveToFolder: 'Move to folder',
|
||||
removeFromFolder: 'Remove from folder',
|
||||
expandFolder: 'Expand folder',
|
||||
collapseFolder: 'Collapse folder',
|
||||
groupByFolders: 'Group by folders',
|
||||
count_one: '{{count}} playlist',
|
||||
count_other: '{{count}} playlists',
|
||||
localOnlyNotice:
|
||||
'Folders are saved only in Psysonic on this device. Navidrome and the Subsonic API have no native playlist-folder support, so the structure is not stored on your server or synced to your other devices and apps.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,4 +101,22 @@ export const playlists = {
|
||||
csvImportToast: '{{added}} agregadas, {{notFound}} no encontradas, {{duplicates}} duplicadas',
|
||||
csvImportDownloadSuccess: 'Reporte descargado exitosamente',
|
||||
csvImportDownloadError: 'Error al descargar el reporte',
|
||||
// Playlist folders (local, per-server organisation layer)
|
||||
folders: {
|
||||
newFolder: 'Nueva carpeta',
|
||||
namePlaceholder: 'Nombre de carpeta…',
|
||||
create: 'Crear',
|
||||
rename: 'Renombrar',
|
||||
delete: 'Eliminar carpeta',
|
||||
ungrouped: 'Sin carpeta',
|
||||
moveToFolder: 'Mover a carpeta',
|
||||
removeFromFolder: 'Quitar de la carpeta',
|
||||
expandFolder: 'Expandir carpeta',
|
||||
collapseFolder: 'Contraer carpeta',
|
||||
groupByFolders: 'Agrupar por carpetas',
|
||||
count_one: '{{count}} lista',
|
||||
count_other: '{{count}} listas',
|
||||
localOnlyNotice:
|
||||
'Las carpetas se guardan solo en Psysonic en este dispositivo. Navidrome y la API de Subsonic no admiten carpetas de listas, por lo que la estructura no se guarda en tu servidor ni se sincroniza con tus otros dispositivos y aplicaciones.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,4 +101,22 @@ export const playlists = {
|
||||
csvImportToast: '{{added}} ajoutés, {{notFound}} non trouvés, {{duplicates}} doublons',
|
||||
csvImportDownloadSuccess: 'Rapport téléchargé avec succès',
|
||||
csvImportDownloadError: 'Échec du téléchargement du rapport',
|
||||
// Playlist folders (local, per-server organisation layer)
|
||||
folders: {
|
||||
newFolder: 'Nouveau dossier',
|
||||
namePlaceholder: 'Nom du dossier…',
|
||||
create: 'Créer',
|
||||
rename: 'Renommer',
|
||||
delete: 'Supprimer le dossier',
|
||||
ungrouped: 'Sans dossier',
|
||||
moveToFolder: 'Déplacer vers un dossier',
|
||||
removeFromFolder: 'Retirer du dossier',
|
||||
expandFolder: 'Développer le dossier',
|
||||
collapseFolder: 'Réduire le dossier',
|
||||
groupByFolders: 'Grouper par dossiers',
|
||||
count_one: '{{count}} playlist',
|
||||
count_other: '{{count}} playlists',
|
||||
localOnlyNotice:
|
||||
'Les dossiers sont enregistrés uniquement dans Psysonic sur cet appareil. Navidrome et l\'API Subsonic ne prennent pas en charge les dossiers de playlists, la structure n\'est donc pas stockée sur ton serveur ni synchronisée avec tes autres appareils et applications.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,4 +101,22 @@ export const playlists = {
|
||||
csvImportToast: '{{added}} lagt til, {{notFound}} ikke funnet, {{duplicates}} duplikater',
|
||||
csvImportDownloadSuccess: 'Rapport lastet ned',
|
||||
csvImportDownloadError: 'Kunne ikke laste ned rapport',
|
||||
// Playlist folders (local, per-server organisation layer)
|
||||
folders: {
|
||||
newFolder: 'Ny mappe',
|
||||
namePlaceholder: 'Mappenavn…',
|
||||
create: 'Opprett',
|
||||
rename: 'Gi nytt navn',
|
||||
delete: 'Slett mappe',
|
||||
ungrouped: 'Uten mappe',
|
||||
moveToFolder: 'Flytt til mappe',
|
||||
removeFromFolder: 'Fjern fra mappe',
|
||||
expandFolder: 'Utvid mappe',
|
||||
collapseFolder: 'Skjul mappe',
|
||||
groupByFolders: 'Grupper etter mapper',
|
||||
count_one: '{{count}} spilleliste',
|
||||
count_other: '{{count}} spillelister',
|
||||
localOnlyNotice:
|
||||
'Mapper lagres bare i Psysonic på denne enheten. Navidrome og Subsonic-API-et støtter ikke spillelistemapper, så strukturen lagres ikke på serveren din og synkroniseres ikke med dine andre enheter og apper.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,4 +101,22 @@ export const playlists = {
|
||||
csvImportToast: '{{added}} toegevoegd, {{notFound}} niet gevonden, {{duplicates}} duplicaten',
|
||||
csvImportDownloadSuccess: 'Rapport succesvol gedownload',
|
||||
csvImportDownloadError: 'Rapport downloaden mislukt',
|
||||
// Playlist folders (local, per-server organisation layer)
|
||||
folders: {
|
||||
newFolder: 'Nieuwe map',
|
||||
namePlaceholder: 'Mapnaam…',
|
||||
create: 'Aanmaken',
|
||||
rename: 'Hernoemen',
|
||||
delete: 'Map verwijderen',
|
||||
ungrouped: 'Zonder map',
|
||||
moveToFolder: 'Naar map verplaatsen',
|
||||
removeFromFolder: 'Uit map verwijderen',
|
||||
expandFolder: 'Map uitvouwen',
|
||||
collapseFolder: 'Map invouwen',
|
||||
groupByFolders: 'Groeperen op mappen',
|
||||
count_one: '{{count}} playlist',
|
||||
count_other: '{{count}} playlists',
|
||||
localOnlyNotice:
|
||||
'Mappen worden alleen in Psysonic op dit apparaat opgeslagen. Navidrome en de Subsonic-API ondersteunen geen playlistmappen, dus de structuur wordt niet op je server opgeslagen of met je andere apparaten en apps gesynchroniseerd.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,4 +101,22 @@ export const playlists = {
|
||||
csvImportToast: '{{added}} adăugate, {{notFound}} negăsite, {{duplicates}} duplicate',
|
||||
csvImportDownloadSuccess: 'Pagina de raport descărcată cu succes',
|
||||
csvImportDownloadError: 'Nu s-a putut descărca raportul',
|
||||
// Playlist folders (local, per-server organisation layer)
|
||||
folders: {
|
||||
newFolder: 'Folder nou',
|
||||
namePlaceholder: 'Nume folder…',
|
||||
create: 'Crează',
|
||||
rename: 'Redenumește',
|
||||
delete: 'Șterge folderul',
|
||||
ungrouped: 'Fără folder',
|
||||
moveToFolder: 'Mută în folder',
|
||||
removeFromFolder: 'Scoate din folder',
|
||||
expandFolder: 'Extinde folderul',
|
||||
collapseFolder: 'Restrânge folderul',
|
||||
groupByFolders: 'Grupează pe foldere',
|
||||
count_one: '{{count}} playlist',
|
||||
count_other: '{{count}} playlisturi',
|
||||
localOnlyNotice:
|
||||
'Folderele sunt salvate doar în Psysonic pe acest dispozitiv. Navidrome și API-ul Subsonic nu acceptă foldere de playlisturi, așa că structura nu este stocată pe serverul tău și nu se sincronizează cu celelalte dispozitive și aplicații ale tale.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,4 +101,22 @@ export const playlists = {
|
||||
csvImportToast: '{{added}} добавлено, {{notFound}} не найдено, {{duplicates}} дубликатов',
|
||||
csvImportDownloadSuccess: 'Отчёт успешно скачан',
|
||||
csvImportDownloadError: 'Не удалось скачать отчёт',
|
||||
// Playlist folders (local, per-server organisation layer)
|
||||
folders: {
|
||||
newFolder: 'Новая папка',
|
||||
namePlaceholder: 'Название папки…',
|
||||
create: 'Создать',
|
||||
rename: 'Переименовать',
|
||||
delete: 'Удалить папку',
|
||||
ungrouped: 'Без папки',
|
||||
moveToFolder: 'Переместить в папку',
|
||||
removeFromFolder: 'Убрать из папки',
|
||||
expandFolder: 'Развернуть папку',
|
||||
collapseFolder: 'Свернуть папку',
|
||||
groupByFolders: 'Группировать по папкам',
|
||||
count_one: '{{count}} плейлист',
|
||||
count_other: '{{count}} плейлистов',
|
||||
localOnlyNotice:
|
||||
'Папки сохраняются только в Psysonic на этом устройстве. Navidrome и API Subsonic не поддерживают папки плейлистов, поэтому структура не хранится на твоём сервере и не синхронизируется с другими твоими устройствами и приложениями.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,4 +99,22 @@ export const playlists = {
|
||||
csvImportToast: '{{added}} 已添加,{{notFound}} 未找到,{{duplicates}} 重复项',
|
||||
csvImportDownloadSuccess: '报告下载成功',
|
||||
csvImportDownloadError: '报告下载失败',
|
||||
// Playlist folders (local, per-server organisation layer)
|
||||
folders: {
|
||||
newFolder: '新建文件夹',
|
||||
namePlaceholder: '文件夹名称…',
|
||||
create: '创建',
|
||||
rename: '重命名',
|
||||
delete: '删除文件夹',
|
||||
ungrouped: '未分组',
|
||||
moveToFolder: '移动到文件夹',
|
||||
removeFromFolder: '从文件夹中移除',
|
||||
expandFolder: '展开文件夹',
|
||||
collapseFolder: '折叠文件夹',
|
||||
groupByFolders: '按文件夹分组',
|
||||
count_one: '{{count}} 个播放列表',
|
||||
count_other: '{{count}} 个播放列表',
|
||||
localOnlyNotice:
|
||||
'文件夹仅保存在此设备上的 Psysonic 中。Navidrome 和 Subsonic API 不支持播放列表文件夹,因此该结构不会存储在你的服务器上,也不会同步到你的其他设备和应用。',
|
||||
},
|
||||
};
|
||||
|
||||
+52
-25
@@ -31,6 +31,9 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
import { Info } from 'lucide-react';
|
||||
import PlaylistsFolderView from '../components/playlists/PlaylistsFolderView';
|
||||
import { usePlaylistFolderStore } from '../store/playlistFolderStore';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
return formatHumanHoursMinutes(seconds);
|
||||
@@ -49,6 +52,11 @@ export default function Playlists() {
|
||||
const playlistsLoading = usePlaylistStore((s) => s.playlistsLoading);
|
||||
const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const folderCount = usePlaylistFolderStore(
|
||||
s => (activeServerId ? s.byServer[activeServerId]?.folders.length ?? 0 : 0),
|
||||
);
|
||||
const folderGroupView = usePlaylistFolderStore(s => s.groupView);
|
||||
const showFolderView = Boolean(activeServerId) && folderCount > 0 && folderGroupView;
|
||||
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
@@ -173,6 +181,28 @@ export default function Playlists() {
|
||||
targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t,
|
||||
});
|
||||
|
||||
const renderCard = (pl: SubsonicPlaylist) => (
|
||||
<PlaylistCard
|
||||
pl={pl}
|
||||
selectionMode={selectionMode}
|
||||
draggable={showFolderView}
|
||||
selectedIds={selectedIds}
|
||||
selectedPlaylists={selectedPlaylists}
|
||||
toggleSelect={toggleSelect}
|
||||
isPlaylistDeletable={isPlaylistDeletable}
|
||||
deleteConfirmId={deleteConfirmId}
|
||||
setDeleteConfirmId={setDeleteConfirmId}
|
||||
handleOpenSmartEditor={handleOpenSmartEditor}
|
||||
handleDelete={handleDelete}
|
||||
handlePlay={handlePlay}
|
||||
playingId={playingId}
|
||||
smartCoverIdsByPlaylist={smartCoverIdsByPlaylist}
|
||||
pendingSmart={pendingSmart}
|
||||
filteredSongCountByPlaylist={filteredSongCountByPlaylist}
|
||||
filteredDurationByPlaylist={filteredDurationByPlaylist}
|
||||
/>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
@@ -267,33 +297,30 @@ export default function Playlists() {
|
||||
{playlists.length === 0 ? (
|
||||
<div className="empty-state">{t('playlists.empty')}</div>
|
||||
) : (
|
||||
<VirtualCardGrid
|
||||
items={playlists}
|
||||
itemKey={(pl, _i) => pl.id}
|
||||
rowVariant="playlist"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={playlists.length}
|
||||
renderItem={pl => (
|
||||
<PlaylistCard
|
||||
pl={pl}
|
||||
selectionMode={selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
selectedPlaylists={selectedPlaylists}
|
||||
toggleSelect={toggleSelect}
|
||||
isPlaylistDeletable={isPlaylistDeletable}
|
||||
deleteConfirmId={deleteConfirmId}
|
||||
setDeleteConfirmId={setDeleteConfirmId}
|
||||
handleOpenSmartEditor={handleOpenSmartEditor}
|
||||
handleDelete={handleDelete}
|
||||
handlePlay={handlePlay}
|
||||
playingId={playingId}
|
||||
smartCoverIdsByPlaylist={smartCoverIdsByPlaylist}
|
||||
pendingSmart={pendingSmart}
|
||||
filteredSongCountByPlaylist={filteredSongCountByPlaylist}
|
||||
filteredDurationByPlaylist={filteredDurationByPlaylist}
|
||||
<>
|
||||
{showFolderView && (
|
||||
<p className="playlist-folder-notice playlist-folder-notice--page">
|
||||
<Info size={13} /> {t('playlists.folders.localOnlyNotice')}
|
||||
</p>
|
||||
)}
|
||||
{showFolderView && activeServerId ? (
|
||||
<PlaylistsFolderView
|
||||
serverId={activeServerId}
|
||||
playlists={playlists}
|
||||
renderCard={renderCard}
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
/>
|
||||
) : (
|
||||
<VirtualCardGrid
|
||||
items={playlists}
|
||||
itemKey={(pl, _i) => pl.id}
|
||||
rowVariant="playlist"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={playlists.length}
|
||||
renderItem={renderCard}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -21,6 +21,7 @@
|
||||
@import './lucky-mix-pips-shared-by-the-inline-queue-lucky-cube-indicator.css';
|
||||
@import './playback-buffering-overlay.css';
|
||||
@import './playlist-edit-modal.css';
|
||||
@import './playlist-folders.css';
|
||||
@import './artists-page.css';
|
||||
@import './years-page.css';
|
||||
@import './settings.css';
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/* Playlist folders — Playlists page notice + folder sections, and sidebar groups. */
|
||||
|
||||
/* ── Local-only notice ──────────────────────────────────────────────────── */
|
||||
.playlist-folder-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.playlist-folder-notice svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.playlist-folder-notice--page {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Folder sections on the page ────────────────────────────────────────── */
|
||||
.playlist-folder-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.playlist-folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.playlist-folder-header:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.playlist-folder-header--static {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
border-radius: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.playlist-folder-header--static:hover {
|
||||
border-color: transparent;
|
||||
border-bottom-color: var(--border-subtle);
|
||||
}
|
||||
.playlist-folder-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
transition: transform var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.playlist-folder-toggle.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.playlist-folder-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.playlist-folder-icon {
|
||||
color: var(--accent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.playlist-folder-name {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
max-width: 40ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.playlist-folder-name--static {
|
||||
cursor: default;
|
||||
}
|
||||
.playlist-folder-rename-input {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.playlist-folder-rename-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.playlist-folder-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-hover);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.playlist-folder-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.playlist-folder-header:hover .playlist-folder-actions,
|
||||
.playlist-folder-actions:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
.playlist-folder-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.playlist-folder-action:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.playlist-folder-action--delete:hover,
|
||||
.playlist-folder-action--delete.is-confirm {
|
||||
color: var(--danger);
|
||||
}
|
||||
.playlist-folder-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-2) 0 var(--space-3);
|
||||
}
|
||||
.playlist-folder-dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Drag-and-drop: dropping a playlist card anywhere in a folder files it there. */
|
||||
.playlist-folder.drag-over {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.album-card--draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
.album-card--draggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ── Sidebar folder groups ──────────────────────────────────────────────── */
|
||||
.sidebar-playlist-folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--sidebar-text, var(--text-muted));
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.sidebar-playlist-folder-header:hover {
|
||||
background: var(--sidebar-item-hover);
|
||||
}
|
||||
.sidebar-playlist-folder-chevron {
|
||||
flex: 0 0 auto;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.sidebar-playlist-folder-header.expanded .sidebar-playlist-folder-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.sidebar-playlist-folder-name {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-playlist-folder-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.sidebar-playlist-folder-items {
|
||||
padding-left: 12px;
|
||||
}
|
||||
@@ -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