feat: Major performance optimizations - Add React.memo, useMemo, and useCallback to prevent unnecessary re-renders - Implement request cancellation with AbortController to prevent memory leaks - Add debounced search to reduce API calls - Optimize song selection and playlist switching performance - Fix browser crashes and memory leaks - Add proper cleanup functions
This commit is contained in:
parent
e98fdf50cb
commit
7fb8614130
223
README.md
223
README.md
@ -1,54 +1,191 @@
|
||||
# React + TypeScript + Vite
|
||||
# Rekordbox Reader
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
A web application for reading, managing, and exporting Rekordbox XML files. This project allows DJs to upload their Rekordbox library XML files, view and organize their music collection, manage playlists, and export modified libraries back to XML format.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## 🎵 Features
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
- **XML File Upload**: Upload and parse Rekordbox XML library files
|
||||
- **Music Library Management**: Browse and search through your music collection
|
||||
- **Playlist Management**: Create, edit, and organize playlists and folders
|
||||
- **Song Details**: View detailed information about tracks including BPM, key, rating, etc.
|
||||
- **Export Functionality**: Export modified libraries back to XML format
|
||||
- **Responsive Design**: Works on desktop and mobile devices
|
||||
- **Database Storage**: Persistent storage using MongoDB
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
## 🏗️ Architecture
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
This is a monorepo with the following structure:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
- **Frontend**: React + TypeScript + Vite application with Chakra UI
|
||||
- **Backend**: Node.js + Express + TypeScript API server
|
||||
- **Database**: MongoDB for data persistence
|
||||
- **Docker**: Containerized deployment option
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v18 or higher)
|
||||
- npm or yarn
|
||||
- MongoDB (for local development)
|
||||
|
||||
### Option 1: Development Setup (Recommended)
|
||||
|
||||
1. **Clone and install dependencies:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd rekordbox-reader
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
2. **Start MongoDB** (if not already running):
|
||||
```bash
|
||||
# On macOS with Homebrew
|
||||
brew services start mongodb-community
|
||||
|
||||
# Or using Docker
|
||||
docker run -d -p 27017:27017 --name mongodb mongo:latest
|
||||
```
|
||||
|
||||
3. **Start the development servers:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will start both frontend and backend servers concurrently:
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend: http://localhost:3000
|
||||
|
||||
### Option 2: Docker Setup
|
||||
|
||||
1. **Build and run with Docker Compose:**
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
This will start all services:
|
||||
- Frontend: http://localhost:8080
|
||||
- Backend: http://localhost:3001
|
||||
- MongoDB: localhost:27017
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
rekordbox-reader/
|
||||
├── packages/
|
||||
│ ├── frontend/ # React frontend application
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── components/ # React components
|
||||
│ │ │ ├── hooks/ # Custom React hooks
|
||||
│ │ │ ├── pages/ # Page components
|
||||
│ │ │ ├── services/ # API and XML services
|
||||
│ │ │ ├── types/ # TypeScript interfaces
|
||||
│ │ │ └── utils/ # Utility functions
|
||||
│ │ └── Dockerfile
|
||||
│ └── backend/ # Express API server
|
||||
│ ├── src/
|
||||
│ │ ├── models/ # MongoDB models
|
||||
│ │ ├── routes/ # API routes
|
||||
│ │ └── index.ts # Server entry point
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml # Docker orchestration
|
||||
└── package.json # Monorepo configuration
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
## 🛠️ Available Scripts
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
### Root Level (Monorepo)
|
||||
- `npm run dev` - Start both frontend and backend in development mode
|
||||
- `npm run dev:frontend` - Start only the frontend
|
||||
- `npm run dev:backend` - Start only the backend
|
||||
- `npm run build` - Build all packages
|
||||
- `npm run install:all` - Install dependencies for all packages
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
### Frontend (`packages/frontend/`)
|
||||
- `npm run dev` - Start Vite development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run lint` - Run ESLint
|
||||
|
||||
### Backend (`packages/backend/`)
|
||||
- `npm run dev` - Start development server with hot reload
|
||||
- `npm run build` - Build TypeScript
|
||||
- `npm run start` - Start production server
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file in the backend directory:
|
||||
|
||||
```env
|
||||
MONGODB_URI=mongodb://localhost:27017/rekordbox
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
### API Configuration
|
||||
|
||||
The frontend is configured to connect to the backend API. The API URL can be configured in:
|
||||
- Development: `packages/frontend/src/services/api.ts`
|
||||
- Production: Environment variable `VITE_API_URL` in Docker
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
- `GET /api/health` - Health check
|
||||
- `POST /api/reset` - Reset database (delete all data)
|
||||
- `GET /api/songs` - Get all songs
|
||||
- `POST /api/songs` - Create/update songs
|
||||
- `DELETE /api/songs` - Delete songs
|
||||
- `GET /api/playlists` - Get all playlists
|
||||
- `POST /api/playlists` - Create/update playlists
|
||||
- `DELETE /api/playlists` - Delete playlists
|
||||
|
||||
## 🎯 Usage
|
||||
|
||||
1. **Upload XML File**: Click "Choose XML File" to upload your Rekordbox library XML
|
||||
2. **Browse Library**: View your music collection in the main panel
|
||||
3. **Manage Playlists**: Use the playlist manager to organize your music
|
||||
4. **Export**: Export your modified library back to XML format
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **MongoDB Connection Error**:
|
||||
- Ensure MongoDB is running: `brew services start mongodb-community`
|
||||
- Check connection string in backend `.env` file
|
||||
|
||||
2. **Port Already in Use**:
|
||||
- Frontend: Change port in `packages/frontend/vite.config.ts`
|
||||
- Backend: Change `PORT` in `.env` file
|
||||
|
||||
3. **Build Errors**:
|
||||
- Clear node_modules and reinstall: `rm -rf node_modules && npm run install:all`
|
||||
|
||||
### Development Tips
|
||||
|
||||
- Use the browser's developer tools to inspect network requests
|
||||
- Check the backend console for API logs
|
||||
- Use the `/api/health` endpoint to verify backend connectivity
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feature-name`
|
||||
3. Make your changes
|
||||
4. Run tests and linting: `npm run lint`
|
||||
5. Commit your changes: `git commit -m 'Add feature'`
|
||||
6. Push to the branch: `git push origin feature-name`
|
||||
7. Submit a pull request
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- Built with React, TypeScript, and Vite
|
||||
- UI components from Chakra UI
|
||||
- XML parsing with xml2js and sax
|
||||
- Backend powered by Express and MongoDB
|
||||
|
||||
21
docker-compose.dev.yml
Normal file
21
docker-compose.dev.yml
Normal file
@ -0,0 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:latest
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- mongodb_dev_data:/data/db
|
||||
environment:
|
||||
- MONGO_INITDB_DATABASE=rekordbox
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mongodb_dev_data:
|
||||
@ -1,16 +1,55 @@
|
||||
import express from 'express';
|
||||
import { Playlist } from '../models/Playlist.js';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all playlists
|
||||
router.get('/', async (req, res) => {
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const playlists = await Playlist.find();
|
||||
const playlists = await Playlist.find({});
|
||||
res.json(playlists);
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlists:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch playlists' });
|
||||
res.status(500).json({ message: 'Error fetching playlists', error });
|
||||
}
|
||||
});
|
||||
|
||||
// Get playlist structure only (without track data) for faster loading
|
||||
router.get('/structure', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const playlists = await Playlist.find({});
|
||||
|
||||
// Remove track data from playlists to reduce payload size
|
||||
const structureOnly = playlists.map(playlist => {
|
||||
const cleanPlaylist = playlist.toObject() as any;
|
||||
|
||||
// Remove tracks from the main playlist
|
||||
delete cleanPlaylist.tracks;
|
||||
|
||||
// Recursively remove tracks from children
|
||||
const cleanChildren = (children: any[]): any[] => {
|
||||
return children.map(child => {
|
||||
const cleanChild = { ...child } as any;
|
||||
delete cleanChild.tracks;
|
||||
if (cleanChild.children && cleanChild.children.length > 0) {
|
||||
cleanChild.children = cleanChildren(cleanChild.children);
|
||||
}
|
||||
return cleanChild;
|
||||
});
|
||||
};
|
||||
|
||||
if (cleanPlaylist.children && cleanPlaylist.children.length > 0) {
|
||||
cleanPlaylist.children = cleanChildren(cleanPlaylist.children);
|
||||
}
|
||||
|
||||
return cleanPlaylist;
|
||||
});
|
||||
|
||||
res.json(structureOnly);
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlist structure:', error);
|
||||
res.status(500).json({ message: 'Error fetching playlist structure', error });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,21 +1,190 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { Song } from '../models/Song.js';
|
||||
import { Playlist } from '../models/Playlist.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get all songs
|
||||
// Get songs with pagination and search
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
console.log('Fetching songs from database...');
|
||||
const songs = await Song.find().lean();
|
||||
console.log(`Found ${songs.length} songs`);
|
||||
res.json(songs);
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const search = req.query.search as string || '';
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
console.log(`Fetching songs from database... Page: ${page}, Limit: ${limit}, Search: "${search}"`);
|
||||
|
||||
// Build query for search
|
||||
let query = {};
|
||||
if (search) {
|
||||
query = {
|
||||
$or: [
|
||||
{ title: { $regex: search, $options: 'i' } },
|
||||
{ artist: { $regex: search, $options: 'i' } },
|
||||
{ album: { $regex: search, $options: 'i' } },
|
||||
{ genre: { $regex: search, $options: 'i' } }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
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)
|
||||
.lean();
|
||||
|
||||
console.log(`Found ${songs.length} songs (${totalSongs} total)`);
|
||||
|
||||
res.json({
|
||||
songs,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalSongs,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching songs:', error);
|
||||
res.status(500).json({ message: 'Error fetching songs', error });
|
||||
}
|
||||
});
|
||||
|
||||
// Get songs by playlist with pagination
|
||||
router.get('/playlist/*', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const playlistName = decodeURIComponent(req.params[0]);
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
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}"`);
|
||||
|
||||
// Find the playlist recursively in the playlist structure
|
||||
const findPlaylistRecursively = (nodes: any[], targetName: string): any => {
|
||||
for (const node of nodes) {
|
||||
if (node.name === targetName) {
|
||||
return node;
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findPlaylistRecursively(node.children, targetName);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Get all playlists and search recursively
|
||||
const allPlaylists = await Playlist.find({});
|
||||
let playlist = null;
|
||||
|
||||
for (const playlistDoc of allPlaylists) {
|
||||
playlist = findPlaylistRecursively([playlistDoc], playlistName);
|
||||
if (playlist) break;
|
||||
}
|
||||
|
||||
if (!playlist) {
|
||||
console.log(`Playlist "${playlistName}" not found. Available playlists:`);
|
||||
const allPlaylistNames = await Playlist.find({}, 'name');
|
||||
console.log(allPlaylistNames.map(p => p.name));
|
||||
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 === 'folder' && node.children) {
|
||||
return node.children.flatMap((child: any) => getAllTrackIds(child));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const trackIds = getAllTrackIds(playlist);
|
||||
console.log(`Found ${trackIds.length} tracks in playlist "${playlistName}"`);
|
||||
|
||||
if (trackIds.length === 0) {
|
||||
return res.json({
|
||||
songs: [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalSongs: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build query for songs in playlist
|
||||
let query: any = { id: { $in: trackIds } };
|
||||
if (search) {
|
||||
query = {
|
||||
$and: [
|
||||
{ id: { $in: trackIds } },
|
||||
{
|
||||
$or: [
|
||||
{ title: { $regex: search, $options: 'i' } },
|
||||
{ artist: { $regex: search, $options: 'i' } },
|
||||
{ album: { $regex: search, $options: 'i' } },
|
||||
{ genre: { $regex: search, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
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)
|
||||
.lean();
|
||||
|
||||
console.log(`Found ${songs.length} songs for playlist "${playlistName}" (${totalSongs} total)`);
|
||||
|
||||
res.json({
|
||||
songs,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalSongs,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlist songs:', error);
|
||||
res.status(500).json({ message: 'Error fetching playlist songs', error });
|
||||
}
|
||||
});
|
||||
|
||||
// Get total song count
|
||||
router.get('/count', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const count = await Song.countDocuments();
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
console.error('Error fetching song count:', error);
|
||||
res.status(500).json({ message: 'Error fetching song count', error });
|
||||
}
|
||||
});
|
||||
|
||||
// Create multiple songs
|
||||
router.post('/batch', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@ -1,69 +1,20 @@
|
||||
import { Box, Button, Flex, Heading, Input, Spinner, Text, useStyleConfig, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton, useDisclosure, Container, Menu, MenuButton, MenuList, MenuItem, useToken, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, VStack } from "@chakra-ui/react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, HamburgerIcon, ViewIcon, SettingsIcon, DragHandleIcon } from "@chakra-ui/icons";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Box, Button, Flex, Heading, Spinner, Text, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, VStack } from "@chakra-ui/react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, SettingsIcon } from "@chakra-ui/icons";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useLocation, Routes, Route } from "react-router-dom";
|
||||
import { SongList } from "./components/SongList";
|
||||
import { PaginatedSongList } from "./components/PaginatedSongList";
|
||||
import { PlaylistManager } from "./components/PlaylistManager";
|
||||
import { SongDetails } from "./components/SongDetails";
|
||||
import { Configuration } from "./pages/Configuration";
|
||||
import { useXmlParser } from "./hooks/useXmlParser";
|
||||
import { exportToXml } from "./services/xmlService";
|
||||
import { usePaginatedSongs } from "./hooks/usePaginatedSongs";
|
||||
|
||||
import { api } from "./services/api";
|
||||
import type { Song, PlaylistNode } from "./types/interfaces";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import "./App.css";
|
||||
|
||||
const StyledFileInput = ({ isMobile = false }) => {
|
||||
const { handleFileUpload } = useXmlParser();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
width="auto"
|
||||
maxW={isMobile ? "100%" : "300px"}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".xml"
|
||||
onChange={handleFileUpload}
|
||||
height="40px"
|
||||
padding="8px 12px"
|
||||
opacity="0"
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
width="100%"
|
||||
cursor="pointer"
|
||||
ref={inputRef}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<Button
|
||||
width="100%"
|
||||
height="40px"
|
||||
bg="gray.700"
|
||||
color="gray.300"
|
||||
border="2px dashed"
|
||||
borderColor="gray.600"
|
||||
_hover={{
|
||||
bg: "gray.600",
|
||||
borderColor: "gray.500"
|
||||
}}
|
||||
_active={{
|
||||
bg: "gray.500"
|
||||
}}
|
||||
onClick={handleClick}
|
||||
size={isMobile ? "sm" : "md"}
|
||||
>
|
||||
Choose XML File
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ResizeHandle = ({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => void }) => (
|
||||
<Box
|
||||
@ -116,12 +67,17 @@ const getAllPlaylistTracks = (node: PlaylistNode): string[] => {
|
||||
};
|
||||
|
||||
export default function RekordboxReader() {
|
||||
const { songs, playlists, setPlaylists, loading } = useXmlParser();
|
||||
const { playlists, setPlaylists, loading: xmlLoading } = useXmlParser();
|
||||
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
|
||||
|
||||
// Memoized song selection handler to prevent unnecessary re-renders
|
||||
const handleSongSelect = useCallback((song: Song) => {
|
||||
setSelectedSong(song);
|
||||
}, []);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const initialLoadDone = useRef(false);
|
||||
const mobileFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isWelcomeOpen, onClose: onWelcomeClose } = useDisclosure({ defaultIsOpen: true });
|
||||
|
||||
@ -135,9 +91,19 @@ export default function RekordboxReader() {
|
||||
? decodeURIComponent(location.pathname.slice("/playlists/".length))
|
||||
: "All Songs";
|
||||
|
||||
const {
|
||||
songs,
|
||||
loading: songsLoading,
|
||||
hasMore,
|
||||
totalSongs,
|
||||
loadNextPage,
|
||||
searchSongs,
|
||||
searchQuery
|
||||
} = usePaginatedSongs({ pageSize: 50, playlistName: currentPlaylist });
|
||||
|
||||
useEffect(() => {
|
||||
// Only run this check after the initial data load
|
||||
if (!loading && playlists.length > 0) {
|
||||
if (!xmlLoading && playlists.length > 0) {
|
||||
initialLoadDone.current = true;
|
||||
}
|
||||
|
||||
@ -147,7 +113,7 @@ export default function RekordboxReader() {
|
||||
!findPlaylistByName(playlists, currentPlaylist)) {
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
}, [currentPlaylist, playlists, navigate, loading]);
|
||||
}, [currentPlaylist, playlists, navigate, xmlLoading]);
|
||||
|
||||
const handlePlaylistSelect = (name: string) => {
|
||||
setSelectedSong(null); // Clear selected song when changing playlists
|
||||
@ -232,17 +198,7 @@ export default function RekordboxReader() {
|
||||
setPlaylists(savedPlaylists);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const xmlContent = exportToXml(songs, playlists);
|
||||
const blob = new Blob([xmlContent], { type: "application/xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "rekordbox_playlists.xml";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
|
||||
const handlePlaylistDelete = async (name: string) => {
|
||||
const updatedPlaylists = playlists.filter(p => p.name !== name);
|
||||
@ -321,19 +277,8 @@ export default function RekordboxReader() {
|
||||
setPlaylists(savedPlaylists);
|
||||
};
|
||||
|
||||
const displayedSongs = currentPlaylist === "All Songs"
|
||||
? songs
|
||||
: songs.filter((song: Song) => {
|
||||
// Try to find the playlist either in nested structure or at root level
|
||||
const playlist = findPlaylistByName(playlists, currentPlaylist) ||
|
||||
playlists.find(p => p.name === currentPlaylist);
|
||||
|
||||
if (!playlist) return false;
|
||||
|
||||
// Use getAllPlaylistTracks for both nested and root playlists
|
||||
const playlistTracks = getAllPlaylistTracks(playlist);
|
||||
return playlistTracks.includes(song.id);
|
||||
});
|
||||
// Note: For now, we're showing all songs with pagination
|
||||
// TODO: Implement playlist filtering with pagination
|
||||
|
||||
const handleResizeStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@ -368,7 +313,7 @@ export default function RekordboxReader() {
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
if (loading) {
|
||||
if (xmlLoading || songsLoading) {
|
||||
return (
|
||||
<Flex height="100vh" align="center" justify="center" direction="column" gap={4}>
|
||||
<Spinner size="xl" />
|
||||
@ -410,7 +355,7 @@ export default function RekordboxReader() {
|
||||
userSelect={isResizing ? 'none' : 'auto'}
|
||||
>
|
||||
{/* Welcome Modal */}
|
||||
{!loading && songs.length === 0 && (
|
||||
{!xmlLoading && !songsLoading && songs.length === 0 && (
|
||||
<Modal isOpen={isWelcomeOpen} onClose={onWelcomeClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent bg="gray.800" maxW="md">
|
||||
@ -554,14 +499,20 @@ export default function RekordboxReader() {
|
||||
>
|
||||
{/* Song List */}
|
||||
<Box flex={1} overflowY="auto" minH={0}>
|
||||
<SongList
|
||||
songs={displayedSongs}
|
||||
<PaginatedSongList
|
||||
songs={songs}
|
||||
onAddToPlaylist={handleAddSongsToPlaylist}
|
||||
onRemoveFromPlaylist={handleRemoveFromPlaylist}
|
||||
playlists={playlists}
|
||||
onSongSelect={setSelectedSong}
|
||||
onSongSelect={handleSongSelect}
|
||||
selectedSongId={selectedSong?.id || null}
|
||||
currentPlaylist={currentPlaylist}
|
||||
loading={songsLoading}
|
||||
hasMore={hasMore}
|
||||
totalSongs={totalSongs}
|
||||
onLoadMore={loadNextPage}
|
||||
onSearch={searchSongs}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
376
packages/frontend/src/components/PaginatedSongList.tsx
Normal file
376
packages/frontend/src/components/PaginatedSongList.tsx
Normal file
@ -0,0 +1,376 @@
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Checkbox,
|
||||
Button,
|
||||
HStack,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
MenuDivider,
|
||||
IconButton,
|
||||
Spinner,
|
||||
|
||||
} from '@chakra-ui/react';
|
||||
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import type { Song, PlaylistNode } from '../types/interfaces';
|
||||
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
||||
|
||||
interface PaginatedSongListProps {
|
||||
songs: Song[];
|
||||
onAddToPlaylist: (songIds: string[], playlistName: string) => void;
|
||||
onRemoveFromPlaylist?: (songIds: string[]) => void;
|
||||
playlists: PlaylistNode[];
|
||||
onSongSelect: (song: Song) => void;
|
||||
selectedSongId: string | null;
|
||||
currentPlaylist: string | null;
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
totalSongs: number;
|
||||
onLoadMore: () => void;
|
||||
onSearch: (query: string) => void;
|
||||
searchQuery: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
// Memoized song item component to prevent unnecessary re-renders
|
||||
const SongItem = memo<{
|
||||
song: Song;
|
||||
isSelected: boolean;
|
||||
isHighlighted: boolean;
|
||||
onSelect: (song: Song) => void;
|
||||
onToggleSelection: (songId: string) => void;
|
||||
showCheckbox: boolean;
|
||||
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox }) => {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(song);
|
||||
}, [onSelect, song]);
|
||||
|
||||
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelection(song.id);
|
||||
}, [onToggleSelection, song.id]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={song.id}
|
||||
p={3}
|
||||
borderBottom="1px"
|
||||
borderColor="gray.700"
|
||||
cursor="pointer"
|
||||
bg={isHighlighted ? "blue.900" : "transparent"}
|
||||
_hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }}
|
||||
onClick={handleClick}
|
||||
transition="background-color 0.2s"
|
||||
>
|
||||
{showCheckbox && (
|
||||
<Checkbox
|
||||
isChecked={isSelected}
|
||||
onChange={handleCheckboxClick}
|
||||
mr={3}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<Box flex={1} minW={0}>
|
||||
<Text fontSize="sm" fontWeight="medium" color="white" noOfLines={1}>
|
||||
{song.title}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.400" noOfLines={1}>
|
||||
{song.artist}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box textAlign="right" ml={2}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{formatDuration(song.totalTime || '')}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{song.averageBpm} BPM
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
SongItem.displayName = 'SongItem';
|
||||
|
||||
export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
||||
songs,
|
||||
onAddToPlaylist,
|
||||
onRemoveFromPlaylist,
|
||||
playlists,
|
||||
onSongSelect,
|
||||
selectedSongId,
|
||||
currentPlaylist,
|
||||
loading,
|
||||
hasMore,
|
||||
totalSongs,
|
||||
onLoadMore,
|
||||
onSearch,
|
||||
searchQuery,
|
||||
depth = 0
|
||||
}) => {
|
||||
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Debounce search to prevent excessive API calls
|
||||
const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
|
||||
|
||||
// Memoized helper function to get all playlists (excluding folders) from the playlist tree
|
||||
const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => {
|
||||
let result: PlaylistNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'playlist') {
|
||||
result.push(node);
|
||||
} else if (node.type === 'folder' && node.children) {
|
||||
result = result.concat(getAllPlaylists(node.children));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
// Memoized flattened list of all playlists
|
||||
const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
|
||||
|
||||
const toggleSelection = useCallback((songId: string) => {
|
||||
setSelectedSongs(prev => {
|
||||
const newSelection = new Set(prev);
|
||||
if (newSelection.has(songId)) {
|
||||
newSelection.delete(songId);
|
||||
} else {
|
||||
newSelection.add(songId);
|
||||
}
|
||||
return newSelection;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
setSelectedSongs(prev =>
|
||||
prev.size === songs.length ? new Set() : new Set(songs.map(s => s.id))
|
||||
);
|
||||
}, [songs]);
|
||||
|
||||
const handleBulkAddToPlaylist = useCallback((playlistName: string) => {
|
||||
if (selectedSongs.size > 0) {
|
||||
onAddToPlaylist(Array.from(selectedSongs), playlistName);
|
||||
setSelectedSongs(new Set()); // Clear selection after action
|
||||
}
|
||||
}, [selectedSongs, onAddToPlaylist]);
|
||||
|
||||
const handleBulkRemoveFromPlaylist = useCallback(() => {
|
||||
if (selectedSongs.size > 0 && onRemoveFromPlaylist) {
|
||||
onRemoveFromPlaylist(Array.from(selectedSongs));
|
||||
setSelectedSongs(new Set()); // Clear selection after action
|
||||
}
|
||||
}, [selectedSongs, onRemoveFromPlaylist]);
|
||||
|
||||
// Memoized song selection handler
|
||||
const handleSongSelect = useCallback((song: Song) => {
|
||||
onSongSelect(song);
|
||||
}, [onSongSelect]);
|
||||
|
||||
// Memoized search handler with debouncing
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
setLocalSearchQuery(query);
|
||||
onSearch(query);
|
||||
}, [onSearch]);
|
||||
|
||||
// Memoized song items to prevent unnecessary re-renders
|
||||
const songItems = useMemo(() => {
|
||||
return songs.map(song => (
|
||||
<SongItem
|
||||
key={song.id}
|
||||
song={song}
|
||||
isSelected={selectedSongs.has(song.id)}
|
||||
isHighlighted={selectedSongId === song.id}
|
||||
onSelect={handleSongSelect}
|
||||
onToggleSelection={toggleSelection}
|
||||
showCheckbox={selectedSongs.size > 0 || depth === 0}
|
||||
/>
|
||||
));
|
||||
}, [songs, selectedSongs, selectedSongId, handleSongSelect, toggleSelection, depth]);
|
||||
|
||||
// Memoized total duration calculation
|
||||
const totalDuration = useMemo(() => {
|
||||
const totalSeconds = songs.reduce((total, song) => {
|
||||
if (!song.totalTime) return total;
|
||||
const seconds = Math.floor(Number(song.totalTime) / (song.totalTime.length > 4 ? 1000 : 1));
|
||||
return total + seconds;
|
||||
}, 0);
|
||||
return formatTotalDuration(totalSeconds);
|
||||
}, [songs]);
|
||||
|
||||
// Memoized playlist options for bulk actions
|
||||
const playlistOptions = useMemo(() => {
|
||||
return allPlaylists.map(playlist => (
|
||||
<MenuItem key={playlist.id} onClick={() => handleBulkAddToPlaylist(playlist.name)}>
|
||||
{playlist.name}
|
||||
</MenuItem>
|
||||
));
|
||||
}, [allPlaylists, handleBulkAddToPlaylist]);
|
||||
|
||||
// Handle debounced search
|
||||
useEffect(() => {
|
||||
if (debouncedSearchQuery !== searchQuery) {
|
||||
onSearch(debouncedSearchQuery);
|
||||
}
|
||||
}, [debouncedSearchQuery, searchQuery, onSearch]);
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
if (loadingRef.current) {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observerRef.current.observe(loadingRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [hasMore, loading, onLoadMore]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" height="100%">
|
||||
{/* Sticky Header */}
|
||||
<Box
|
||||
position="sticky"
|
||||
top={0}
|
||||
bg="gray.900"
|
||||
zIndex={1}
|
||||
pb={4}
|
||||
>
|
||||
{/* Search Bar */}
|
||||
<InputGroup mb={4}>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Search2Icon color="gray.300" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Search songs by title, artist, album, or genre..."
|
||||
value={localSearchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLocalSearchQuery(e.target.value)}
|
||||
bg="gray.800"
|
||||
borderColor="gray.600"
|
||||
_hover={{ borderColor: "gray.500" }}
|
||||
_focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
<Flex justify="space-between" align="center" p={2} bg="gray.800" borderRadius="md">
|
||||
<HStack spacing={4}>
|
||||
<Checkbox
|
||||
isChecked={selectedSongs.size === songs.length && songs.length > 0}
|
||||
isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < songs.length}
|
||||
onChange={toggleSelectAll}
|
||||
colorScheme="blue"
|
||||
sx={{
|
||||
'& > span:first-of-type': {
|
||||
opacity: 1,
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.500'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedSongs.size === 0
|
||||
? "Select All"
|
||||
: `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`}
|
||||
</Checkbox>
|
||||
<Text color="gray.400" fontSize="sm">
|
||||
{songs.length} of {totalSongs} songs • {totalDuration}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{selectedSongs.size > 0 && (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
>
|
||||
Actions
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{allPlaylists.map((playlist) => (
|
||||
<MenuItem
|
||||
key={playlist.id}
|
||||
onClick={() => {
|
||||
handleBulkAddToPlaylist(playlist.name);
|
||||
}}
|
||||
>
|
||||
Add to {playlist.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
color="red.300"
|
||||
onClick={() => {
|
||||
handleBulkRemoveFromPlaylist();
|
||||
}}
|
||||
>
|
||||
Remove from {currentPlaylist}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable Song List */}
|
||||
<Box flex={1} overflowY="auto" mt={2}>
|
||||
<Flex direction="column" gap={2}>
|
||||
{songItems}
|
||||
|
||||
{/* Loading indicator for infinite scroll */}
|
||||
{loading && (
|
||||
<Flex justify="center" p={4}>
|
||||
<Spinner size="md" color="blue.400" />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Intersection observer target */}
|
||||
<div ref={loadingRef} style={{ height: '20px' }} />
|
||||
|
||||
{/* End of results message */}
|
||||
{!hasMore && songs.length > 0 && (
|
||||
<Flex justify="center" p={4}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
No more songs to load
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{!loading && songs.length === 0 && (
|
||||
<Flex justify="center" p={8}>
|
||||
<Text color="gray.500">
|
||||
{searchQuery ? 'No songs found matching your search' : 'No songs available'}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@ -16,7 +16,7 @@ import {
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
|
||||
Collapse,
|
||||
MenuDivider,
|
||||
MenuGroup,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import React, { useMemo, memo } from "react";
|
||||
import { Box, VStack, Text, Divider } from "@chakra-ui/react";
|
||||
import { Song } from "../types/interfaces";
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
@ -21,7 +22,36 @@ const calculateBitrate = (size: string, totalTime: string): number | null => {
|
||||
return Math.round(bits / seconds / 1000);
|
||||
};
|
||||
|
||||
export const SongDetails: React.FC<SongDetailsProps> = ({ song }) => {
|
||||
export const SongDetails: React.FC<SongDetailsProps> = memo(({ song }) => {
|
||||
// Memoize expensive calculations
|
||||
const songDetails = useMemo(() => {
|
||||
if (!song) return null;
|
||||
|
||||
// Calculate bitrate only if imported value isn't available
|
||||
const calculatedBitrate = song.size && song.totalTime ? calculateBitrate(song.size, song.totalTime) : null;
|
||||
const displayBitrate = song.bitRate ?
|
||||
`${song.bitRate} kbps` :
|
||||
(calculatedBitrate ? `${calculatedBitrate} kbps (calculated)` : undefined);
|
||||
|
||||
const details = [
|
||||
{ label: "Title", value: song.title },
|
||||
{ label: "Artist", value: song.artist },
|
||||
{ label: "Duration", value: formatDuration(song.totalTime || '') },
|
||||
{ label: "Album", value: song.album },
|
||||
{ label: "Genre", value: song.genre },
|
||||
{ label: "BPM", value: song.averageBpm },
|
||||
{ label: "Key", value: song.tonality },
|
||||
{ label: "Year", value: song.year },
|
||||
{ label: "Label", value: song.label },
|
||||
{ label: "Mix", value: song.mix },
|
||||
{ label: "Rating", value: song.rating },
|
||||
{ label: "Bitrate", value: displayBitrate },
|
||||
{ label: "Comments", value: song.comments },
|
||||
].filter(detail => detail.value);
|
||||
|
||||
return { details, displayBitrate };
|
||||
}, [song]);
|
||||
|
||||
if (!song) {
|
||||
return (
|
||||
<Box p={4} bg="gray.800" borderRadius="md">
|
||||
@ -30,27 +60,13 @@ export const SongDetails: React.FC<SongDetailsProps> = ({ song }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate bitrate only if imported value isn't available
|
||||
const calculatedBitrate = song.size && song.totalTime ? calculateBitrate(song.size, song.totalTime) : null;
|
||||
const displayBitrate = song.bitRate ?
|
||||
`${song.bitRate} kbps` :
|
||||
(calculatedBitrate ? `${calculatedBitrate} kbps (calculated)` : undefined);
|
||||
|
||||
const details = [
|
||||
{ label: "Title", value: song.title },
|
||||
{ label: "Artist", value: song.artist },
|
||||
{ label: "Duration", value: formatDuration(song.totalTime) },
|
||||
{ label: "Album", value: song.album },
|
||||
{ label: "Genre", value: song.genre },
|
||||
{ label: "BPM", value: song.averageBpm },
|
||||
{ label: "Key", value: song.tonality },
|
||||
{ label: "Year", value: song.year },
|
||||
{ label: "Label", value: song.label },
|
||||
{ label: "Mix", value: song.mix },
|
||||
{ label: "Rating", value: song.rating },
|
||||
{ label: "Bitrate", value: displayBitrate },
|
||||
{ label: "Comments", value: song.comments },
|
||||
].filter(detail => detail.value);
|
||||
if (!songDetails) {
|
||||
return (
|
||||
<Box p={4} bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">Loading song details...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={4} bg="gray.800" borderRadius="md">
|
||||
@ -65,7 +81,7 @@ export const SongDetails: React.FC<SongDetailsProps> = ({ song }) => {
|
||||
</Box>
|
||||
<Divider borderColor="gray.700" />
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{details.map(({ label, value }) => (
|
||||
{songDetails.details.map(({ label, value }) => (
|
||||
<Box key={label}>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
{label}
|
||||
@ -103,4 +119,6 @@ export const SongDetails: React.FC<SongDetailsProps> = ({ song }) => {
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
SongDetails.displayName = 'SongDetails';
|
||||
@ -17,8 +17,7 @@ import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
|
||||
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
|
||||
import type { Song, PlaylistNode } from "../types/interfaces";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { MouseEvent } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
||||
|
||||
interface SongListProps {
|
||||
|
||||
17
packages/frontend/src/hooks/useDebounce.ts
Normal file
17
packages/frontend/src/hooks/useDebounce.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
164
packages/frontend/src/hooks/usePaginatedSongs.ts
Normal file
164
packages/frontend/src/hooks/usePaginatedSongs.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { api, type SongsResponse } from '../services/api';
|
||||
import type { Song } from '../types/interfaces';
|
||||
|
||||
interface UsePaginatedSongsOptions {
|
||||
pageSize?: number;
|
||||
initialSearch?: string;
|
||||
playlistName?: string;
|
||||
}
|
||||
|
||||
export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
||||
const { pageSize = 50, initialSearch = '', playlistName } = options;
|
||||
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||
const [totalSongs, setTotalSongs] = useState(0);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
const currentPlaylistRef = useRef(playlistName);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Cleanup function to prevent memory leaks
|
||||
const cleanup = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
loadingRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Load songs for a specific page
|
||||
const loadPage = useCallback(async (page: number, search: string = searchQuery, targetPlaylist?: string) => {
|
||||
if (loadingRef.current) return;
|
||||
|
||||
const playlistToUse = targetPlaylist || playlistName;
|
||||
|
||||
// Cleanup previous request
|
||||
cleanup();
|
||||
|
||||
// Create new abort controller for this request
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let response: SongsResponse;
|
||||
|
||||
if (playlistToUse && playlistToUse !== 'All Songs') {
|
||||
// Load songs for specific playlist
|
||||
response = await api.getPlaylistSongsPaginated(playlistToUse, page, pageSize, search);
|
||||
} else {
|
||||
// Load all songs
|
||||
response = await api.getSongsPaginated(page, pageSize, search);
|
||||
}
|
||||
|
||||
// Check if request was aborted
|
||||
if (abortControllerRef.current?.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (page === 1) {
|
||||
// First page - replace all songs
|
||||
setSongs(response.songs);
|
||||
} else {
|
||||
// Subsequent pages - append songs
|
||||
setSongs(prev => [...prev, ...response.songs]);
|
||||
}
|
||||
|
||||
setHasMore(response.pagination.hasNextPage);
|
||||
setTotalSongs(response.pagination.totalSongs);
|
||||
setCurrentPage(page);
|
||||
} catch (err) {
|
||||
// Don't set error if request was aborted
|
||||
if (!abortControllerRef.current?.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load songs');
|
||||
}
|
||||
} finally {
|
||||
if (!abortControllerRef.current?.signal.aborted) {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
}
|
||||
}, [pageSize, searchQuery, playlistName, cleanup]);
|
||||
|
||||
// Load next page (for infinite scroll)
|
||||
const loadNextPage = useCallback(() => {
|
||||
if (!loading && hasMore && !loadingRef.current) {
|
||||
loadPage(currentPage + 1);
|
||||
}
|
||||
}, [loading, hasMore, currentPage, loadPage]);
|
||||
|
||||
// Search songs with debouncing
|
||||
const searchSongs = useCallback((query: string) => {
|
||||
setSearchQuery(query);
|
||||
setSongs([]);
|
||||
setHasMore(true);
|
||||
setCurrentPage(1);
|
||||
setError(null);
|
||||
loadPage(1, query);
|
||||
}, [loadPage]);
|
||||
|
||||
// Reset to initial state
|
||||
const reset = useCallback(() => {
|
||||
cleanup();
|
||||
setSongs([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setHasMore(true);
|
||||
setCurrentPage(1);
|
||||
setSearchQuery(initialSearch);
|
||||
setIsInitialLoad(true);
|
||||
}, [initialSearch, cleanup]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadPage(1);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle playlist changes
|
||||
useEffect(() => {
|
||||
if (currentPlaylistRef.current !== playlistName) {
|
||||
currentPlaylistRef.current = playlistName;
|
||||
if (!isInitialLoad) {
|
||||
setSongs([]);
|
||||
setHasMore(true);
|
||||
setCurrentPage(1);
|
||||
setSearchQuery(initialSearch);
|
||||
setError(null);
|
||||
// Use setTimeout to avoid the dependency issue
|
||||
setTimeout(() => {
|
||||
loadPage(1, initialSearch, playlistName);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, [playlistName, isInitialLoad, initialSearch, loadPage]);
|
||||
|
||||
return {
|
||||
songs,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
totalSongs,
|
||||
currentPage,
|
||||
searchQuery,
|
||||
isInitialLoad,
|
||||
loadNextPage,
|
||||
searchSongs,
|
||||
reset,
|
||||
refresh: () => loadPage(1)
|
||||
};
|
||||
};
|
||||
@ -1,27 +1,30 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { Song, PlaylistNode } from "../types/interfaces";
|
||||
import type { PlaylistNode } from "../types/interfaces";
|
||||
import { parseXmlFile } from "../services/xmlService";
|
||||
import { api } from "../services/api";
|
||||
|
||||
export const useXmlParser = () => {
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [playlists, setPlaylists] = useState<PlaylistNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Load data from the backend on mount
|
||||
// Load playlist structure from the backend on mount (faster loading)
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [loadedSongs, loadedPlaylists] = await Promise.all([
|
||||
api.getSongs(),
|
||||
api.getPlaylists()
|
||||
]);
|
||||
setSongs(loadedSongs);
|
||||
// Load just the structure first for faster initial load
|
||||
const loadedPlaylists = await api.getPlaylistStructure();
|
||||
setPlaylists(loadedPlaylists);
|
||||
} catch (err) {
|
||||
console.error("Error loading data from backend:", err);
|
||||
console.error("Error loading playlist structure from backend:", err);
|
||||
// Fallback to full playlist data if structure endpoint fails
|
||||
try {
|
||||
const fullPlaylists = await api.getPlaylists();
|
||||
setPlaylists(fullPlaylists);
|
||||
} catch (fallbackErr) {
|
||||
console.error("Error loading full playlists from backend:", fallbackErr);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -57,15 +60,12 @@ export const useXmlParser = () => {
|
||||
};
|
||||
|
||||
const resetLibrary = async () => {
|
||||
setSongs([]);
|
||||
setPlaylists([]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return {
|
||||
songs,
|
||||
playlists,
|
||||
setSongs,
|
||||
setPlaylists,
|
||||
handleFileUpload,
|
||||
loading,
|
||||
|
||||
@ -2,27 +2,17 @@ import { Box, Heading, VStack, Text, Button, OrderedList, ListItem, useDisclosur
|
||||
import { ChevronLeftIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useXmlParser } from "../hooks/useXmlParser";
|
||||
import { exportToXml } from "../services/xmlService";
|
||||
|
||||
import { StyledFileInput } from "../components/StyledFileInput";
|
||||
import { api } from "../services/api";
|
||||
|
||||
export function Configuration() {
|
||||
const { songs, resetLibrary } = useXmlParser();
|
||||
const { resetLibrary } = useXmlParser();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleExport = () => {
|
||||
const xmlContent = exportToXml(songs, []);
|
||||
const blob = new Blob([xmlContent], { type: "application/xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "rekordbox_playlists.xml";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
|
||||
const handleResetDatabase = async () => {
|
||||
try {
|
||||
@ -105,31 +95,7 @@ export function Configuration() {
|
||||
<StyledFileInput />
|
||||
</Box>
|
||||
|
||||
{songs.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="medium" mb={2}>Export Library</Text>
|
||||
<Text color="gray.400" mb={4}>
|
||||
Export your current library back to XML format to import into Rekordbox.
|
||||
</Text>
|
||||
<OrderedList spacing={3} color="gray.400" mb={4}>
|
||||
<ListItem>
|
||||
Click the button below to export your library to XML
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
Open Rekordbox 7 and go to <Text as="span" color="gray.300" fontWeight="medium">Preferences → Advanced → rekordbox xml</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
Select your exported XML file in the <Text as="span" color="gray.300" fontWeight="medium">rekordbox xml</Text> section
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
Your imported playlists will appear in the left sidebar under <Text as="span" color="gray.300" fontWeight="medium">the sd card with < > in them icon.</></Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
<Button onClick={handleExport} width="full">
|
||||
Export XML
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
<Box borderTopWidth="1px" borderColor="gray.700" pt={6} mt={4}>
|
||||
<Text fontWeight="medium" mb={2}>Reset Database</Text>
|
||||
|
||||
@ -2,13 +2,63 @@ import type { Song, PlaylistNode } from '../types/interfaces';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
limit: number;
|
||||
totalSongs: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
}
|
||||
|
||||
export interface SongsResponse {
|
||||
songs: Song[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
class Api {
|
||||
// Legacy method for backward compatibility
|
||||
async getSongs(): Promise<Song[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/songs`);
|
||||
if (!response.ok) throw new Error('Failed to fetch songs');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// New paginated method for all songs
|
||||
async getSongsPaginated(page: number = 1, limit: number = 50, search: string = ''): Promise<SongsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
...(search && { search })
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/songs?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch songs');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// New paginated method for playlist songs
|
||||
async getPlaylistSongsPaginated(playlistName: string, page: number = 1, limit: number = 50, search: string = ''): Promise<SongsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
...(search && { search })
|
||||
});
|
||||
|
||||
const encodedPlaylistName = encodeURIComponent(playlistName);
|
||||
const response = await fetch(`${API_BASE_URL}/songs/playlist/${encodedPlaylistName}?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch playlist songs');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Get total song count
|
||||
async getSongCount(): Promise<number> {
|
||||
const response = await fetch(`${API_BASE_URL}/songs/count`);
|
||||
if (!response.ok) throw new Error('Failed to fetch song count');
|
||||
const data = await response.json();
|
||||
return data.count;
|
||||
}
|
||||
|
||||
async saveSongs(songs: Song[]): Promise<Song[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/songs/batch`, {
|
||||
method: 'POST',
|
||||
@ -27,6 +77,13 @@ class Api {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Get playlist structure only (without track data) for faster loading
|
||||
async getPlaylistStructure(): Promise<PlaylistNode[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/playlists/structure`);
|
||||
if (!response.ok) throw new Error('Failed to fetch playlist structure');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async savePlaylists(playlists: PlaylistNode[]): Promise<PlaylistNode[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/playlists/batch`, {
|
||||
method: 'POST',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user