Compare commits

..

15 Commits

Author SHA1 Message Date
Geert Rademakes
d13fe81ade merge: playlist reorder improvements (single+multi drag, end drop zone) and export order overlay; cleanup 2025-08-08 15:05:16 +02:00
Geert Rademakes
5f17380816 chore: remove verbose reorder/debug logs and tidy XML export logs; streamline FE reorder console noise 2025-08-08 15:05:10 +02:00
Geert Rademakes
5659dde540 feat(export): honor custom playlist order overlay when exporting Rekordbox XML (Entries + TRACK sequence) 2025-08-08 14:56:28 +02:00
Geert Rademakes
9249a5a4a7 feat(reorder): support dragging multiple selected songs to a specific position and to end (block move) 2025-08-08 14:55:06 +02:00
Geert Rademakes
ac52441dd1 fix(reorder): only show end-of-list drop zone during active reorder drag; reset hover indicators on drag end 2025-08-08 14:53:26 +02:00
Geert Rademakes
d0a83a85f5 feat(reorder): add end-of-list drop zone to move track to end; show hover indicator 2025-08-08 14:52:48 +02:00
Geert Rademakes
2d42c6da71 fix(reorder): stop overwriting server move with stale client order; just refresh after move; improve dnd effectAllowed/dropEffect 2025-08-08 14:47:20 +02:00
Geert Rademakes
e2d6d55433 chore(reorder-debug): add server/client logs for move and playlist order slice to diagnose ordering issue 2025-08-08 14:37:45 +02:00
Geert Rademakes
484d191201 fix(playlist-order): honor custom order in playlist songs endpoint by slicing trackIds and mapping results 2025-08-08 14:33:42 +02:00
Geert Rademakes
8136bbb959 fix(reorder): backend move endpoint and frontend use precise move to persist order reliably; refresh after move 2025-08-08 14:30:58 +02:00
Geert Rademakes
61d4ca16de feat(playlists): track custom order in separate 'order' array; reading API honors 'order' while preserving 'tracks' 2025-08-08 14:25:50 +02:00
Geert Rademakes
5a396d774e fix(playlists): markModified on children/tracks to persist order; API response confirms update 2025-08-08 14:20:48 +02:00
Geert Rademakes
5a21243f5b chore(reorder): add client debug logs; refresh playlist after reorder; backend keeps missing ids at end when reordering 2025-08-08 14:15:55 +02:00
Geert Rademakes
50a486f6d8 feat(playlist-reorder): enable intra-playlist row drag&drop with landing indicator; persist order via backend 2025-08-08 14:09:36 +02:00
Geert Rademakes
32d545959d feat(playlists): support custom per-playlist track order; backend /playlists/reorder; playlist songs endpoint honors order; frontend intra-playlist row DnD with reorder persist 2025-08-08 14:03:03 +02:00
7 changed files with 382 additions and 54 deletions

View File

