From ccc0a1a1ec81adebd8651e60a5eba02089247da6 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Fri, 8 Aug 2025 01:05:28 +0100 Subject: [PATCH] styling looks right but this will cause massive conflicts, going to merge this with main once all the other in-flight PRs are finished before I continue with it further --- frontend/src/components/tools/ToolPicker.tsx | 469 ++++++++++-------- .../tools/toolPicker/ToolButton.tsx | 32 ++ .../tools/{ => toolPicker}/ToolPicker.css | 27 +- .../tools/toolPicker/ToolSearch.tsx | 128 +++++ frontend/src/pages/HomePage.tsx | 31 +- frontend/src/styles/theme.css | 20 + 6 files changed, 492 insertions(+), 215 deletions(-) create mode 100644 frontend/src/components/tools/toolPicker/ToolButton.tsx rename frontend/src/components/tools/{ => toolPicker}/ToolPicker.css (51%) create mode 100644 frontend/src/components/tools/toolPicker/ToolSearch.tsx diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index 8a0532458..bfdb3cda4 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -1,251 +1,302 @@ -import React, { useState, useMemo } from "react"; -import { Box, Text, Stack, Button, TextInput, Group, Tooltip, Collapse, ActionIcon } from "@mantine/core"; +import React, { useState, useMemo, useRef, useLayoutEffect } from "react"; +import { Box, Text, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import SearchIcon from "@mui/icons-material/Search"; -import { baseToolRegistry } from "../../data/toolRegistry"; -import "./ToolPicker.css"; - -type Tool = { - icon: React.ReactNode; - name: string; - description: string; -}; - -type ToolRegistry = { - [id: string]: Tool; -}; +import { baseToolRegistry, type ToolRegistryEntry } from "../../data/toolRegistry"; +import ToolSearch from "./toolPicker/ToolSearch"; +import ToolButton from "./toolPicker/ToolButton"; +import "./toolPicker/ToolPicker.css"; interface ToolPickerProps { selectedToolKey: string | null; onSelect: (id: string) => void; - toolRegistry: ToolRegistry; + toolRegistry: Readonly>; } interface GroupedTools { [category: string]: { - [subcategory: string]: Array<{ id: string; tool: Tool }>; + [subcategory: string]: Array<{ id: string; tool: ToolRegistryEntry }>; }; } const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => { const { t } = useTranslation(); - const [search, setSearch] = useState(""); - const [expandedCategories, setExpandedCategories] = useState>(new Set()); - // Group tools by category and subcategory in a single pass - O(n) + const [search, setSearch] = useState(""); + const [quickHeaderHeight, setQuickHeaderHeight] = useState(0); + const [allHeaderHeight, setAllHeaderHeight] = useState(0); + + const scrollableRef = useRef(null); + const quickHeaderRef = useRef(null); + const allHeaderRef = useRef(null); + const quickAccessRef = useRef(null); + const allToolsRef = useRef(null); + + useLayoutEffect(() => { + const update = () => { + if (quickHeaderRef.current) { + setQuickHeaderHeight(quickHeaderRef.current.offsetHeight); + } + if (allHeaderRef.current) { + setAllHeaderHeight(allHeaderRef.current.offsetHeight); + } + }; + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, []); + const groupedTools = useMemo(() => { const grouped: GroupedTools = {}; - Object.entries(toolRegistry).forEach(([id, tool]) => { - // Get category and subcategory from the base registry const baseTool = baseToolRegistry[id as keyof typeof baseToolRegistry]; - const category = baseTool?.category || "Other"; + const category = baseTool?.category || "OTHER"; const subcategory = baseTool?.subcategory || "General"; - - if (!grouped[category]) { - grouped[category] = {}; - } - if (!grouped[category][subcategory]) { - grouped[category][subcategory] = []; - } - + if (!grouped[category]) grouped[category] = {}; + if (!grouped[category][subcategory]) grouped[category][subcategory] = []; grouped[category][subcategory].push({ id, tool }); }); - return grouped; }, [toolRegistry]); - // Sort categories in custom order and subcategories alphabetically - O(c * s * log(s)) - const sortedCategories = useMemo(() => { - const categoryOrder = ['RECOMMENDED TOOLS', 'STANDARD TOOLS', 'ADVANCED TOOLS']; - - return Object.entries(groupedTools) - .map(([category, subcategories]) => ({ - category, - subcategories: Object.entries(subcategories) - .sort(([a], [b]) => a.localeCompare(b)) // Sort subcategories alphabetically - .map(([subcategory, tools]) => ({ - subcategory, - tools: tools.sort((a, b) => a.tool.name.localeCompare(b.tool.name)) // Sort tools alphabetically - })) - })) - .sort((a, b) => { - const aIndex = categoryOrder.indexOf(a.category.toUpperCase()); - const bIndex = categoryOrder.indexOf(b.category.toUpperCase()); - return aIndex - bIndex; + const sections = useMemo(() => { + const mapping: Record = { + "RECOMMENDED TOOLS": "QUICK ACCESS", + "STANDARD TOOLS": "ALL TOOLS", + "ADVANCED TOOLS": "ALL TOOLS" + }; + const quick: Record> = {}; + const all: Record> = {}; + Object.entries(groupedTools).forEach(([origCat, subs]) => { + const bucket = mapping[origCat.toUpperCase()] || "ALL TOOLS"; + const target = bucket === "QUICK ACCESS" ? quick : all; + Object.entries(subs).forEach(([sub, tools]) => { + if (!target[sub]) target[sub] = []; + target[sub].push(...tools); }); - }, [groupedTools, t]); - - // Filter tools based on search - O(n) - const filteredCategories = useMemo(() => { - if (!search.trim()) return sortedCategories; - - return sortedCategories.map(({ category, subcategories }) => ({ - category, - subcategories: subcategories.map(({ subcategory, tools }) => ({ - subcategory, - tools: tools.filter(({ tool }) => - tool.name.toLowerCase().includes(search.toLowerCase()) || - tool.description.toLowerCase().includes(search.toLowerCase()) - ) - })).filter(({ tools }) => tools.length > 0) - })).filter(({ subcategories }) => subcategories.length > 0); - }, [sortedCategories, search, t]); - - const toggleCategory = (category: string) => { - setExpandedCategories(prev => { - const newSet = new Set(prev); - if (newSet.has(category)) { - newSet.delete(category); - } else { - newSet.add(category); - } - return newSet; }); - }; - const renderToolButton = (id: string, tool: Tool, index: number) => ( - - - + const sortSubs = (obj: Record>) => + Object.entries(obj) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([subcategory, tools]) => ({ + subcategory, + tools: tools.sort((a, b) => a.tool.name.localeCompare(b.tool.name)) + })); + + return [ + { title: "QUICK ACCESS", ref: quickAccessRef, subcategories: sortSubs(quick) }, + { title: "ALL TOOLS", ref: allToolsRef, subcategories: sortSubs(all) } + ]; + }, [groupedTools]); + + const visibleSections = useMemo(() => { + if (!search.trim()) return sections; + const term = search.toLowerCase(); + return sections + .map(s => ({ + ...s, + subcategories: s.subcategories + .map(sc => ({ + ...sc, + tools: sc.tools.filter(({ tool }) => + tool.name.toLowerCase().includes(term) || + tool.description.toLowerCase().includes(term) + ) + })) + .filter(sc => sc.tools.length) + })) + .filter(s => s.subcategories.length); + }, [sections, search]); + + const quickSection = useMemo( + () => visibleSections.find(s => s.title === "QUICK ACCESS"), + [visibleSections] + ); + const allSection = useMemo( + () => visibleSections.find(s => s.title === "ALL TOOLS"), + [visibleSections] ); + const scrollTo = (ref: React.RefObject) => { + const container = scrollableRef.current; + const target = ref.current; + if (container && target) { + const stackedOffset = ref === allToolsRef + ? (quickHeaderHeight + allHeaderHeight) + : quickHeaderHeight; + const top = target.offsetTop - container.offsetTop - (stackedOffset || 0); + container.scrollTo({ + top: Math.max(0, top), + behavior: "smooth" + }); + } + }; + return ( - - setSearch(e.currentTarget.value)} - autoComplete="off" - className="search-input rounded-lg" - leftSection={} - /> + + - - {filteredCategories.length === 0 ? ( - - {t("toolPicker.noToolsFound", "No tools found")} - - ) : ( - filteredCategories.map(({ category, subcategories }) => ( - - {/* Category Header */} - + {quickSection && ( + <> +
scrollTo(quickAccessRef)} + > + QUICK ACCESS + + {quickSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} + +
- {/* Subcategories */} - - - {subcategories.map(({ subcategory, tools }) => ( - - {/* Subcategory Header (only show if there are multiple subcategories) */} - {subcategories.length > 1 && ( - - {subcategory} - - )} + + + {quickSection.subcategories.map(sc => ( + + {quickSection.subcategories.length > 1 && ( + + {sc.subcategory} + + )} + + {sc.tools.map(({ id, tool }) => ( + + ))} + + + ))} + + + + )} - {/* Tools in this subcategory */} - - {tools.map(({ id, tool }, index) => - renderToolButton(id, tool, index) - )} - - - ))} - - -
- )) - )} -
+ {allSection && ( + <> +
scrollTo(allToolsRef)} + > + ALL TOOLS + + {allSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} + +
+ + + + {allSection.subcategories.map(sc => ( + + {allSection.subcategories.length > 1 && ( + + {sc.subcategory} + + )} + + {sc.tools.map(({ id, tool }) => ( + + ))} + + + ))} + + + + )} + + {!quickSection && !allSection && ( + + {t("toolPicker.noToolsFound", "No tools found")} + + )}
); diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx new file mode 100644 index 000000000..06e3d5fd2 --- /dev/null +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Button, Tooltip } from "@mantine/core"; +import { type ToolRegistryEntry } from "../../../data/toolRegistry"; + +interface ToolButtonProps { + id: string; + tool: ToolRegistryEntry; + isSelected: boolean; + onSelect: (id: string) => void; +} + +const ToolButton: React.FC = ({ id, tool, isSelected, onSelect }) => { + return ( + + + + ); +}; + +export default ToolButton; \ No newline at end of file diff --git a/frontend/src/components/tools/ToolPicker.css b/frontend/src/components/tools/toolPicker/ToolPicker.css similarity index 51% rename from frontend/src/components/tools/ToolPicker.css rename to frontend/src/components/tools/toolPicker/ToolPicker.css index dbd49f4f6..964e2f4ff 100644 --- a/frontend/src/components/tools/ToolPicker.css +++ b/frontend/src/components/tools/toolPicker/ToolPicker.css @@ -20,8 +20,33 @@ .tool-picker-scrollable::-webkit-scrollbar-thumb:hover { background-color: var(--mantine-color-gray-5); -} +} .search-input { margin: 1rem; +} + +.tool-subcategory-title { + text-transform: uppercase; + padding-bottom: 0.5rem; + font-size: 0.75rem; + color: var(--tool-subcategory-text-color); + /* Align the text with tool labels to account for icon gutter */ + padding-left: 1rem; +} + +/* Compact tool buttons */ +.tool-button { + font-size: 0.875rem; /* default 1rem - 0.125rem? We'll apply exact -0.25rem via calc below */ + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} + +.tool-button .mantine-Button-label { + font-size: .85rem; +} + +.tool-button-icon { + font-size: 1rem; + line-height: 1; } \ No newline at end of file diff --git a/frontend/src/components/tools/toolPicker/ToolSearch.tsx b/frontend/src/components/tools/toolPicker/ToolSearch.tsx new file mode 100644 index 000000000..901c0a054 --- /dev/null +++ b/frontend/src/components/tools/toolPicker/ToolSearch.tsx @@ -0,0 +1,128 @@ +import React, { useState, useRef, useEffect, useMemo } from "react"; +import { TextInput, Stack, Button, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import SearchIcon from "@mui/icons-material/Search"; +import { type ToolRegistryEntry } from "../../../data/toolRegistry"; + +interface ToolSearchProps { + value: string; + onChange: (value: string) => void; + toolRegistry: Readonly>; + onToolSelect?: (toolId: string) => void; + mode: 'filter' | 'dropdown'; + selectedToolKey?: string | null; +} + +const ToolSearch = ({ + value, + onChange, + toolRegistry, + onToolSelect, + mode = 'filter', + selectedToolKey +}: ToolSearchProps) => { + const { t } = useTranslation(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const searchRef = useRef(null); + + const filteredTools = useMemo(() => { + if (!value.trim()) return []; + return Object.entries(toolRegistry) + .filter(([id, tool]) => { + if (mode === 'dropdown' && id === selectedToolKey) return false; + return tool.name.toLowerCase().includes(value.toLowerCase()) || + tool.description.toLowerCase().includes(value.toLowerCase()); + }) + .slice(0, 6) + .map(([id, tool]) => ({ id, tool })); + }, [value, toolRegistry, mode, selectedToolKey]); + + const handleSearchChange = (searchValue: string) => { + onChange(searchValue); + if (mode === 'dropdown') { + setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0); + } + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const searchInput = ( + handleSearchChange(e.currentTarget.value)} + autoComplete="off" + className="search-input rounded-lg" + leftSection={} + /> + ); + + if (mode === 'filter') { + return searchInput; + } + + return ( +
+ {searchInput} + {dropdownOpen && filteredTools.length > 0 && ( +
+ + {filteredTools.map(({ id, tool }) => ( + + ))} + +
+ )} +
+ ); +}; + +export default ToolSearch; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index e5c9a151e..f86d0d229 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -10,6 +10,7 @@ import { PageEditorFunctions } from "../types/pageEditor"; import rainbowStyles from '../styles/rainbow.module.css'; import ToolPicker from "../components/tools/ToolPicker"; +import ToolSearch from "../components/tools/toolPicker/ToolSearch"; import TopControls from "../components/shared/TopControls"; import FileEditor from "../components/fileEditor/FileEditor"; import PageEditor from "../components/pageEditor/PageEditor"; @@ -29,6 +30,10 @@ function HomePageContent() { const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); const { addToActiveFiles } = useFileHandler(); + const [sidebarsVisible, setSidebarsVisible] = useState(true); + const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); + const [readerMode, setReaderMode] = useState(false); + const { selectedToolKey, selectedTool, @@ -37,11 +42,9 @@ function HomePageContent() { clearToolSelection, } = useToolManagement(); - const [sidebarsVisible, setSidebarsVisible] = useState(true); - const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); - const [readerMode, setReaderMode] = useState(false); const [pageEditorFunctions, setPageEditorFunctions] = useState(null); const [previewFile, setPreviewFile] = useState(null); + const [toolSearch, setToolSearch] = useState(""); // Update file selection context when tool changes useEffect(() => { @@ -81,7 +84,13 @@ function HomePageContent() { setCurrentView(view as any); }, [setCurrentView]); - + const handleToolSearchSelect = useCallback((toolId: string) => { + selectTool(toolId); + setCurrentView('fileEditor'); + setLeftPanelView('toolContent'); + setReaderMode(false); + setToolSearch(''); // Clear search after selection + }, [selectTool, setCurrentView]); return ( @@ -129,8 +138,20 @@ function HomePageContent() { ) : ( // Selected Tool Content View
+ {/* Search bar for quick tool switching */} +
+ +
+ {/* Back button */} -
+