feat: Integrate all Music Storage functionality into unified Configuration page

- Move Upload Music, Music Library, and Song Matching tabs to Configuration page
- Add all music storage state management and functionality to Configuration component
- Remove standalone Music Storage page and route
- Update header to use single Configuration button instead of separate Music Storage button
- Create comprehensive 5-tab Configuration page:
  1. Library Management (XML import, database reset)
  2. Upload Music (drag & drop file uploads)
  3. Music Library (file management and S3 sync)
  4. Song Matching (link files to Rekordbox songs)
  5. S3 Configuration (external bucket setup)
- Clean up unused imports and routes
- Provide single, comprehensive configuration experience

All configuration and music storage functionality is now centralized
in one unified page with a clean tabbed interface.
This commit is contained in:
Geert Rademakes 2025-08-06 15:57:35 +02:00
parent d16217eac1
commit 9eb4587537
3 changed files with 306 additions and 20 deletions

View File

@ -0,0 +1,8 @@
{
"endpoint": "http://localhost:9000",
"region": "us-east-1",
"accessKeyId": "minioadmin",
"secretAccessKey": "minioadmin",
"bucketName": "music-files",
"useSSL": true
}

View File

@ -6,7 +6,6 @@ import { PaginatedSongList } from "./components/PaginatedSongList";
import { PlaylistManager } from "./components/PlaylistManager"; import { PlaylistManager } from "./components/PlaylistManager";
import { SongDetails } from "./components/SongDetails"; import { SongDetails } from "./components/SongDetails";
import { Configuration } from "./pages/Configuration"; import { Configuration } from "./pages/Configuration";
import { MusicStorage } from "./pages/MusicStorage";
import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer"; import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer";
import { MusicPlayerProvider, useMusicPlayer } from "./contexts/MusicPlayerContext"; import { MusicPlayerProvider, useMusicPlayer } from "./contexts/MusicPlayerContext";
import { useXmlParser } from "./hooks/useXmlParser"; import { useXmlParser } from "./hooks/useXmlParser";
@ -503,18 +502,17 @@ const RekordboxReader: React.FC = () => {
Rekordbox Reader Rekordbox Reader
</Heading> </Heading>
{/* Music Storage Button */} {/* Configuration Button */}
<Button <IconButton
size="sm" icon={<SettingsIcon />}
aria-label="Configuration"
variant="ghost" variant="ghost"
color="gray.300" color="gray.300"
_hover={{ color: "white", bg: "whiteAlpha.200" }} _hover={{ color: "white", bg: "whiteAlpha.200" }}
onClick={() => navigate('/music-storage')} onClick={() => navigate('/config')}
ml="auto" ml="auto"
mr={2} mr={2}
> />
🎵 Music Storage
</Button>
{/* Export Library Button */} {/* Export Library Button */}
<IconButton <IconButton
@ -527,23 +525,12 @@ const RekordboxReader: React.FC = () => {
onClick={handleExportLibrary} onClick={handleExportLibrary}
isDisabled={songs.length === 0} isDisabled={songs.length === 0}
/> />
{/* Configuration Button */}
<IconButton
icon={<SettingsIcon />}
aria-label="Configuration"
variant="ghost"
color="gray.300"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
onClick={() => navigate('/config')}
/>
</Flex> </Flex>
{/* Main Content */} {/* Main Content */}
<Box flex={1} overflow="hidden"> <Box flex={1} overflow="hidden">
<Routes> <Routes>
<Route path="/config" element={<Configuration />} /> <Route path="/config" element={<Configuration />} />
<Route path="/music-storage" element={<MusicStorage />} />
<Route <Route
path="*" path="*"
element={ element={

View File

@ -23,14 +23,32 @@ import {
TabPanel, TabPanel,
Icon, Icon,
HStack, HStack,
Badge,
Spinner,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { ChevronLeftIcon } from "@chakra-ui/icons"; import { ChevronLeftIcon } from "@chakra-ui/icons";
import { FiDatabase, FiSettings } from 'react-icons/fi'; import { FiDatabase, FiSettings, FiUpload, FiMusic, FiLink, FiRefreshCw, FiTrash2 } from 'react-icons/fi';
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useXmlParser } from "../hooks/useXmlParser"; import { useXmlParser } from "../hooks/useXmlParser";
import { StyledFileInput } from "../components/StyledFileInput"; import { StyledFileInput } from "../components/StyledFileInput";
import { S3Configuration } from "./S3Configuration"; import { S3Configuration } from "./S3Configuration";
import { MusicUpload } from "../components/MusicUpload";
import { SongMatching } from "../components/SongMatching";
import { api } from "../services/api"; import { api } from "../services/api";
import { useState, useEffect } from "react";
interface MusicFile {
_id: string;
originalName: string;
title?: string;
artist?: string;
album?: string;
duration?: number;
size: number;
format?: string;
uploadedAt: string;
songId?: any; // Reference to linked song
}
export function Configuration() { export function Configuration() {
const { resetLibrary } = useXmlParser(); const { resetLibrary } = useXmlParser();
@ -38,6 +56,133 @@ export function Configuration() {
const toast = useToast(); const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
// Music storage state
const [musicFiles, setMusicFiles] = useState<MusicFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
// Load music files on component mount
useEffect(() => {
loadMusicFiles();
}, []);
const loadMusicFiles = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/music/files');
if (response.ok) {
const data = await response.json();
setMusicFiles(data.musicFiles || []);
} else {
throw new Error('Failed to load music files');
}
} catch (error) {
console.error('Error loading music files:', error);
toast({
title: 'Error',
description: 'Failed to load music files',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
const handleSyncS3 = async () => {
setIsSyncing(true);
try {
const response = await fetch('/api/music/sync-s3', {
method: 'POST',
});
if (response.ok) {
const data = await response.json();
toast({
title: 'S3 Sync Complete',
description: `Found ${data.results.total} files, synced ${data.results.newFiles} new files`,
status: 'success',
duration: 5000,
isClosable: true,
});
// Reload music files to show the new ones
await loadMusicFiles();
} else {
throw new Error('Failed to sync S3');
}
} catch (error) {
console.error('Error syncing S3:', error);
toast({
title: 'Error',
description: 'Failed to sync S3 files',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsSyncing(false);
}
};
const handleUploadComplete = (files: MusicFile[]) => {
setMusicFiles(prev => [...files, ...prev]);
toast({
title: 'Upload Complete',
description: `${files.length} file${files.length !== 1 ? 's' : ''} uploaded successfully`,
status: 'success',
duration: 3000,
isClosable: true,
});
};
const handleDeleteFile = async (fileId: string) => {
try {
const response = await fetch(`/api/music/${fileId}`, {
method: 'DELETE',
});
if (response.ok) {
setMusicFiles(prev => prev.filter(file => file._id !== fileId));
toast({
title: 'File Deleted',
description: 'Music file deleted successfully',
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
throw new Error('Failed to delete file');
}
} catch (error) {
console.error('Error deleting file:', error);
toast({
title: 'Error',
description: 'Failed to delete music file',
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
const formatFileSize = (bytes: number): string => {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const formatDuration = (seconds: number): string => {
if (!seconds || isNaN(seconds)) return '00:00';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
const handleResetDatabase = async () => { const handleResetDatabase = async () => {
@ -104,6 +249,24 @@ export function Configuration() {
<Text>Library Management</Text> <Text>Library Management</Text>
</HStack> </HStack>
</Tab> </Tab>
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}>
<Icon as={FiUpload} />
<Text>Upload Music</Text>
</HStack>
</Tab>
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}>
<Icon as={FiMusic} />
<Text>Music Library</Text>
</HStack>
</Tab>
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}>
<Icon as={FiLink} />
<Text>Song Matching</Text>
</HStack>
</Tab>
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}> <Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}> <HStack spacing={2}>
<Icon as={FiSettings} /> <Icon as={FiSettings} />
@ -156,6 +319,134 @@ export function Configuration() {
</VStack> </VStack>
</TabPanel> </TabPanel>
{/* Upload Music Tab */}
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<VStack spacing={6} align="stretch">
<Box>
<Heading size="md" mb={4} color="white">
Upload Music Files
</Heading>
<Text color="gray.400" mb={4}>
Drag and drop your music files here or click to select. Files will be uploaded to S3 storage
and metadata will be automatically extracted.
</Text>
</Box>
<MusicUpload onUploadComplete={handleUploadComplete} />
</VStack>
</TabPanel>
{/* Music Library Tab */}
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<VStack spacing={4} align="stretch">
<HStack justify="space-between">
<Heading size="md" color="white">Music Library</Heading>
<HStack spacing={2}>
<Text color="gray.400">
{musicFiles.length} file{musicFiles.length !== 1 ? 's' : ''}
</Text>
<Button
leftIcon={isSyncing ? <Spinner size="sm" /> : <FiRefreshCw />}
size="sm"
variant="outline"
colorScheme="blue"
onClick={handleSyncS3}
isLoading={isSyncing}
loadingText="Syncing..."
_hover={{ bg: "blue.900", borderColor: "blue.400" }}
>
Sync S3
</Button>
</HStack>
</HStack>
{isLoading ? (
<Text textAlign="center" color="gray.500">
Loading music files...
</Text>
) : musicFiles.length === 0 ? (
<VStack spacing={4} textAlign="center" color="gray.500">
<Text>No music files found in the database.</Text>
<Text fontSize="sm">
Try uploading files in the Upload tab, or click "Sync S3" to find files already in your S3 bucket.
</Text>
<Button
leftIcon={<FiRefreshCw />}
onClick={handleSyncS3}
isLoading={isSyncing}
loadingText="Syncing..."
colorScheme="blue"
_hover={{ bg: "blue.700" }}
>
Sync S3 Bucket
</Button>
</VStack>
) : (
<VStack spacing={2} align="stretch">
{musicFiles.map((file) => (
<Box
key={file._id}
p={4}
border="1px"
borderColor="gray.700"
borderRadius="md"
bg="gray.800"
_hover={{ bg: "gray.750" }}
>
<HStack justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack spacing={2} align="center">
<Text fontWeight="bold" fontSize="md" color="white" noOfLines={1}>
{file.title || file.originalName}
</Text>
<Badge colorScheme="blue" variant="subtle" bg="blue.900" color="blue.200">
{file.format?.toUpperCase() || 'AUDIO'}
</Badge>
{file.songId && (
<Badge colorScheme="green" size="sm" bg="green.900" color="green.200">
Linked to Rekordbox
</Badge>
)}
</HStack>
{file.artist && (
<Text fontSize="sm" color="gray.400" noOfLines={1}>
{file.artist}
</Text>
)}
{file.album && (
<Text fontSize="sm" color="gray.500" noOfLines={1}>
{file.album}
</Text>
)}
<HStack spacing={4} fontSize="xs" color="gray.500">
<Text>{formatDuration(file.duration || 0)}</Text>
<Text>{formatFileSize(file.size)}</Text>
<Text>{file.format?.toUpperCase()}</Text>
</HStack>
</VStack>
<HStack spacing={2}>
<IconButton
aria-label="Delete file"
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDeleteFile(file._id)}
_hover={{ bg: "red.900" }}
/>
</HStack>
</HStack>
</Box>
))}
</VStack>
)}
</VStack>
</TabPanel>
{/* Song Matching Tab */}
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<SongMatching />
</TabPanel>
{/* S3 Configuration Tab */} {/* S3 Configuration Tab */}
<TabPanel bg="gray.800" p={0}> <TabPanel bg="gray.800" p={0}>
<Box p={6}> <Box p={6}>