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

This commit is contained in:
EthanHealy01 2025-08-08 01:05:28 +01:00
parent 546bfe408a
commit ccc0a1a1ec
6 changed files with 492 additions and 215 deletions

View File

@ -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<Record<string, ToolRegistryEntry>>;
}
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<Set<string>>(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<HTMLDivElement>(null);
const quickHeaderRef = useRef<HTMLDivElement>(null);
const allHeaderRef = useRef<HTMLDivElement>(null);
const quickAccessRef = useRef<HTMLDivElement>(null);
const allToolsRef = useRef<HTMLDivElement>(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<string, "QUICK ACCESS" | "ALL TOOLS"> = {
"RECOMMENDED TOOLS": "QUICK ACCESS",
"STANDARD TOOLS": "ALL TOOLS",
"ADVANCED TOOLS": "ALL TOOLS"
};
const quick: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const all: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
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) => (
<Tooltip
key={id}
label={tool.description}
position="right"
withArrow
openDelay={500}
>
<Button
variant={selectedToolKey === id ? "filled" : "subtle"}
onClick={() => onSelect(id)}
size="md"
radius="md"
leftSection={
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
{tool.icon}
</div>
}
fullWidth
justify="flex-start"
style={{
borderRadius: '0',
color: 'var(--tools-text-and-icon-color)'
}}
>
<span style={{
marginRight: '8px',
opacity: 0.6,
fontSize: '0.8em',
color: 'var(--tools-text-and-icon-color)'
}}>
{index + 1}.
</span>
{tool.name}
</Button>
</Tooltip>
const sortSubs = (obj: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>>) =>
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<HTMLDivElement | null>) => {
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 (
<Box style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--bg-toolbar)',
padding: '0'
}}>
<TextInput
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
value={search}
radius="md"
onChange={(e) => setSearch(e.currentTarget.value)}
autoComplete="off"
className="search-input rounded-lg"
leftSection={<SearchIcon sx={{ fontSize: 16, color: 'var(--tools-text-and-icon-color)' }} />}
/>
<Box
h="100vh"
style={{
display: "flex",
flexDirection: "column",
background: "var(--bg-toolbar)"
}}
>
<ToolSearch value={search} onChange={setSearch} toolRegistry={toolRegistry} mode="filter" />
<Box
className="tool-picker-scrollable"
ref={scrollableRef}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
overflowY: "auto",
overflowX: "hidden",
minHeight: 0,
maxHeight: 'calc(100vh - 200px)'
height: "calc(100vh - 120px)"
}}
className="tool-picker-scrollable"
>
<Stack align="flex-start" gap="xs">
{filteredCategories.length === 0 ? (
<Text c="dimmed" size="sm">
{t("toolPicker.noToolsFound", "No tools found")}
</Text>
) : (
filteredCategories.map(({ category, subcategories }) => (
<Box key={category} style={{ width: '100%' }}>
{/* Category Header */}
<Button
variant="subtle"
onClick={() => toggleCategory(category)}
rightSection={
<div style={{
transition: 'transform 0.2s ease',
transform: expandedCategories.has(category) ? 'rotate(90deg)' : 'rotate(0deg)'
}}>
<ChevronRightIcon sx={{ fontSize: 16, color: 'var(--tools-text-and-icon-color)' }} />
</div>
}
fullWidth
justify="space-between"
style={{
fontWeight: 'bold',
backgroundColor: 'var(--bg-toolbar)',
marginBottom: '0',
borderTop: '1px solid var(--border-default)',
borderBottom: '1px solid var(--border-default)',
borderRadius: '0',
padding: '0.75rem 1rem',
color: 'var(--tools-text-and-icon-color)'
}}
>
{category.toUpperCase()}
</Button>
{quickSection && (
<>
<div
ref={quickHeaderRef}
style={{
position: "sticky",
top: 0,
zIndex: 2,
borderTop: `1px solid var(--tool-header-border)`,
borderBottom: `1px solid var(--tool-header-border)`,
marginBottom: -1,
padding: "0.5rem 1rem",
fontWeight: 700,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
onClick={() => scrollTo(quickAccessRef)}
>
<span>QUICK ACCESS</span>
<span
style={{
background: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
borderRadius: 8,
padding: "2px 8px",
fontSize: 12,
fontWeight: 700
}}
>
{quickSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</span>
</div>
{/* Subcategories */}
<Collapse in={expandedCategories.has(category)}>
<Stack gap="xs" style={{ paddingLeft: '1rem', paddingRight: '1rem' }}>
{subcategories.map(({ subcategory, tools }) => (
<Box key={subcategory}>
{/* Subcategory Header (only show if there are multiple subcategories) */}
{subcategories.length > 1 && (
<Text
size="sm"
fw={500}
style={{
marginBottom: '4px',
textTransform: 'uppercase',
fontSize: '0.75rem',
borderBottom: '1px solid var(--border-default)',
paddingBottom: '0.5rem',
marginLeft: '1rem',
marginRight: '1rem',
color: 'var(--tools-text-and-icon-color)'
}}
>
{subcategory}
</Text>
)}
<Box ref={quickAccessRef} w="100%">
<Stack p="sm" gap="xs">
{quickSection.subcategories.map(sc => (
<Box key={sc.subcategory} w="100%">
{quickSection.subcategories.length > 1 && (
<Text
size="sm"
fw={500}
mb="0.25rem"
mt="1rem"
className="tool-subcategory-title"
>
{sc.subcategory}
</Text>
)}
<Stack gap="xs">
{sc.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
))}
</Stack>
</Box>
</>
)}
{/* Tools in this subcategory */}
<Stack gap="xs">
{tools.map(({ id, tool }, index) =>
renderToolButton(id, tool, index)
)}
</Stack>
</Box>
))}
</Stack>
</Collapse>
</Box>
))
)}
</Stack>
{allSection && (
<>
<div
ref={allHeaderRef}
style={{
position: "sticky",
top: quickSection ? quickHeaderHeight - 1: 0,
zIndex: 2,
borderTop: `1px solid var(--tool-header-border)`,
borderBottom: `1px solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 700,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
onClick={() => scrollTo(allToolsRef)}
>
<span>ALL TOOLS</span>
<span
style={{
background: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
borderRadius: 8,
padding: "2px 8px",
fontSize: 12,
fontWeight: 700
}}
>
{allSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</span>
</div>
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection.subcategories.map(sc => (
<Box key={sc.subcategory} w="100%">
{allSection.subcategories.length > 1 && (
<Text
size="sm"
fw={500}
mb="0.25rem"
mt="1rem"
className="tool-subcategory-title"
>
{sc.subcategory}
</Text>
)}
<Stack gap="xs">
{sc.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
))}
</Stack>
</Box>
</>
)}
{!quickSection && !allSection && (
<Text c="dimmed" size="sm" p="sm">
{t("toolPicker.noToolsFound", "No tools found")}
</Text>
)}
</Box>
</Box>
);

View File

@ -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<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
return (
<Tooltip key={id} label={tool.description} withArrow openDelay={500}>
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={() => onSelect(id)}
size="md"
radius="md"
leftSection={<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
>
{tool.name}
</Button>
</Tooltip>
);
};
export default ToolButton;

View File

@ -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;
}

View File

@ -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<Record<string, ToolRegistryEntry>>;
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<HTMLInputElement>(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 = (
<TextInput
ref={searchRef}
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
value={value}
radius="md"
onChange={(e) => handleSearchChange(e.currentTarget.value)}
autoComplete="off"
className="search-input rounded-lg"
leftSection={<SearchIcon sx={{ fontSize: 16, color: 'var(--tools-text-and-icon-color)' }} />}
/>
);
if (mode === 'filter') {
return searchInput;
}
return (
<div ref={searchRef} style={{ position: 'relative' }}>
{searchInput}
{dropdownOpen && filteredTools.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '8px',
marginTop: '4px',
maxHeight: '300px',
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
}}
>
<Stack gap="xs" style={{ padding: '8px' }}>
{filteredTools.map(({ id, tool }) => (
<Button
key={id}
variant="subtle"
onClick={() => onToolSelect && onToolSelect(id)}
leftSection={
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
{tool.icon}
</div>
}
fullWidth
justify="flex-start"
style={{
borderRadius: '6px',
color: 'var(--tools-text-and-icon-color)',
padding: '8px 12px'
}}
>
<div style={{ textAlign: 'left' }}>
<div style={{ fontWeight: 500 }}>{tool.name}</div>
<Text size="xs" c="dimmed" style={{ marginTop: '2px' }}>
{tool.description}
</Text>
</div>
</Button>
))}
</Stack>
</div>
)}
</div>
);
};
export default ToolSearch;

View File

@ -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<PageEditorFunctions | null>(null);
const [previewFile, setPreviewFile] = useState<File | null>(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
<div className="flex-1 flex flex-col">
{/* Search bar for quick tool switching */}
<div className="mb-4 border-b-1 border-b-[var(--border-default)] mb-4" >
<ToolSearch
value={toolSearch}
onChange={setToolSearch}
toolRegistry={toolRegistry}
onToolSelect={handleToolSearchSelect}
mode="dropdown"
selectedToolKey={selectedToolKey}
/>
</div>
{/* Back button */}
<div className="mb-4" style={{ padding: '0 1rem' }}>
<div className="mb-4" style={{ padding: '0 1rem', marginTop: '1rem'}}>
<Button
variant="subtle"
size="sm"

View File

@ -109,6 +109,16 @@
/* New theme colors for text and icons */
--tools-text-and-icon-color: #374151;
/* Tool picker sticky header variables (light mode) */
--tool-header-bg: #DBEFFF;
--tool-header-border: #BEE2FF;
--tool-header-text: #1E88E5;
--tool-header-badge-bg: #C0DDFF;
--tool-header-badge-text: #004E99;
/* Subcategory title styling (light mode) */
--tool-subcategory-text-color: #6B7280;
}
[data-mantine-color-scheme="dark"] {
@ -188,6 +198,16 @@
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
--tools-text-and-icon-color: #D0D6DC;
/* Tool picker sticky header variables (dark mode) */
--tool-header-bg: #2A2F36;
--tool-header-border: #3A4047;
--tool-header-text: #D0D6DC;
--tool-header-badge-bg: #4B525A;
--tool-header-badge-text: #FFFFFF;
/* Subcategory title styling (dark mode) */
--tool-subcategory-text-color: #6B7280;
}
/* Smooth transitions for theme switching */