diff --git a/src/renderer/components/drag-preview/drag-preview.tsx b/src/renderer/components/drag-preview/drag-preview.tsx
index ba3a5236d..76f3c626e 100644
--- a/src/renderer/components/drag-preview/drag-preview.tsx
+++ b/src/renderer/components/drag-preview/drag-preview.tsx
@@ -6,7 +6,7 @@ import styles from './drag-preview.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { Icon } from '/@/shared/components/icon/icon';
import { LibraryItem } from '/@/shared/types/domain-types';
-import { DragData } from '/@/shared/types/drag-and-drop';
+import { DragData, DragTarget } from '/@/shared/types/drag-and-drop';
interface DragPreviewProps {
data: DragData;
@@ -29,7 +29,9 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
const { t } = useTranslation();
const itemCount = items.length;
const firstItem = items[0];
- const itemName = firstItem ? getItemName(firstItem) : 'Item';
+ const folderName =
+ data.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER ? data.id[0] : undefined;
+ const itemName = folderName || (firstItem ? getItemName(firstItem) : 'Item');
const itemImage = useItemImageUrl({
id: (firstItem as { imageId: string })?.imageId,
@@ -50,6 +52,9 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
) : (
+ {data.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER && (
+
+ )}
{data.itemType === LibraryItem.ALBUM &&
}
{data.itemType === LibraryItem.SONG && (
diff --git a/src/renderer/features/sidebar/components/playlist-folder-tree.module.css b/src/renderer/features/sidebar/components/playlist-folder-tree.module.css
index 95f982fe6..b66a69c38 100644
--- a/src/renderer/features/sidebar/components/playlist-folder-tree.module.css
+++ b/src/renderer/features/sidebar/components/playlist-folder-tree.module.css
@@ -3,6 +3,20 @@
flex-direction: column;
}
+.header-dragged-over {
+ position: relative;
+
+ &::after {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ content: "";
+ border: 2px solid var(--theme-colors-primary);
+ border-radius: var(--theme-radius-md);
+ opacity: 0.8;
+ }
+}
+
.header {
display: flex;
gap: var(--theme-spacing-md);
@@ -110,3 +124,17 @@
flex-shrink: 0;
opacity: 0.6;
}
+
+.root-drop-target-dragged-over {
+ position: relative;
+
+ &::after {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ content: "";
+ border: 2px solid var(--theme-colors-primary);
+ border-radius: var(--theme-radius-md);
+ opacity: 0.8;
+ }
+}
diff --git a/src/renderer/features/sidebar/components/playlist-folder-tree.tsx b/src/renderer/features/sidebar/components/playlist-folder-tree.tsx
index 660892008..35b815f0a 100644
--- a/src/renderer/features/sidebar/components/playlist-folder-tree.tsx
+++ b/src/renderer/features/sidebar/components/playlist-folder-tree.tsx
@@ -1,22 +1,475 @@
-import { CSSProperties, MouseEvent, ReactElement, useCallback, useMemo, useState } from 'react';
+import clsx from 'clsx';
+import {
+ ComponentPropsWithoutRef,
+ CSSProperties,
+ MouseEvent,
+ ReactElement,
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
import styles from './playlist-folder-tree.module.css';
+import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { PlaylistRowButton } from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
+import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import {
+ useCurrentServerId,
useSidebarPlaylistFolders,
useSidebarPlaylistFolderSeparator,
useSidebarPlaylistFolderTreeIndent,
useSidebarPlaylistFolderTreeLineColor,
useSidebarPlaylistFolderView,
} from '/@/renderer/store';
+import { Accordion } from '/@/shared/components/accordion/accordion';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
+import { toast } from '/@/shared/components/toast/toast';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
-import { Playlist } from '/@/shared/types/domain-types';
+import { LibraryItem, Playlist } from '/@/shared/types/domain-types';
+import { DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
const STORAGE_KEY_PREFIX = 'feishin:playlist-folder-state';
+export const getPlaylistLeafName = (name: string, separator: string): string => {
+ if (!separator) return name;
+ const segments = name.split(separator).filter((segment) => segment.length > 0);
+ return segments[segments.length - 1] ?? name;
+};
+
+export const buildPlaylistNameInFolder = (
+ folderPath: string,
+ leafName: string,
+ separator: string,
+): string => {
+ if (!folderPath) return leafName;
+ return `${folderPath}${separator}${leafName}`;
+};
+
+export const getFolderName = (folderPath: string, separator: string): string => {
+ const segments = folderPath.split(separator).filter((segment) => segment.length > 0);
+ return segments[segments.length - 1] ?? folderPath;
+};
+
+export const isDirectChildFolder = (
+ childFolderPath: string,
+ parentFolderPath: string,
+ separator: string,
+): boolean => {
+ // True when child is exactly one folder level below parent (Rock/Classic under Rock).
+ if (!childFolderPath || !parentFolderPath) return false;
+ if (childFolderPath === parentFolderPath) return false;
+
+ const prefix = `${parentFolderPath}${separator}`;
+ if (!childFolderPath.startsWith(prefix)) return false;
+
+ const relativePath = childFolderPath.slice(prefix.length);
+ return relativePath.length > 0 && !relativePath.includes(separator);
+};
+
+export const isValidFolderNest = (
+ sourceFolderPath: string,
+ targetFolderPath: string,
+ separator: string,
+): boolean => {
+ // Folder-on-folder drops are allowed except onto self or onto a descendant folder.
+ if (!sourceFolderPath || !targetFolderPath) return false;
+ if (sourceFolderPath === targetFolderPath) return false;
+ if (targetFolderPath.startsWith(`${sourceFolderPath}${separator}`)) return false;
+ return true;
+};
+
+export const getPlaylistsInFolderTree = (
+ playlists: Playlist[],
+ folderPath: string,
+ separator: string,
+): Playlist[] => {
+ // Every playlist whose name lives under this folder path (including nested subfolders).
+ const prefix = `${folderPath}${separator}`;
+ return playlists.filter((playlist) => playlist.name.startsWith(prefix));
+};
+
+export const remapPlaylistFolderPath = (
+ playlistName: string,
+ sourceFolderPath: string,
+ targetFolderPath: string,
+ separator: string,
+): null | string => {
+ // Rename one playlist when its containing folder is dropped onto another folder.
+ const sourcePrefix = `${sourceFolderPath}${separator}`;
+ if (!playlistName.startsWith(sourcePrefix)) return null;
+
+ const remainder = playlistName.slice(sourcePrefix.length);
+ if (!remainder) return null;
+
+ if (isDirectChildFolder(sourceFolderPath, targetFolderPath, separator)) {
+ // Direct parent: flatten playlists into the parent (Rock/Classic/x -> Rock/x).
+ return `${targetFolderPath}${separator}${remainder}`;
+ }
+
+ if (sourceFolderPath.startsWith(`${targetFolderPath}${separator}`)) {
+ // Higher ancestor: move the folder as a unit (Pop/Rock/Classic/x -> Pop/Classic/x).
+ const folderName = getFolderName(sourceFolderPath, separator);
+ return `${targetFolderPath}${separator}${folderName}${separator}${remainder}`;
+ }
+
+ // Unrelated target: nest the full source folder path (Classic/x -> Rock/Classic/x).
+ return `${targetFolderPath}${separator}${sourceFolderPath}${separator}${remainder}`;
+};
+
+export const remapPlaylistToRoot = (playlistName: string, separator: string): string => {
+ return getPlaylistLeafName(playlistName, separator).trim();
+};
+
+const updatePlaylistName = async (
+ updateMutation: ReturnType
,
+ serverId: string,
+ playlist: Playlist,
+ newName: string,
+) => {
+ if (newName === playlist.name) return;
+
+ await updateMutation.mutateAsync({
+ apiClientProps: { serverId },
+ body: {
+ comment: playlist.description || '',
+ name: newName,
+ ownerId: playlist.ownerId || '',
+ public: playlist.public || false,
+ queryBuilderRules: playlist.rules ?? undefined,
+ sync: playlist.sync ?? undefined,
+ },
+ query: { id: playlist.id },
+ });
+};
+
+export const usePlaylistRootDrop = (allPlaylists: Playlist[]) => {
+ const { t } = useTranslation();
+ const serverId = useCurrentServerId();
+ const separator = useSidebarPlaylistFolderSeparator();
+ const updateMutation = useUpdatePlaylist({});
+
+ const handleDrop = useCallback(
+ async (source: DragData) => {
+ if (!serverId) return;
+
+ try {
+ if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
+ const sourceFolderPath =
+ source.id[0] ??
+ (source.metadata as undefined | { folderName?: string })?.folderName;
+ if (!sourceFolderPath) return;
+
+ const affected = getPlaylistsInFolderTree(
+ allPlaylists,
+ sourceFolderPath,
+ separator,
+ );
+
+ for (const playlist of affected) {
+ await updatePlaylistName(
+ updateMutation,
+ serverId,
+ playlist,
+ remapPlaylistToRoot(playlist.name, separator),
+ );
+ }
+
+ return;
+ }
+
+ const playlists = source.item as Playlist[] | undefined;
+ if (!Array.isArray(playlists) || playlists.length === 0) return;
+
+ for (const playlist of playlists) {
+ await updatePlaylistName(
+ updateMutation,
+ serverId,
+ playlist,
+ remapPlaylistToRoot(playlist.name, separator),
+ );
+ }
+ } catch (err: unknown) {
+ toast.error({
+ message: err instanceof Error ? err.message : undefined,
+ title: t('error.genericError'),
+ });
+ }
+ },
+ [allPlaylists, separator, serverId, t, updateMutation],
+ );
+
+ const { isDraggedOver, ref } = useDragDrop({
+ drop: {
+ canDrop: (args) => {
+ if (args.source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
+ return Boolean(args.source.id[0]);
+ }
+
+ if (args.source.itemType !== LibraryItem.PLAYLIST) return false;
+ const items = args.source.item as Playlist[] | undefined;
+ return Array.isArray(items) && items.length > 0;
+ },
+ getData: () => ({
+ id: [''],
+ type: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
+ }),
+ onDrag: () => {
+ return;
+ },
+ onDragLeave: () => {
+ return;
+ },
+ onDrop: ({ source }) => {
+ void handleDrop(source);
+ },
+ },
+ isEnabled: true,
+ });
+
+ return { isDraggedOver, ref };
+};
+
+interface PlaylistRootAccordionControlProps extends Omit<
+ ComponentPropsWithoutRef,
+ 'ref'
+> {
+ allPlaylists: Playlist[];
+}
+
+export const PlaylistRootAccordionControl = ({
+ allPlaylists,
+ children,
+ className,
+ ...controlProps
+}: PlaylistRootAccordionControlProps) => {
+ const { isDraggedOver, ref } = usePlaylistRootDrop(allPlaylists);
+
+ return (
+
+ {children}
+
+ );
+};
+
+// Drag-and-drop on folder headers: folders can be dragged, and accept folder or playlist drops.
+const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => {
+ const { t } = useTranslation();
+ const serverId = useCurrentServerId();
+ const separator = useSidebarPlaylistFolderSeparator();
+ const updateMutation = useUpdatePlaylist({});
+
+ const handleDrop = useCallback(
+ async (source: DragData) => {
+ if (!serverId) return;
+
+ try {
+ if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
+ // Folder drop: rename every playlist under the dragged folder tree.
+ const sourceFolderPath =
+ source.id[0] ??
+ (source.metadata as undefined | { folderName?: string })?.folderName;
+ if (
+ !sourceFolderPath ||
+ !isValidFolderNest(sourceFolderPath, folderPath, separator)
+ ) {
+ return;
+ }
+
+ const affected = getPlaylistsInFolderTree(
+ allPlaylists,
+ sourceFolderPath,
+ separator,
+ );
+
+ for (const playlist of affected) {
+ const newName = remapPlaylistFolderPath(
+ playlist.name,
+ sourceFolderPath,
+ folderPath,
+ separator,
+ );
+ if (!newName || newName === playlist.name) continue;
+
+ await updateMutation.mutateAsync({
+ apiClientProps: { serverId },
+ body: {
+ comment: playlist.description || '',
+ name: newName,
+ ownerId: playlist.ownerId || '',
+ public: playlist.public || false,
+ queryBuilderRules: playlist.rules ?? undefined,
+ sync: playlist.sync ?? undefined,
+ },
+ query: { id: playlist.id },
+ });
+ }
+
+ return;
+ }
+
+ // Playlist drop: move a single playlist into this folder using its leaf name only.
+ const playlists = source.item as Playlist[] | undefined;
+ if (!Array.isArray(playlists) || playlists.length === 0) return;
+
+ for (const playlist of playlists) {
+ const leafName = getPlaylistLeafName(playlist.name, separator);
+ const newName = buildPlaylistNameInFolder(folderPath, leafName, separator);
+ if (newName === playlist.name) continue;
+
+ await updateMutation.mutateAsync({
+ apiClientProps: { serverId },
+ body: {
+ comment: playlist.description || '',
+ name: newName,
+ ownerId: playlist.ownerId || '',
+ public: playlist.public || false,
+ queryBuilderRules: playlist.rules ?? undefined,
+ sync: playlist.sync ?? undefined,
+ },
+ query: { id: playlist.id },
+ });
+ }
+ } catch (err: unknown) {
+ toast.error({
+ message: err instanceof Error ? err.message : undefined,
+ title: t('error.genericError'),
+ });
+ }
+ },
+ [allPlaylists, folderPath, separator, serverId, t, updateMutation],
+ );
+
+ const { isDraggedOver, isDragging, ref } = useDragDrop({
+ drag: {
+ // Folders are virtual; drag data carries the folder path, not playlist items.
+ getId: () => [folderPath],
+ getItem: () => [],
+ metadata: { folderName: folderPath },
+ operation: [DragOperation.ADD],
+ target: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
+ },
+ drop: {
+ canDrop: (args) => {
+ if (args.source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
+ const sourceFolderPath =
+ args.source.id[0] ??
+ (args.source.metadata as undefined | { folderName?: string })?.folderName;
+ if (!sourceFolderPath) return false;
+ return isValidFolderNest(sourceFolderPath, folderPath, separator);
+ }
+
+ // Single playlist rows can also be dropped onto a folder header.
+ if (args.source.itemType !== LibraryItem.PLAYLIST) return false;
+ const items = args.source.item as Playlist[] | undefined;
+ return Array.isArray(items) && items.length > 0;
+ },
+ getData: () => ({
+ id: [folderPath],
+ type: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
+ }),
+ onDrag: () => {
+ return;
+ },
+ onDragLeave: () => {
+ return;
+ },
+ onDrop: ({ source }) => {
+ void handleDrop(source);
+ },
+ },
+ isEnabled: true,
+ });
+
+ return { isDraggedOver, isDragging, ref };
+};
+
+interface PlaylistFolderHeaderProps {
+ allPlaylists: Playlist[];
+ folderPath: string;
+ isOpen?: boolean;
+ leafCount: number;
+ name: string;
+ onClick: () => void;
+ variant: 'header' | 'nav';
+}
+
+const PlaylistFolderHeader = ({
+ allPlaylists,
+ folderPath,
+ isOpen,
+ leafCount,
+ name,
+ onClick,
+ variant,
+}: PlaylistFolderHeaderProps) => {
+ const { isDraggedOver, isDragging, ref } = usePlaylistFolderDrop(folderPath, allPlaylists);
+
+ if (variant === 'nav') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
export type FolderNode = {
children: TreeNode[];
leafCount: number;
@@ -199,6 +652,7 @@ export const usePlaylistFolderState = (scope: PlaylistFolderScope) => {
};
interface PlaylistFolderTreeProps {
+ allPlaylists: Playlist[];
expandedSet: Set;
groups: PlaylistGroup[];
onContextMenu: (e: MouseEvent, item: Playlist) => void;
@@ -207,6 +661,7 @@ interface PlaylistFolderTreeProps {
}
export const PlaylistFolderTree = ({
+ allPlaylists,
expandedSet,
groups,
onContextMenu,
@@ -232,26 +687,15 @@ export const PlaylistFolderTree = ({
const isOpen = expandedSet.has(group.name);
return (
-
+ variant="header"
+ />
{isOpen && (
{group.items.map((item) => (
@@ -274,6 +718,7 @@ export const PlaylistFolderTree = ({
};
interface PlaylistFolderTreeViewProps {
+ allPlaylists: Playlist[];
expandedSet: Set
;
nodes: TreeNode[];
onContextMenu: (e: MouseEvent, item: Playlist) => void;
@@ -282,6 +727,7 @@ interface PlaylistFolderTreeViewProps {
}
export const PlaylistFolderTreeView = ({
+ allPlaylists,
expandedSet,
nodes,
onContextMenu,
@@ -305,26 +751,15 @@ export const PlaylistFolderTreeView = ({
const isOpen = expandedSet.has(node.path);
return (
-
+ variant="header"
+ />
{isOpen && (
{node.children.map((child) => (
@@ -364,6 +799,7 @@ export const usePlaylistNavigationState = (): PlaylistNavigationState => {
};
interface PlaylistFolderNavigationViewProps {
+ allPlaylists: Playlist[];
nodes: TreeNode[];
onContextMenu: (e: MouseEvent
, item: Playlist) => void;
onEnter: (name: string) => void;
@@ -372,6 +808,7 @@ interface PlaylistFolderNavigationViewProps {
}
export const PlaylistFolderNavigationView = ({
+ allPlaylists,
nodes,
onContextMenu,
onEnter,
@@ -403,24 +840,15 @@ export const PlaylistFolderNavigationView = ({
return (
{folders.map((folder) => (
-
+ variant="nav"
+ />
))}
{leaves.map((leaf) => (
;
navigation: PlaylistNavigationState;
onContextMenu: (e: MouseEvent, item: Playlist) => void;
@@ -489,6 +918,7 @@ interface PlaylistFolderViewsProps extends PlaylistFolderViewState {
}
export const PlaylistFolderViews = ({
+ allPlaylists,
expandedSet,
foldersEnabled,
folderView,
@@ -504,6 +934,7 @@ export const PlaylistFolderViews = ({
return (
{
return (
-
-
+
+
{inNavigation && (
{
/>
-
+
{
return (
-
+
{inNavigation && (
{