From 10e38fae4d3bab1fe136cb00fe5df467dcf6f1e4 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Thu, 14 Aug 2025 09:12:37 +0200 Subject: [PATCH] feat(upload): replace dropdown with tree-based folder browser for better S3 folder selection UX --- .../frontend/src/components/FolderBrowser.tsx | 233 ++++++++++++++++++ .../frontend/src/components/MusicUpload.tsx | 39 +-- 2 files changed, 256 insertions(+), 16 deletions(-) create mode 100644 packages/frontend/src/components/FolderBrowser.tsx diff --git a/packages/frontend/src/components/FolderBrowser.tsx b/packages/frontend/src/components/FolderBrowser.tsx new file mode 100644 index 0000000..4d59c84 --- /dev/null +++ b/packages/frontend/src/components/FolderBrowser.tsx @@ -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 = ({ + folders, + selectedFolder, + onFolderSelect, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [folderTree, setFolderTree] = useState([]); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + + // Convert flat folder list to tree structure + useEffect(() => { + const tree: FolderNode[] = []; + const folderMap = new Map(); + + // 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 ( + + onFolderSelect(node.path)} + minH="32px" + > + + + {hasChildren ? ( + : } + onClick={(e) => { + e.stopPropagation(); + toggleFolder(node.path); + }} + aria-label={expanded ? 'Collapse folder' : 'Expand folder'} + /> + ) : ( + + )} + + + + + + + {node.name} + + + + {hasChildren && ( + + + {filteredChildren.map(child => renderFolderNode(child, level + 1))} + + + )} + + ); + }; + + 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 ( + + + + + + setSearchQuery(e.target.value)} + bg="gray.800" + borderColor="gray.700" + _focus={{ borderColor: 'blue.500' }} + /> + + + + + {filteredTree.map(node => renderFolderNode(node))} + + + + {searchQuery && filteredTree.length === 0 && ( + + No folders match your search + + )} + + ); +}; + +export default FolderBrowser; diff --git a/packages/frontend/src/components/MusicUpload.tsx b/packages/frontend/src/components/MusicUpload.tsx index ddefd50..5b5defb 100644 --- a/packages/frontend/src/components/MusicUpload.tsx +++ b/packages/frontend/src/components/MusicUpload.tsx @@ -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 = ({ onUploadComplete }) => return ( - - - Target S3 folder - {folders.length > 0 ? ( - - ) : ( - setTargetFolder(e.target.value)} placeholder="e.g. Prep/ToScan" bg="gray.800" borderColor="gray.700" /> - )} - - setMarkForScan(e.target.checked)} colorScheme="blue" alignSelf="end"> + + Target S3 folder + {folders.length > 0 ? ( + + ) : ( + setTargetFolder(e.target.value)} + placeholder="e.g. Prep/ToScan" + bg="gray.800" + borderColor="gray.700" + /> + )} + + + + setMarkForScan(e.target.checked)} colorScheme="blue"> Add to "To Be Scanned" - +