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,
|
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 ? (
|
||||||
<Select value={targetFolder} onChange={(e) => setTargetFolder(e.target.value)} bg="gray.800" borderColor="gray.700">
|
<FolderBrowser
|
||||||
{folders.map((f) => (
|
folders={folders}
|
||||||
<option key={f || 'ROOT'} value={f}>{f || '(root)'}</option>
|
selectedFolder={targetFolder}
|
||||||
))}
|
onFolderSelect={setTargetFolder}
|
||||||
</Select>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<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>
|
</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"
|
Add to "To Be Scanned"
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</HStack>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
border="2px dashed"
|
border="2px dashed"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user