feat(upload): replace dropdown with tree-based folder browser for better S3 folder selection UX
This commit is contained in:
parent
a49e628d93
commit
10e38fae4d
233
packages/frontend/src/components/FolderBrowser.tsx
Normal file
233
packages/frontend/src/components/FolderBrowser.tsx
Normal 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;
|
||||
@ -14,10 +14,10 @@ import {
|
||||
Icon,
|
||||
Input,
|
||||
Checkbox,
|
||||
Select,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiUpload, FiMusic, FiCheck, FiX } from 'react-icons/fi';
|
||||
import FolderBrowser from './FolderBrowser';
|
||||
|
||||
interface UploadProgress {
|
||||
fileName: string;
|
||||
@ -148,23 +148,30 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch" w="full">
|
||||
<HStack>
|
||||
<Box flex={1}>
|
||||
<Text color="gray.300" fontSize="sm" mb={1}>Target S3 folder</Text>
|
||||
<Box>
|
||||
<Text color="gray.300" fontSize="sm" mb={3}>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 || 'ROOT'} value={f}>{f || '(root)'}</option>
|
||||
))}
|
||||
</Select>
|
||||
<FolderBrowser
|
||||
folders={folders}
|
||||
selectedFolder={targetFolder}
|
||||
onFolderSelect={setTargetFolder}
|
||||
/>
|
||||
) : (
|
||||
<Input value={targetFolder} onChange={(e) => setTargetFolder(e.target.value)} placeholder="e.g. Prep/ToScan" bg="gray.800" borderColor="gray.700" />
|
||||
<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">
|
||||
|
||||
<Box>
|
||||
<Checkbox isChecked={markForScan} onChange={(e) => setMarkForScan(e.target.checked)} colorScheme="blue">
|
||||
Add to "To Be Scanned"
|
||||
</Checkbox>
|
||||
</HStack>
|
||||
</Box>
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
border="2px dashed"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user