Files
psysonic/src/components/contextMenu/MoveToFolderSubmenu.tsx
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

112 lines
4.2 KiB
TypeScript

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>
);
}