575 lines
20 KiB
TypeScript

import {
Box,
Heading,
VStack,
Text,
Button,
OrderedList,
ListItem,
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useToast,
IconButton,
Flex,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Icon,
HStack,
Badge,
Spinner,
} from "@chakra-ui/react";
import { ChevronLeftIcon } from "@chakra-ui/icons";
import { FiDatabase, FiSettings, FiUpload, FiMusic, FiLink, FiRefreshCw, FiTrash2 } from 'react-icons/fi';
import { useNavigate } from "react-router-dom";
import { useXmlParser } from "../hooks/useXmlParser";
import { StyledFileInput } from "../components/StyledFileInput";
import { S3Configuration } from "./S3Configuration";
import { MusicUpload } from "../components/MusicUpload";
import { SongMatching } from "../components/SongMatching";
import { api } from "../services/api";
import { DuplicatesViewer } from "../components/DuplicatesViewer";
import { useState, useEffect, useMemo } 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() {
const { resetLibrary } = useXmlParser();
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
const navigate = useNavigate();
// Music storage state
const [musicFiles, setMusicFiles] = useState<MusicFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [hasS3Config, setHasS3Config] = useState<boolean>(false);
const [musicLoaded, setMusicLoaded] = useState<boolean>(false);
// Tabs: remember active tab across refreshes
const initialTabIndex = useMemo(() => {
const stored = localStorage.getItem('configTabIndex');
return stored ? parseInt(stored, 10) : 0;
}, []);
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
// Tab indices (keep in sync with Tab order below)
const TAB_INDEX = {
LIBRARY: 0,
UPLOAD: 1,
MUSIC_LIBRARY: 2,
MATCHING: 3,
DUPLICATES: 4,
S3_CONFIG: 5,
} as const;
// Fetch S3 config (small and safe to do on mount)
useEffect(() => {
const fetchS3Config = async () => {
try {
const res = await fetch('/api/config/s3');
if (res.ok) {
const data = await res.json();
const cfg = data?.config || {};
const required = ['endpoint', 'region', 'accessKeyId', 'secretAccessKey', 'bucketName'];
const present = required.every((k) => typeof cfg[k] === 'string' && cfg[k]);
setHasS3Config(present);
} else {
setHasS3Config(false);
}
} catch {
setHasS3Config(false);
}
};
fetchS3Config();
}, []);
// Lazy-load: only load music files when Music Library tab becomes active
useEffect(() => {
if (tabIndex === TAB_INDEX.MUSIC_LIBRARY && !musicLoaded) {
loadMusicFiles().then(() => setMusicLoaded(true));
}
}, [tabIndex]);
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 if user is on Music Library tab
if (tabIndex === TAB_INDEX.MUSIC_LIBRARY) {
await loadMusicFiles();
} else {
// Mark as not loaded so that when user opens the tab, it fetches fresh
setMusicLoaded(false);
}
} 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 () => {
try {
await api.resetDatabase();
await resetLibrary();
onClose();
toast({
title: "Database reset successful",
description: "Your library has been cleared.",
status: "success",
duration: 5000,
isClosable: true,
});
// Navigate to homepage to show welcome modal and start fresh
navigate('/');
} catch (error) {
toast({
title: "Failed to reset database",
description: "An error occurred while resetting the database.",
status: "error",
duration: 5000,
isClosable: true,
});
}
};
return (
<Box
h="100%"
bg="gray.900"
p={8}
overflowY="auto"
sx={{
'&::-webkit-scrollbar': {
width: '8px',
borderRadius: '8px',
backgroundColor: 'gray.900',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'gray.700',
borderRadius: '8px',
},
}}
>
<VStack spacing={8} align="stretch" maxW="4xl" mx="auto">
<Flex align="center" gap={4}>
<IconButton
icon={<ChevronLeftIcon boxSize={6} />}
aria-label="Go back"
variant="ghost"
onClick={() => navigate('/')}
color="gray.300"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
/>
<Heading size="lg">Configuration</Heading>
</Flex>
<Tabs
variant="enclosed"
colorScheme="blue"
isLazy
index={tabIndex}
onChange={(index) => {
setTabIndex(index);
localStorage.setItem('configTabIndex', String(index));
}}
>
<TabList bg="gray.800" borderColor="gray.700">
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}>
<Icon as={FiDatabase} />
<Text>Library Management</Text>
</HStack>
</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" }}>
<HStack spacing={2}>
<Icon as={FiDatabase} />
<Text>Duplicates</Text>
</HStack>
</Tab>
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}>
<Icon as={FiSettings} />
<Text>S3 Configuration</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
{/* Library Management Tab */}
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<VStack spacing={6} align="stretch">
<Box>
<Text color="gray.400" mb={4}>
To get started with Rekordbox Reader, you'll need to export your library from Rekordbox and import it here.
</Text>
<OrderedList spacing={3} color="gray.400" mb={6}>
<ListItem>
Open Rekordbox and go to <Text as="span" color="gray.300" fontWeight="medium">File → Export Collection in xml format</Text>
</ListItem>
<ListItem>
Choose a location to save your XML file
</ListItem>
<ListItem>
Click the button below to import your Rekordbox XML file
</ListItem>
</OrderedList>
</Box>
<Box>
<Text fontWeight="medium" mb={2}>Import Library</Text>
<StyledFileInput />
</Box>
<Box borderTopWidth="1px" borderColor="gray.700" pt={6} mt={4}>
<Text fontWeight="medium" mb={2}>Reset Database</Text>
<Text color="gray.400" mb={4}>
Clear all songs and playlists from the database. This action cannot be undone.
</Text>
<Button
onClick={onOpen}
width="full"
colorScheme="red"
variant="outline"
>
Reset Database
</Button>
</Box>
</VStack>
</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}
isDisabled={!hasS3Config || isSyncing}
loadingText="Syncing..."
_hover={{ bg: "blue.900", borderColor: "blue.400" }}
>
Sync S3
</Button>
</HStack>
</HStack>
{tabIndex === TAB_INDEX.MUSIC_LIBRARY && isLoading ? (
<Text textAlign="center" color="gray.500">
Loading music files...
</Text>
) : tabIndex === TAB_INDEX.MUSIC_LIBRARY && 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}
isDisabled={!hasS3Config || isSyncing}
loadingText="Syncing..."
colorScheme="blue"
_hover={{ bg: "blue.700" }}
>
Sync S3 Bucket
</Button>
{!hasS3Config && (
<Text fontSize="xs" color="orange.300">
Configure your S3 connection in the S3 Configuration tab first.
</Text>
)}
</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>
{/* Duplicates Tab */}
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<DuplicatesViewer />
</TabPanel>
{/* S3 Configuration Tab */}
<TabPanel bg="gray.800" p={0}>
<Box p={6}>
<S3Configuration />
</Box>
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
{/* Reset Database Confirmation Modal */}
<Modal isOpen={isOpen} onClose={onClose} isCentered>
<ModalOverlay />
<ModalContent bg="gray.800">
<ModalHeader color="white">Confirm Database Reset</ModalHeader>
<ModalBody>
<VStack spacing={4} align="stretch">
<Text color="gray.300">
Are you sure you want to reset the database? This will:
</Text>
<OrderedList spacing={2} color="gray.400" pl={4}>
<ListItem>Delete all imported songs</ListItem>
<ListItem>Remove all playlists</ListItem>
<ListItem>Clear all custom configurations</ListItem>
</OrderedList>
<Text color="red.300" fontWeight="medium">
This action cannot be undone.
</Text>
</VStack>
</ModalBody>
<ModalFooter gap={3}>
<Button
variant="outline"
onClick={onClose}
color="gray.300"
_hover={{
bg: "whiteAlpha.200",
color: "white"
}}
>
Cancel
</Button>
<Button colorScheme="red" onClick={handleResetDatabase}>
Reset Database
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
);
}