@ -9,6 +9,8 @@ const playlistSchema = new mongoose.Schema({
required: true
},
tracks: [String],
// Optional custom order of track IDs (display-only, not exported)
order: [String],
children: [{
type: mongoose.Schema.Types.Mixed
}]

View File

@ -80,4 +80,171 @@ router.post('/batch', async (req, res) => {
}
});
// Reorder tracks inside a playlist (by name). Stores order separately in `order` to avoid interfering with original `tracks`.
router.post('/reorder', async (req: Request, res: Response) => {
try {
const { playlistName, orderedIds } = req.body as { playlistName: string; orderedIds: string[] };
if (!playlistName || !Array.isArray(orderedIds)) {
return res.status(400).json({ message: 'playlistName and orderedIds are required' });
}
const playlists = await Playlist.find({});
let updated = false;
const reorderNode = (node: any): any => {
if (!node) return node;
if (node.type === 'playlist' && node.name === playlistName) {
// Store order separately; do not mutate tracks
const unique = Array.from(new Set(orderedIds));
node.order = unique;
updated = true;
return node;
}
if (Array.isArray(node.children)) {
node.children = node.children.map(reorderNode);
}
return node;
};
for (const p of playlists) {
reorderNode(p as any);
// Force Mongoose to see deep changes under Mixed children
p.markModified('children');
p.markModified('tracks');
await p.save();
}
if (!updated) {
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
}
res.json({ message: 'Playlist order updated', playlistName, newLength: orderedIds.length });
} catch (error) {
console.error('Error reordering playlist:', error);
res.status(500).json({ error: 'Failed to reorder playlist' });
}
});
// Move a single track before another (stable, full-playlist context)
router.post('/reorder-move', async (req: Request, res: Response) => {
try {
const { playlistName, fromId, toId } = req.body as { playlistName: string; fromId: string; toId?: string };
if (!playlistName || !fromId) {
return res.status(400).json({ message: 'playlistName and fromId are required' });
}
const playlists = await Playlist.find({});
let updated = false;
const applyMove = (node: any): any => {
if (!node) return node;
if (node.type === 'playlist' && node.name === playlistName) {
const base: string[] = Array.isArray(node.tracks) ? node.tracks : [];
const order: string[] = Array.isArray(node.order) ? node.order : [];
// Build effective order
const baseSet = new Set(base);
const effective = [
...order.filter((id: string) => baseSet.has(id)),
...base.filter((id: string) => !order.includes(id))
];
// Remove fromId if present
const without = effective.filter(id => id !== fromId);
let insertIndex = typeof toId === 'string' ? without.indexOf(toId) : without.length;
if (insertIndex < 0) insertIndex = without.length;
without.splice(insertIndex, 0, fromId);
// Updated playlist order overlay
node.order = without; // store full order overlay
updated = true;
return node;
}
if (Array.isArray(node.children)) {
node.children = node.children.map(applyMove);
}
return node;
};
for (const p of playlists) {
applyMove(p as any);
p.markModified('children');
p.markModified('order');
await p.save();
}
if (!updated) {
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
}
res.json({ message: 'Track moved', playlistName });
} catch (error) {
console.error('Error moving track in playlist:', error);
res.status(500).json({ error: 'Failed to move track' });
}
});
// Move multiple tracks before another (en bloc, preserves relative order)
router.post('/reorder-move-many', async (req: Request, res: Response) => {
try {
const { playlistName, fromIds, toId } = req.body as { playlistName: string; fromIds: string[]; toId?: string };
if (!playlistName || !Array.isArray(fromIds) || fromIds.length === 0) {
return res.status(400).json({ message: 'playlistName and fromIds are required' });
}
const playlists = await Playlist.find({});
let updated = false;
const applyMoveMany = (node: any): any => {
if (!node) return node;
if (node.type === 'playlist' && node.name === playlistName) {
const base: string[] = Array.isArray(node.tracks) ? node.tracks : [];
const order: string[] = Array.isArray(node.order) ? node.order : [];
const baseSet = new Set(base);
const effective: string[] = [
...order.filter((id: string) => baseSet.has(id)),
...base.filter((id: string) => !order.includes(id))
];
const moveSet = new Set(fromIds);
const movingOrdered = effective.filter(id => moveSet.has(id));
if (movingOrdered.length === 0) {
return node;
}
// Remove all moving ids
const without = effective.filter(id => !moveSet.has(id));
let insertIndex = typeof toId === 'string' ? without.indexOf(toId) : without.length;
if (insertIndex < 0) insertIndex = without.length;
// Insert block preserving relative order
without.splice(insertIndex, 0, ...movingOrdered);
// Updated playlist order overlay for batch move
node.order = without;
updated = true;
return node;
}
if (Array.isArray(node.children)) {
node.children = node.children.map(applyMoveMany);
}
return node;
};
for (const p of playlists) {
applyMoveMany(p as any);
p.markModified('children');
p.markModified('order');
await p.save();
}
if (!updated) {
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
}
res.json({ message: 'Tracks moved', playlistName });
} catch (error) {
console.error('Error moving tracks in playlist:', error);
res.status(500).json({ message: 'Error moving tracks in playlist', error });
}
});
export const playlistsRouter = router;

View File

