feat(upload): replace dropdown with tree-based folder browser for better S3 folder selection UX

This commit is contained in:
Geert Rademakes 2025-08-14 09:12:37 +02:00
parent a49e628d93
commit 10e38fae4d
2 changed files with 256 additions and 16 deletions

View File

@ -0,0 +1,233 @@
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'}
/>
) : (
<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;

View File

@ -14,10 +14,10 @@ import {
Icon, Icon,
Input, Input,
Checkbox, Checkbox,
Select,
useToast, useToast,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FiUpload, FiMusic, FiCheck, FiX } from 'react-icons/fi'; import { FiUpload, FiMusic, FiCheck, FiX } from 'react-icons/fi';
import FolderBrowser from './FolderBrowser';
interface UploadProgress { interface UploadProgress {
fileName: string; fileName: string;
@ -148,23 +148,30 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
return ( return (
<VStack spacing={4} align="stretch" w="full"> <VStack spacing={4} align="stretch" w="full">
<HStack> <Box>
<Box flex={1}> <Text color="gray.300" fontSize="sm" mb={3}>Target S3 folder</Text>
<Text color="gray.300" fontSize="sm" mb={1}>Target S3 folder</Text> {folders.length > 0 ? (
{folders.length > 0 ? ( <FolderBrowser
<Select value={targetFolder} onChange={(e) => setTargetFolder(e.target.value)} bg="gray.800" borderColor="gray.700"> folders={folders}
{folders.map((f) => ( selectedFolder={targetFolder}
<option key={f || 'ROOT'} value={f}>{f || '(root)'}</option> onFolderSelect={setTargetFolder}
))} />
</Select> ) : (
) : ( <Input
<Input value={targetFolder} onChange={(e) => setTargetFolder(e.target.value)} placeholder="e.g. Prep/ToScan" bg="gray.800" borderColor="gray.700" /> value={targetFolder}
)} onChange={(e) => setTargetFolder(e.target.value)}
</Box> placeholder="e.g. Prep/ToScan"
<Checkbox isChecked={markForScan} onChange={(e) => setMarkForScan(e.target.checked)} colorScheme="blue" alignSelf="end"> bg="gray.800"
borderColor="gray.700"
/>
)}
</Box>
<Box>
<Checkbox isChecked={markForScan} onChange={(e) => setMarkForScan(e.target.checked)} colorScheme="blue">
Add to "To Be Scanned" Add to "To Be Scanned"
</Checkbox> </Checkbox>
</HStack> </Box>
<Box <Box
{...getRootProps()} {...getRootProps()}
border="2px dashed" border="2px dashed"