Compare commits
9 Commits
6c879987bf
...
96fdf64060
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96fdf64060 | ||
|
|
cf39a3c2b1 | ||
|
|
675c1f8d8f | ||
|
|
7557eddeb4 | ||
|
|
10e38fae4d | ||
|
|
a49e628d93 | ||
|
|
e4ee7230d1 | ||
|
|
762ae0730a | ||
|
|
aae57ec55f |
@ -74,9 +74,11 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
||||
}
|
||||
|
||||
const { buffer, originalname, mimetype } = req.file;
|
||||
const targetFolder = typeof req.body?.targetFolder === 'string' ? req.body.targetFolder : undefined;
|
||||
const markForScan = String(req.body?.markForScan || '').toLowerCase() === 'true';
|
||||
|
||||
// Upload to S3
|
||||
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype);
|
||||
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype, targetFolder);
|
||||
|
||||
// Extract audio metadata
|
||||
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
|
||||
@ -93,6 +95,62 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
||||
|
||||
await musicFile.save();
|
||||
|
||||
// Invalidate folder cache since we added a new file
|
||||
invalidateFolderCache();
|
||||
|
||||
// Optionally add to a special playlist for scanning
|
||||
if (markForScan) {
|
||||
try {
|
||||
const { Playlist } = await import('../models/Playlist.js');
|
||||
const { Song } = await import('../models/Song.js');
|
||||
|
||||
// Find or create the "To Be Scanned" playlist
|
||||
let toScanPlaylist = await Playlist.findOne({ name: 'To Be Scanned' });
|
||||
if (!toScanPlaylist) {
|
||||
toScanPlaylist = new Playlist({
|
||||
id: 'to-be-scanned',
|
||||
name: 'To Be Scanned',
|
||||
type: 'playlist',
|
||||
tracks: [],
|
||||
order: 0
|
||||
});
|
||||
await toScanPlaylist.save();
|
||||
console.log('✅ Created "To Be Scanned" playlist');
|
||||
}
|
||||
|
||||
// Create stub song with temporary id if needed
|
||||
const tempId = `stub-${musicFile._id.toString()}`;
|
||||
let existingStub = await Song.findOne({ id: tempId });
|
||||
if (!existingStub) {
|
||||
const stub = new Song({
|
||||
id: tempId,
|
||||
title: musicFile.title || musicFile.originalName,
|
||||
artist: musicFile.artist || '',
|
||||
album: musicFile.album || '',
|
||||
totalTime: musicFile.duration ? String(Math.round(musicFile.duration / 1000)) : '',
|
||||
location: '',
|
||||
s3File: {
|
||||
musicFileId: musicFile._id,
|
||||
s3Key: musicFile.s3Key,
|
||||
s3Url: musicFile.s3Url,
|
||||
hasS3File: true
|
||||
}
|
||||
});
|
||||
await stub.save();
|
||||
console.log('✅ Created stub song:', tempId);
|
||||
}
|
||||
|
||||
// Add stub song to playlist if not already there
|
||||
if (!toScanPlaylist.tracks.includes(tempId)) {
|
||||
toScanPlaylist.tracks.push(tempId);
|
||||
await toScanPlaylist.save();
|
||||
console.log('✅ Added song to "To Be Scanned" playlist');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to mark uploaded file for scanning:', e);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'File uploaded successfully',
|
||||
musicFile,
|
||||
@ -112,6 +170,7 @@ router.post('/batch-upload', upload.array('files', 10), async (req, res) => {
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
const { markForScan = false } = req.body;
|
||||
const results = [];
|
||||
|
||||
for (const file of req.files as Express.Multer.File[]) {
|
||||
@ -136,6 +195,61 @@ router.post('/batch-upload', upload.array('files', 10), async (req, res) => {
|
||||
|
||||
await musicFile.save();
|
||||
results.push({ success: true, musicFile });
|
||||
|
||||
// Optionally add to "To Be Scanned" playlist
|
||||
if (markForScan) {
|
||||
try {
|
||||
const { Playlist } = await import('../models/Playlist.js');
|
||||
const { Song } = await import('../models/Song.js');
|
||||
|
||||
// Find or create the "To Be Scanned" playlist
|
||||
let toScanPlaylist = await Playlist.findOne({ name: 'To Be Scanned' });
|
||||
if (!toScanPlaylist) {
|
||||
toScanPlaylist = new Playlist({
|
||||
name: 'To Be Scanned',
|
||||
type: 'playlist',
|
||||
tracks: [],
|
||||
order: 0
|
||||
});
|
||||
await toScanPlaylist.save();
|
||||
console.log('✅ Created "To Be Scanned" playlist (batch)');
|
||||
}
|
||||
|
||||
// Create stub song with temporary id if needed
|
||||
const tempId = `stub-${musicFile._id.toString()}`;
|
||||
let existingStub = await Song.findOne({ id: tempId });
|
||||
if (!existingStub) {
|
||||
const stub = new Song({
|
||||
id: tempId,
|
||||
title: musicFile.title || musicFile.originalName,
|
||||
artist: musicFile.artist || '',
|
||||
album: musicFile.album || '',
|
||||
totalTime: musicFile.duration ? String(Math.round(musicFile.duration / 1000)) : '',
|
||||
location: '',
|
||||
s3File: {
|
||||
musicFileId: musicFile._id,
|
||||
s3Key: musicFile.s3Key,
|
||||
s3Url: musicFile.s3Url,
|
||||
hasS3File: true
|
||||
}
|
||||
});
|
||||
await stub.save();
|
||||
console.log('✅ Created stub song (batch):', tempId);
|
||||
}
|
||||
|
||||
// Add stub song to playlist if not already there
|
||||
if (!toScanPlaylist.tracks.includes(tempId)) {
|
||||
toScanPlaylist.tracks.push(tempId);
|
||||
await toScanPlaylist.save();
|
||||
console.log('✅ Added song to "To Be Scanned" playlist (batch)');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to mark uploaded file for scanning (batch):', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate folder cache since we added a new file
|
||||
invalidateFolderCache();
|
||||
} catch (error) {
|
||||
console.error(`Error uploading ${file.originalname}:`, error);
|
||||
results.push({ success: false, fileName: file.originalname, error: (error as Error).message });
|
||||
@ -165,6 +279,54 @@ router.get('/files', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List folders in the S3 bucket for folder selection
|
||||
*/
|
||||
// Cache for folder listing to improve performance
|
||||
let folderCache: { folders: string[]; timestamp: number } | null = null;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Function to invalidate folder cache
|
||||
const invalidateFolderCache = () => {
|
||||
folderCache = null;
|
||||
};
|
||||
|
||||
router.get('/folders', async (req, res) => {
|
||||
try {
|
||||
// Check if we have a valid cached result
|
||||
if (folderCache && (Date.now() - folderCache.timestamp) < CACHE_DURATION) {
|
||||
return res.json({ folders: folderCache.folders });
|
||||
}
|
||||
|
||||
const folders = await s3Service.listAllFolders('');
|
||||
const result = ['', ...folders];
|
||||
|
||||
// Cache the result
|
||||
folderCache = {
|
||||
folders: result,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
res.json({ folders: result });
|
||||
} catch (error) {
|
||||
console.error('Error fetching S3 folders:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch S3 folders' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Force refresh folder cache
|
||||
*/
|
||||
router.post('/folders/refresh', async (req, res) => {
|
||||
try {
|
||||
invalidateFolderCache();
|
||||
res.json({ message: 'Folder cache refreshed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error refreshing folder cache:', error);
|
||||
res.status(500).json({ error: 'Failed to refresh folder cache' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sync S3 files with database - now uses background job system
|
||||
*/
|
||||
@ -187,6 +349,9 @@ router.post('/sync-s3', async (req, res) => {
|
||||
status: 'started'
|
||||
});
|
||||
|
||||
// Invalidate folder cache since sync might change folder structure
|
||||
invalidateFolderCache();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting S3 sync job:', error);
|
||||
res.status(500).json({ error: 'Failed to start S3 sync job' });
|
||||
@ -327,6 +492,9 @@ router.delete('/:id', async (req, res) => {
|
||||
// Delete from database
|
||||
await MusicFile.findByIdAndDelete(req.params.id);
|
||||
|
||||
// Invalidate folder cache since we removed a file
|
||||
invalidateFolderCache();
|
||||
|
||||
res.json({ message: 'Music file deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
|
||||
@ -79,10 +79,15 @@ export class S3Service {
|
||||
async uploadFile(
|
||||
file: Buffer,
|
||||
originalName: string,
|
||||
contentType: string
|
||||
contentType: string,
|
||||
targetFolder?: string
|
||||
): Promise<UploadResult> {
|
||||
const fileExtension = originalName.split('.').pop();
|
||||
const key = `music/${uuidv4()}.${fileExtension}`;
|
||||
const cleaned = typeof targetFolder === 'string' ? targetFolder.replace(/^\/+|\/+$/g, '') : '';
|
||||
const safeFolder = cleaned;
|
||||
const key = safeFolder
|
||||
? `${safeFolder}/${uuidv4()}.${fileExtension}`
|
||||
: `${uuidv4()}.${fileExtension}`;
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
@ -139,6 +144,42 @@ export class S3Service {
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all folders (prefixes) in the bucket. Recursively collects nested prefixes.
|
||||
*/
|
||||
async listAllFolders(prefix: string = ''): Promise<string[]> {
|
||||
const folders = new Set<string>();
|
||||
const queue: string[] = [prefix];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentPrefix = queue.shift() || '';
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: this.bucketName,
|
||||
Prefix: currentPrefix,
|
||||
Delimiter: '/',
|
||||
ContinuationToken: continuationToken,
|
||||
});
|
||||
const response = await this.client.send(command);
|
||||
const common = (response.CommonPrefixes || []).map(cp => cp.Prefix).filter(Boolean) as string[];
|
||||
for (const p of common) {
|
||||
// Normalize: strip trailing slash
|
||||
const normalized = p.replace(/\/+$/, '');
|
||||
if (!folders.has(normalized)) {
|
||||
folders.add(normalized);
|
||||
// Continue deeper
|
||||
queue.push(p);
|
||||
}
|
||||
}
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
}
|
||||
|
||||
return Array.from(folders).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for secure file access
|
||||
*/
|
||||
|
||||
236
packages/frontend/src/components/FolderBrowser.tsx
Normal file
236
packages/frontend/src/components/FolderBrowser.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { FiFolder } from 'react-icons/fi';
|
||||
|
||||
interface FolderNode {
|
||||
name: string;
|
||||
path: string;
|
||||
children: FolderNode[];
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
interface FolderBrowserProps {
|
||||
folders: string[];
|
||||
selectedFolder: string;
|
||||
onFolderSelect: (folder: string) => void;
|
||||
}
|
||||
|
||||
const FolderBrowser: React.FC<FolderBrowserProps> = ({
|
||||
folders,
|
||||
selectedFolder,
|
||||
onFolderSelect,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [folderTree, setFolderTree] = useState<FolderNode[]>([]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
// Convert flat folder list to tree structure
|
||||
useEffect(() => {
|
||||
const tree: FolderNode[] = [];
|
||||
const folderMap = new Map<string, FolderNode>();
|
||||
|
||||
// Add root node
|
||||
const rootNode: FolderNode = { name: '(root)', path: '', children: [] };
|
||||
tree.push(rootNode);
|
||||
folderMap.set('', rootNode);
|
||||
|
||||
// Process each folder path
|
||||
folders.forEach(folder => {
|
||||
if (folder === '') return; // Skip root as it's already added
|
||||
|
||||
const parts = folder.split('/');
|
||||
let currentPath = '';
|
||||
|
||||
parts.forEach((part) => {
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
|
||||
if (!folderMap.has(currentPath)) {
|
||||
const newNode: FolderNode = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
children: [],
|
||||
};
|
||||
|
||||
folderMap.set(currentPath, newNode);
|
||||
|
||||
if (parentPath === '') {
|
||||
// Top level folder
|
||||
tree.push(newNode);
|
||||
} else {
|
||||
// Subfolder
|
||||
const parent = folderMap.get(parentPath);
|
||||
if (parent) {
|
||||
parent.children.push(newNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setFolderTree(tree);
|
||||
}, [folders]);
|
||||
|
||||
const toggleFolder = (path: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
const isExpanded = (path: string) => expandedFolders.has(path);
|
||||
|
||||
const renderFolderNode = (node: FolderNode, level: number = 0): React.ReactNode => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const expanded = isExpanded(node.path);
|
||||
const isSelected = selectedFolder === node.path;
|
||||
|
||||
// Filter children based on search query
|
||||
const filteredChildren = node.children.filter(child =>
|
||||
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
child.path.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// If searching and this node doesn't match, only show if it has matching children
|
||||
if (searchQuery && !node.name.toLowerCase().includes(searchQuery.toLowerCase()) && filteredChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={node.path}>
|
||||
<HStack
|
||||
spacing={2}
|
||||
py={1}
|
||||
px={2}
|
||||
cursor="pointer"
|
||||
bg={isSelected ? 'blue.600' : 'transparent'}
|
||||
_hover={{ bg: isSelected ? 'blue.600' : 'gray.700' }}
|
||||
borderRadius="md"
|
||||
onClick={() => onFolderSelect(node.path)}
|
||||
minH="32px"
|
||||
>
|
||||
<Box w={`${level * 20}px`} />
|
||||
|
||||
{hasChildren ? (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={expanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFolder(node.path);
|
||||
}}
|
||||
aria-label={expanded ? 'Collapse folder' : 'Expand folder'}
|
||||
color="gray.300"
|
||||
_hover={{ bg: 'gray.700', color: 'white' }}
|
||||
_active={{ bg: 'gray.600' }}
|
||||
/>
|
||||
) : (
|
||||
<Box w="32px" />
|
||||
)}
|
||||
|
||||
<Box color={hasChildren ? 'yellow.400' : 'gray.400'}>
|
||||
<FiFolder />
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight={isSelected ? 'bold' : 'normal'}
|
||||
color={isSelected ? 'white' : 'gray.200'}
|
||||
flex={1}
|
||||
noOfLines={1}
|
||||
>
|
||||
{node.name}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{hasChildren && (
|
||||
<Collapse in={expanded}>
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{filteredChildren.map(child => renderFolderNode(child, level + 1))}
|
||||
</VStack>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredTree = searchQuery
|
||||
? folderTree.filter(node =>
|
||||
node.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
node.path.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
node.children.some(child =>
|
||||
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
child.path.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
)
|
||||
: folderTree;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InputGroup mb={3}>
|
||||
<InputLeftElement>
|
||||
<FiFolder />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Search folders..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
bg="gray.800"
|
||||
borderColor="gray.700"
|
||||
_focus={{ borderColor: 'blue.500' }}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Box
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
border="1px solid"
|
||||
borderColor="gray.700"
|
||||
borderRadius="md"
|
||||
bg="gray.900"
|
||||
sx={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: 'gray.600',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: 'gray.500',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{filteredTree.map(node => renderFolderNode(node))}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{searchQuery && filteredTree.length === 0 && (
|
||||
<Text color="gray.500" textAlign="center" py={4}>
|
||||
No folders match your search
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderBrowser;
|
||||
@ -12,9 +12,13 @@ import {
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Icon,
|
||||
Input,
|
||||
Checkbox,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiUpload, FiMusic, FiCheck, FiX } from 'react-icons/fi';
|
||||
import FolderBrowser from './FolderBrowser';
|
||||
|
||||
interface UploadProgress {
|
||||
fileName: string;
|
||||
@ -30,9 +34,17 @@ interface MusicUploadProps {
|
||||
export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) => {
|
||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [targetFolder, setTargetFolder] = useState<string>('uploads');
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [isLoadingFolders, setIsLoadingFolders] = useState(true);
|
||||
const [markForScan, setMarkForScan] = useState<boolean>(true);
|
||||
const toast = useToast();
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
console.log('onDrop called with files:', acceptedFiles);
|
||||
console.log('Current targetFolder:', targetFolder);
|
||||
console.log('Current markForScan:', markForScan);
|
||||
|
||||
setIsUploading(true);
|
||||
const newProgress: UploadProgress[] = acceptedFiles.map(file => ({
|
||||
fileName: file.name,
|
||||
@ -44,12 +56,14 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
|
||||
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < acceptedFiles.length; i++) {
|
||||
for (let i = 0; i < acceptedFiles.length; i++) {
|
||||
const file = acceptedFiles[i];
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('targetFolder', targetFolder);
|
||||
formData.append('markForScan', String(markForScan));
|
||||
|
||||
const response = await fetch('/api/music/upload', {
|
||||
method: 'POST',
|
||||
@ -103,23 +117,112 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
|
||||
|
||||
onUploadComplete?.(results);
|
||||
}
|
||||
}, [onUploadComplete, toast]);
|
||||
}, [onUploadComplete, toast, targetFolder, markForScan]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'audio/*': ['.mp3', '.wav', '.flac', '.aac', '.m4a', '.ogg', '.wma', '.opus'],
|
||||
},
|
||||
maxSize: 100 * 1024 * 1024, // 100MB
|
||||
multiple: true,
|
||||
onDropRejected: (rejectedFiles) => {
|
||||
console.log('Files rejected:', rejectedFiles);
|
||||
rejectedFiles.forEach(({ file, errors }) => {
|
||||
console.error(`File ${file.name} rejected:`, errors);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Load folders for dropdown
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setIsLoadingFolders(true);
|
||||
const res = await fetch('/api/music/folders');
|
||||
if (!res.ok) throw new Error('Failed to load folders');
|
||||
const data = await res.json();
|
||||
const items = Array.isArray(data.folders) ? data.folders : [];
|
||||
setFolders(items);
|
||||
if (items.length > 0 && targetFolder === 'uploads') {
|
||||
// default target: root (empty) if available, else first item
|
||||
const defaultChoice = items.find((f: string) => f === '') || items[0];
|
||||
setTargetFolder(defaultChoice.replace(/^\/+/, ''));
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore, keep text input fallback
|
||||
} finally {
|
||||
setIsLoadingFolders(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resetUploads = () => {
|
||||
setUploadProgress([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch" w="full">
|
||||
<Box>
|
||||
<Text color="gray.300" fontSize="sm" mb={3}>Target S3 folder</Text>
|
||||
{targetFolder && (
|
||||
<Text color="blue.400" fontSize="sm" mb={2}>
|
||||
Selected: {targetFolder === '' ? '(root)' : targetFolder}
|
||||
</Text>
|
||||
)}
|
||||
{isLoadingFolders ? (
|
||||
<Box
|
||||
p={6}
|
||||
textAlign="center"
|
||||
bg="gray.800"
|
||||
border="1px solid"
|
||||
borderColor="gray.700"
|
||||
borderRadius="md"
|
||||
>
|
||||
<Spinner size="md" color="blue.400" mb={3} />
|
||||
<Text color="gray.400" fontSize="sm">Loading folder structure...</Text>
|
||||
</Box>
|
||||
) : folders.length > 0 ? (
|
||||
<FolderBrowser
|
||||
folders={folders}
|
||||
selectedFolder={targetFolder}
|
||||
onFolderSelect={(folder) => {
|
||||
console.log('Folder selected:', folder);
|
||||
setTargetFolder(folder);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={targetFolder}
|
||||
onChange={(e) => setTargetFolder(e.target.value)}
|
||||
placeholder="e.g. Prep/ToScan"
|
||||
bg="gray.800"
|
||||
borderColor="gray.700"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Checkbox isChecked={markForScan} onChange={(e) => setMarkForScan(e.target.checked)} colorScheme="blue">
|
||||
Add to "To Be Scanned"
|
||||
</Checkbox>
|
||||
</Box>
|
||||
|
||||
{fileRejections.length > 0 && (
|
||||
<Alert status="error" bg="red.900" borderColor="red.700" color="red.100">
|
||||
<AlertIcon color="red.300" />
|
||||
<Box>
|
||||
<AlertTitle color="red.100">File Rejected</AlertTitle>
|
||||
<AlertDescription color="red.200">
|
||||
{fileRejections.map(({ file, errors }) => (
|
||||
<Text key={file.name}>
|
||||
{file.name}: {errors.map(e => e.message).join(', ')}
|
||||
</Text>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
border="2px dashed"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user