add loading state to playlist folder move

This commit is contained in:
jeffvli
2026-05-19 01:50:36 -07:00
parent c8675ab600
commit ada94e5f5d
4 changed files with 147 additions and 114 deletions
@@ -0,0 +1,62 @@
import { useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Playlist } from '/@/shared/types/domain-types';
export const sidebarPlaylistFolderMoveMutationKey = ['sidebar-playlist-folder-move'];
export type SidebarPlaylistFolderMoveArgs = {
serverId: string;
updates: SidebarPlaylistFolderMoveUpdate[];
};
export type SidebarPlaylistFolderMoveUpdate = {
newName: string;
playlist: Playlist;
};
export const useSidebarPlaylistFolderMove = () => {
const queryClient = useQueryClient();
return useMutation<void, AxiosError, SidebarPlaylistFolderMoveArgs>({
mutationFn: async ({ serverId, updates }) => {
for (const { newName, playlist } of updates) {
if (newName === playlist.name) continue;
await api.controller.updatePlaylist({
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 },
});
}
},
mutationKey: sidebarPlaylistFolderMoveMutationKey,
onSuccess: (_data, { serverId, updates }) => {
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.list(serverId),
});
for (const { playlist } of updates) {
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.detail(serverId, playlist.id),
});
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.songList(serverId, playlist.id),
});
}
},
});
};
export const useIsMutatingSidebarPlaylistFolderMove = () => {
return useIsMutating({ mutationKey: sidebarPlaylistFolderMoveMutationKey }) > 0;
};
@@ -17,7 +17,10 @@ import { useTranslation } from 'react-i18next';
import styles from './playlist-folder-tree.module.css';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import {
SidebarPlaylistFolderMoveUpdate,
useSidebarPlaylistFolderMove,
} from '/@/renderer/features/playlists/mutations/sidebar-playlist-folder-move-mutation';
import { PlaylistRowButton } from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import {
@@ -209,76 +212,56 @@ export const remapPlaylistFolderToRoot = (
return `${folderName}${separator}${remainder}`;
};
const updatePlaylistName = async (
updateMutation: ReturnType<typeof useUpdatePlaylist>,
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 folderMoveMutation = useSidebarPlaylistFolderMove();
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 updates: SidebarPlaylistFolderMoveUpdate[] = [];
const affected = getPlaylistsInFolderTree(
allPlaylists,
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) {
const newName = remapPlaylistFolderToRoot(
playlist.name,
sourceFolderPath,
separator,
);
if (!newName) continue;
for (const playlist of affected) {
const newName = remapPlaylistFolderToRoot(
playlist.name,
sourceFolderPath,
separator,
);
if (!newName) continue;
await updatePlaylistName(updateMutation, serverId, playlist, newName);
}
return;
updates.push({ newName, playlist });
}
} else {
const playlists = source.item as Playlist[] | undefined;
if (!Array.isArray(playlists) || playlists.length === 0) return;
for (const playlist of playlists) {
await updatePlaylistName(
updateMutation,
serverId,
updates.push({
newName: remapPlaylistToRoot(playlist.name, separator),
playlist,
remapPlaylistToRoot(playlist.name, separator),
);
});
}
}
if (updates.length === 0) return;
try {
await folderMoveMutation.mutateAsync({ serverId, updates });
} catch (err: unknown) {
toast.error({
message: err instanceof Error ? err.message : undefined,
@@ -286,7 +269,7 @@ export const usePlaylistRootDrop = (allPlaylists: Playlist[]) => {
});
}
},
[allPlaylists, separator, serverId, t, updateMutation],
[allPlaylists, folderMoveMutation, separator, serverId, t],
);
const { isDraggedOver, ref } = useDragDrop<HTMLButtonElement>({
@@ -518,59 +501,44 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
const { t } = useTranslation();
const serverId = useCurrentServerId();
const separator = useSidebarPlaylistFolderSeparator();
const updateMutation = useUpdatePlaylist({});
const folderMoveMutation = useSidebarPlaylistFolderMove();
const dragExpand = useContext(PlaylistFolderDragExpandContext);
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 },
});
}
const updates: SidebarPlaylistFolderMoveUpdate[] = [];
if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
const sourceFolderPath =
source.id[0] ??
(source.metadata as undefined | { folderName?: string })?.folderName;
if (
!sourceFolderPath ||
!isValidFolderNest(sourceFolderPath, folderPath, separator)
) {
return;
}
// Playlist drop: move a single playlist into this folder using its leaf name only.
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;
updates.push({ newName, playlist });
}
} else {
const playlists = source.item as Playlist[] | undefined;
if (!Array.isArray(playlists) || playlists.length === 0) return;
@@ -579,19 +547,14 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
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 },
});
updates.push({ newName, playlist });
}
}
if (updates.length === 0) return;
try {
await folderMoveMutation.mutateAsync({ serverId, updates });
} catch (err: unknown) {
toast.error({
message: err instanceof Error ? err.message : undefined,
@@ -599,7 +562,7 @@ const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) =>
});
}
},
[allPlaylists, folderPath, separator, serverId, t, updateMutation],
[allPlaylists, folderMoveMutation, folderPath, separator, serverId, t],
);
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLButtonElement>({
@@ -4,6 +4,10 @@
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
}
.panel {
position: relative;
}
.row {
position: relative;
display: flex;
@@ -29,13 +33,12 @@
.compact-name {
flex: 1;
min-width: 0;
padding: 0;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.controls {
position: absolute;
top: 50%;
@@ -45,7 +48,6 @@
transform: translateY(-50%);
}
.controls-compact {
position: absolute;
top: 50%;
@@ -56,13 +58,12 @@
transform: translateY(-50%);
}
.row-dragged-over::after {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
content: "";
content: '';
border: 2px solid var(--theme-colors-primary);
border-radius: var(--theme-radius-md);
opacity: 0.8;
@@ -13,6 +13,7 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';
import { useIsMutatingSidebarPlaylistFolderMove } from '/@/renderer/features/playlists/mutations/sidebar-playlist-folder-move-mutation';
import {
LONG_PRESS_PLAY_BEHAVIOR,
PlayTooltip,
@@ -47,6 +48,7 @@ import { ButtonProps } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay';
import { Text } from '/@/shared/components/text/text';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import {
@@ -603,6 +605,7 @@ export const SidebarPlaylistList = () => {
);
const showExpandAll = folderView !== 'navigation' && folderPaths.length > 0;
const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove();
return (
<Accordion.Item value="playlists">
@@ -674,7 +677,8 @@ export const SidebarPlaylistList = () => {
</Group>
</Group>
</PlaylistRootAccordionControl>
<Accordion.Panel>
<Accordion.Panel className={styles.panel}>
<LoadingOverlay pos="absolute" visible={isFolderMovePending} />
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
<PlaylistFolderViews
{...folderViewState}
@@ -837,6 +841,8 @@ export const SidebarSharedPlaylistList = () => {
[navigation],
);
const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove();
if (playlistItems?.items?.length === 0) {
return null;
}
@@ -860,7 +866,8 @@ export const SidebarSharedPlaylistList = () => {
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Accordion.Panel className={styles.panel}>
<LoadingOverlay pos="absolute" visible={isFolderMovePending} />
<PlaylistFolderDragExpandProvider expandedSet={expandedSet} setMany={setMany}>
<PlaylistFolderViews
{...folderViewState}