@ -34,13 +34,19 @@ router.get('/', async (req: Request, res: Response) => {
const totalSongs = await Song.countDocuments(query);
const totalPages = Math.ceil(totalSongs / limit);
// Get songs with pagination
const songs = await Song.find(query)
.sort({ title: 1 })
.skip(skip)
.limit(limit)
// Get songs for this page in the exact playlist order
// Determine the slice of trackIds for the requested page
const pageStart = (page - 1) * limit;
const pageEnd = Math.min(pageStart + limit, trackIds.length);
const pageTrackIds = trackIds.slice(pageStart, pageEnd);
const pageSongs = await Song.find({ id: { $in: pageTrackIds } })
.populate('s3File.musicFileId')
.lean();
// Order them to match pageTrackIds
const idToSong: Record<string, any> = {};
for (const s of pageSongs) idToSong[s.id] = s;
const songs = pageTrackIds.map(id => idToSong[id]).filter(Boolean);
console.log(`Found ${songs.length} songs (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`);
@ -70,7 +76,7 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
const search = req.query.search as string || '';
const skip = (page - 1) * limit;
console.log(`Fetching songs for playlist "${playlistName}"... Page: ${page}, Limit: ${limit}, Search: "${search}"`);
// Fetch songs for playlist with pagination
// Find the playlist recursively in the playlist structure
const findPlaylistRecursively = (nodes: any[], targetName: string): any => {
@ -96,16 +102,20 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
}
if (!playlist) {
console.log(`Playlist "${playlistName}" not found. Available playlists:`);
const allPlaylistNames = await Playlist.find({}, 'name');
console.log(allPlaylistNames.map(p => p.name));
// Playlist not found
return res.status(404).json({ message: `Playlist "${playlistName}" not found` });
}
// Get all track IDs from the playlist (including nested playlists)
const getAllTrackIds = (node: any): string[] => {
if (node.type === 'playlist' && node.tracks) {
return node.tracks;
if (node.type === 'playlist') {
const base = Array.isArray(node.tracks) ? node.tracks : [];
const order = Array.isArray(node.order) ? node.order : [];
if (order.length === 0) return base;
const setBase = new Set(base);
const orderedKnown = order.filter((id: string) => setBase.has(id));
const missing = base.filter((id: string) => !order.includes(id));
return [...orderedKnown, ...missing];
}
if (node.type === 'folder' && node.children) {
return node.children.flatMap((child: any) => getAllTrackIds(child));
@ -114,7 +124,7 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
};
const trackIds = getAllTrackIds(playlist);
console.log(`Found ${trackIds.length} tracks in playlist "${playlistName}"`);
// Compute effective ordered track IDs
if (trackIds.length === 0) {
return res.json({
@ -160,15 +170,17 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
return total + seconds;
}, 0);
// Get songs with pagination
const songs = await Song.find(query)
.sort({ title: 1 })
.skip(skip)
.limit(limit)
// Get songs for this page in the exact playlist order
const pageStart = (page - 1) * limit;
const pageEnd = Math.min(pageStart + limit, trackIds.length);
const pageTrackIds = trackIds.slice(pageStart, pageEnd);
const pageSongs = await Song.find({ id: { $in: pageTrackIds } })
.populate('s3File.musicFileId')
.lean();
console.log(`Found ${songs.length} songs for playlist "${playlistName}" (${totalSongs} total), ${songs.filter((s: any) => s.s3File?.hasS3File).length} with S3 files`);
const idToSong: Record<string, any> = {};
for (const s of pageSongs) idToSong[s.id] = s;
const songs = pageTrackIds.map(id => idToSong[id]).filter(Boolean);
res.json({
songs,

View File

@ -2,6 +2,17 @@ import { create } from "xmlbuilder";
import { Song } from "../models/Song.js";
import { Playlist } from "../models/Playlist.js";
// Compute effective playlist order: custom overlay (order) + remaining base tracks
const getEffectiveTrackIds = (node: any): string[] => {
const base: string[] = Array.isArray(node?.tracks) ? node.tracks : [];
const overlay: string[] = Array.isArray(node?.order) ? node.order : [];
if (overlay.length === 0) return base;
const setBase = new Set(base);
const orderedKnown = overlay.filter((id: string) => setBase.has(id));
const missing = base.filter((id: string) => !overlay.includes(id));
return [...orderedKnown, ...missing];
};
const buildXmlNode = (node: any): any => {
const xmlNode: any = {
'@Type': node.type === 'folder' ? '0' : '1',
@ -14,13 +25,12 @@ const buildXmlNode = (node: any): any => {
xmlNode.NODE = node.children.map((child: any) => buildXmlNode(child));
}
} else {
// For playlists, always include KeyType and Entries
// For playlists, include KeyType and Entries based on effective order
const effectiveIds = getEffectiveTrackIds(node);
xmlNode['@KeyType'] = '0';
// Set Entries to the actual number of tracks
xmlNode['@Entries'] = (node.tracks || []).length;
// Include TRACK elements if there are tracks
if (node.tracks && node.tracks.length > 0) {
xmlNode.TRACK = node.tracks.map((trackId: string) => ({
xmlNode['@Entries'] = effectiveIds.length;
if (effectiveIds.length > 0) {
xmlNode.TRACK = effectiveIds.map((trackId: string) => ({
'@Key': trackId
}));
}
@ -30,7 +40,7 @@ const buildXmlNode = (node: any): any => {
};
export const streamToXml = async (res: any) => {
console.log('Starting streamToXml function...');
console.log('Starting XML export...');
// Write XML header with encoding (like master collection)
res.write('<?xml version="1.0" encoding="UTF-8"?>');
@ -40,9 +50,7 @@ export const streamToXml = async (res: any) => {
res.write('\n <PRODUCT Name="rekordbox" Version="7.1.3" Company="AlphaTheta"/>');
// Start COLLECTION section
console.log('Counting songs in database...');
const songCount = await Song.countDocuments();
console.log(`Found ${songCount} songs in database`);
res.write(`\n <COLLECTION Entries="${songCount}">`);
// Stream songs in batches to avoid memory issues
@ -68,7 +76,6 @@ export const streamToXml = async (res: any) => {
}
processedSongs += songs.length;
console.log(`Streamed ${processedSongs}/${songCount} songs...`);
}
res.write('\n </COLLECTION>');
@ -77,9 +84,7 @@ export const streamToXml = async (res: any) => {
res.write('\n <PLAYLISTS>');
// Stream playlists
console.log('Fetching playlists from database...');
const playlists = await Playlist.find({}).lean();
console.log(`Found ${playlists.length} playlists in database`);
// Write ROOT node with correct Count
res.write(`\n <NODE Type="0" Name="ROOT" Count="${playlists.length}">`);
@ -93,6 +98,7 @@ export const streamToXml = async (res: any) => {
res.write('\n</DJ_PLAYLISTS>');
res.end();
console.log('XML export completed.');
};
const streamPlaylistNodeFull = async (res: any, node: any) => {
@ -110,15 +116,13 @@ const streamPlaylistNodeFull = async (res: any, node: any) => {
res.write('\n </NODE>');
} else {
const trackCount = node.tracks ? node.tracks.length : 0;
res.write(`\n <NODE Name="${escapeXml(node.name)}" Type="${nodeType}" KeyType="0" Entries="${trackCount}">`);
if (node.tracks && node.tracks.length > 0) {
for (const trackId of node.tracks) {
const effectiveIds = getEffectiveTrackIds(node);
res.write(`\n <NODE Name="${escapeXml(node.name)}" Type="${nodeType}" KeyType="0" Entries="${effectiveIds.length}">`);
if (effectiveIds.length > 0) {
for (const trackId of effectiveIds) {
res.write(`\n <TRACK Key="${trackId}"/>`);
}
}
res.write('\n </NODE>');
}
};
@ -138,15 +142,13 @@ const streamPlaylistNodeCompact = async (res: any, node: any) => {
res.write('</NODE>');
} else {
const trackCount = node.tracks ? node.tracks.length : 0;
res.write(`<NODE Name="${escapeXml(node.name)}" Type="${nodeType}" Count="${trackCount}">`);
if (node.tracks && node.tracks.length > 0) {
for (const trackId of node.tracks) {
const effectiveIds = getEffectiveTrackIds(node);
res.write(`<NODE Name="${escapeXml(node.name)}" Type="${nodeType}" Count="${effectiveIds.length}">`);
if (effectiveIds.length > 0) {
for (const trackId of effectiveIds) {
res.write(`<TRACK Key="${trackId}"/>`);
}
}
res.write('</NODE>');
}
};
@ -167,15 +169,13 @@ const streamPlaylistNode = async (res: any, node: any, indent: number) => {
res.write(`${spaces}</NODE>\n`);
} else {
const trackCount = node.tracks ? node.tracks.length : 0;
res.write(`${spaces}<NODE Type="${nodeType}" Name="${escapeXml(node.name)}" KeyType="0" Entries="${trackCount}">\n`);
if (node.tracks && node.tracks.length > 0) {
for (const trackId of node.tracks) {
const effectiveIds = getEffectiveTrackIds(node);
res.write(`${spaces}<NODE Type="${nodeType}" Name="${escapeXml(node.name)}" KeyType="0" Entries="${effectiveIds.length}">\n`);
if (effectiveIds.length > 0) {
for (const trackId of effectiveIds) {
res.write(`${spaces} <TRACK Key="${trackId}"/>\n`);
}
}
res.write(`${spaces}</NODE>\n`);
}
};

View File

@ -129,7 +129,8 @@ const RekordboxReader: React.FC = () => {
totalDuration,
loadNextPage,
searchSongs,
searchQuery
searchQuery,
refresh
} = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist });
// Export library to XML
@ -651,6 +652,11 @@ const RekordboxReader: React.FC = () => {
searchQuery={searchQuery}
isSwitchingPlaylist={isSwitchingPlaylist}
onPlaySong={handlePlaySong}
onReorder={async (_orderedIds: string[]) => {
if (!currentPlaylist || currentPlaylist === 'All Songs') return;
// Do not overwrite server's move result with stale order; just refresh
refresh();
}}
/>
</Box>

View File

@ -16,6 +16,7 @@ import {
import { Search2Icon } from '@chakra-ui/icons';
import { FiPlay } from 'react-icons/fi';
import type { Song, PlaylistNode } from '../types/interfaces';
import { api } from '../services/api';
import { formatDuration, formatTotalDuration } from '../utils/formatters';
import { useDebounce } from '../hooks/useDebounce';
import { PlaylistSelectionModal } from './PlaylistSelectionModal';
@ -38,6 +39,7 @@ interface PaginatedSongListProps {
depth?: number;
isSwitchingPlaylist?: boolean; // New prop to indicate playlist switching
onPlaySong?: (song: Song) => void; // New prop for playing songs
onReorder?: (orderedIds: string[]) => Promise<void>;
}
// Memoized song item component to prevent unnecessary re-renders
@ -50,7 +52,10 @@ const SongItem = memo<{
showCheckbox: boolean;
onPlaySong?: (song: Song) => void;
onDragStart: (e: React.DragEvent, songIdsFallback: string[]) => void;
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, onDragStart }) => {
onRowDragOver?: (e: React.DragEvent) => void;
onRowDrop?: (e: React.DragEvent) => void;
onRowDragStartCapture?: (e: React.DragEvent) => void;
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture }) => {
// Memoize the formatted duration to prevent recalculation
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]);
const handleClick = useCallback(() => {
@ -88,6 +93,9 @@ const SongItem = memo<{
onDragStart={(e) => {
onDragStart(e, [song.id]);
}}
onDragOver={onRowDragOver}
onDrop={onRowDrop}
onDragStartCapture={onRowDragStartCapture}
>
{showCheckbox && (
<Checkbox
@ -148,7 +156,8 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
searchQuery,
depth = 0,
isSwitchingPlaylist = false,
onPlaySong
onPlaySong,
onReorder
}) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const dragSelectionRef = useRef<string[] | null>(null);
@ -160,6 +169,9 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isTriggeringRef = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [dragHoverIndex, setDragHoverIndex] = useState<number | null>(null);
const [endDropHover, setEndDropHover] = useState<boolean>(false);
const [isReorderDragging, setIsReorderDragging] = useState<boolean>(false);
// Store current values in refs to avoid stale closures
const hasMoreRef = useRef(hasMore);
@ -247,12 +259,15 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
const handleDragEnd = useCallback(() => {
setIsDragging(false);
setIsReorderDragging(false);
setEndDropHover(false);
setDragHoverIndex(null);
dragSelectionRef.current = null;
}, []);
// Memoized song items to prevent unnecessary re-renders
const songItems = useMemo(() => {
return songs.map(song => (
return songs.map((song, index) => (
<SongItem
key={song.id}
song={song}
@ -263,6 +278,51 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
showCheckbox={selectedSongs.size > 0 || depth === 0}
onPlaySong={onPlaySong}
onDragStart={handleDragStart}
// Simple playlist reordering within same list by dragging rows
onRowDragOver={(e: React.DragEvent) => {
if (!onReorder || !currentPlaylist || selectedSongs.size > 0) return;
e.preventDefault();
setDragHoverIndex(index);
}}
onRowDrop={async (e: React.DragEvent) => {
if (!onReorder || !currentPlaylist || selectedSongs.size > 0) return;
e.preventDefault();
const fromId = e.dataTransfer.getData('text/song-id');
const multiJson = e.dataTransfer.getData('application/json');
let multiIds: string[] | null = null;
if (multiJson) {
try {
const parsed = JSON.parse(multiJson);
if (parsed && parsed.type === 'songs' && Array.isArray(parsed.songIds)) {
multiIds = parsed.songIds as string[];
}
} catch {}
}
if (!fromId && !multiIds) return;
const fromIndex = fromId ? songs.findIndex(s => s.id === fromId) : -1;
const toIndex = index;
if (fromIndex < 0 || toIndex < 0) return;
if (fromIndex === toIndex) return;
const toId = songs[index].id;
// If multiple, move block; else move single
if (multiIds && multiIds.length > 0) {
await api.moveTracksInPlaylist(currentPlaylist, multiIds, toId);
} else {
await api.moveTrackInPlaylist(currentPlaylist, fromId!, toId);
}
await onReorder(songs.map(s => s.id)); // trigger refresh via parent
setDragHoverIndex(null);
setIsReorderDragging(false);
}}
onRowDragStartCapture={(e: React.DragEvent) => {
// Provide a simple id for intra-list reorder
if (!currentPlaylist || selectedSongs.size > 0) return;
e.dataTransfer.setData('text/song-id', song.id);
// Explicitly set effect to move for better UX
try { e.dataTransfer.effectAllowed = 'move'; } catch {}
try { e.dataTransfer.dropEffect = 'move'; } catch {}
setIsReorderDragging(true);
}}
/>
));
}, [songs, selectedSongs, selectedSongId, toggleSelection, depth, onPlaySong, handleDragStart]); // Removed handleSongSelect since it's already memoized
@ -440,7 +500,61 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
id="song-list-container"
>
<Flex direction="column" gap={2}>
<Box onDragEnd={handleDragEnd}>{songItems}</Box>
<Box onDragEnd={handleDragEnd}>
{songs.map((song, index) => (
<Box key={`row-${song.id}`} position="relative">
{dragHoverIndex === index && (
<Box position="absolute" top={0} left={0} right={0} height="2px" bg="blue.400" zIndex={1} />
)}
{songItems[index]}
</Box>
))}
</Box>
{/* Drop zone to move item to end of playlist */}
{onReorder && currentPlaylist && selectedSongs.size === 0 && isReorderDragging && (
<Box
onDragOver={(e: React.DragEvent) => {
e.preventDefault();
setDragHoverIndex(null);
setEndDropHover(true);
try { e.dataTransfer.dropEffect = 'move'; } catch {}
}}
onDragLeave={() => setEndDropHover(false)}
onDrop={async (e: React.DragEvent) => {
e.preventDefault();
const fromId = e.dataTransfer.getData('text/song-id');
const multiJson = e.dataTransfer.getData('application/json');
let multiIds: string[] | null = null;
if (multiJson) {
try {
const parsed = JSON.parse(multiJson);
if (parsed && parsed.type === 'songs' && Array.isArray(parsed.songIds)) {
multiIds = parsed.songIds as string[];
}
} catch {}
}
if (!fromId && !multiIds) return;
// Move to end: omit toId
if (multiIds && multiIds.length > 0) {
await api.moveTracksInPlaylist(currentPlaylist, multiIds);
} else {
await api.moveTrackInPlaylist(currentPlaylist, fromId!);
}
await onReorder(songs.map(s => s.id));
setEndDropHover(false);
setIsReorderDragging(false);
}}
onDragEnd={handleDragEnd}
position="relative"
height="28px"
mt={1}
>
{endDropHover && (
<Box position="absolute" top="50%" left={0} right={0} height="2px" bg="blue.400" />
)}
</Box>
)}
{/* Loading indicator for infinite scroll or playlist switching */}
{(loading || isSwitchingPlaylist) && (

View File

@ -97,6 +97,33 @@ class Api {
return response.json();
}
async reorderPlaylist(playlistName: string, orderedIds: string[]): Promise<void> {
const response = await fetch(`${API_BASE_URL}/playlists/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistName, orderedIds })
});
if (!response.ok) throw new Error('Failed to reorder playlist');
}
async moveTrackInPlaylist(playlistName: string, fromId: string, toId?: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/playlists/reorder-move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistName, fromId, toId })
});
if (!response.ok) throw new Error('Failed to move track in playlist');
}
async moveTracksInPlaylist(playlistName: string, fromIds: string[], toId?: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/playlists/reorder-move-many`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistName, fromIds, toId })
});
if (!response.ok) throw new Error('Failed to move tracks in playlist');
}
async resetDatabase(): Promise<boolean> {
try {
const response = await fetch(`${API_BASE_URL}/reset`, {