feat(upload): add S3 folder dropdown (backend /api/music/folders) and use selected folder for uploads; keep text input as fallback

This commit is contained in:
Geert Rademakes 2025-08-13 17:02:58 +02:00
parent aae57ec55f
commit 762ae0730a
3 changed files with 80 additions and 1 deletions

View File

@ -220,6 +220,19 @@ router.get('/files', async (req, res) => {
}
});
/**
* List folders in the S3 bucket for folder selection
*/
router.get('/folders', async (req, res) => {
try {
const folders = await s3Service.listAllFolders('');
res.json({ folders });
} catch (error) {
console.error('Error fetching S3 folders:', error);
res.status(500).json({ error: 'Failed to fetch S3 folders' });
}
});
/**
* Sync S3 files with database - now uses background job system
*/

View File

@ -141,6 +141,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
*/

View File

@ -14,6 +14,7 @@ import {
Icon,
Input,
Checkbox,
Select,
useToast,
} from '@chakra-ui/react';
import { FiUpload, FiMusic, FiCheck, FiX } from 'react-icons/fi';
@ -33,6 +34,7 @@ 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 [markForScan, setMarkForScan] = useState<boolean>(true);
const toast = useToast();
@ -120,6 +122,26 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
multiple: true,
});
// Load folders for dropdown
React.useEffect(() => {
(async () => {
try {
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) {
// default to first folder if uploads not present
const defaultChoice = items.find((f: string) => f.toLowerCase().includes('upload')) || items[0];
setTargetFolder(defaultChoice.replace(/^\/+/, ''));
}
} catch (e) {
// ignore, keep text input fallback
}
})();
}, []);
const resetUploads = () => {
setUploadProgress([]);
};
@ -129,7 +151,15 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
<HStack>
<Box flex={1}>
<Text color="gray.300" fontSize="sm" mb={1}>Target S3 folder</Text>
{folders.length > 0 ? (
<Select value={targetFolder} onChange={(e) => setTargetFolder(e.target.value)} bg="gray.800" borderColor="gray.700">
{folders.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</Select>
) : (
<Input value={targetFolder} onChange={(e) => setTargetFolder(e.target.value)} placeholder="e.g. Prep/ToScan" bg="gray.800" borderColor="gray.700" />
)}
</Box>
<Checkbox isChecked={markForScan} onChange={(e) => setMarkForScan(e.target.checked)} colorScheme="blue" alignSelf="end">
Add to "To Be Scanned"