mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-24 13:19:23 +00:00
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:
parent
546bfe408a
commit
ccc0a1a1ec
@ -1,252 +1,303 @@
|
|||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useMemo, useRef, useLayoutEffect } from "react";
|
||||||
import { Box, Text, Stack, Button, TextInput, Group, Tooltip, Collapse, ActionIcon } from "@mantine/core";
|
import { Box, Text, Stack } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import { baseToolRegistry, type ToolRegistryEntry } from "../../data/toolRegistry";
|
||||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
import ToolSearch from "./toolPicker/ToolSearch";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import ToolButton from "./toolPicker/ToolButton";
|
||||||
import { baseToolRegistry } from "../../data/toolRegistry";
|
import "./toolPicker/ToolPicker.css";
|
||||||
import "./ToolPicker.css";
|
|
||||||
|
|
||||||
type Tool = {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToolRegistry = {
|
|
||||||
[id: string]: Tool;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ToolPickerProps {
|
interface ToolPickerProps {
|
||||||
selectedToolKey: string | null;
|
selectedToolKey: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
toolRegistry: ToolRegistry;
|
toolRegistry: Readonly<Record<string, ToolRegistryEntry>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupedTools {
|
interface GroupedTools {
|
||||||
[category: string]: {
|
[category: string]: {
|
||||||
[subcategory: string]: Array<{ id: string; tool: Tool }>;
|
[subcategory: string]: Array<{ id: string; tool: ToolRegistryEntry }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => {
|
const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => {
|
||||||
const { t } = useTranslation();
|
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 groupedTools = useMemo(() => {
|
||||||
const grouped: GroupedTools = {};
|
const grouped: GroupedTools = {};
|
||||||
|
|
||||||
Object.entries(toolRegistry).forEach(([id, tool]) => {
|
Object.entries(toolRegistry).forEach(([id, tool]) => {
|
||||||
// Get category and subcategory from the base registry
|
|
||||||
const baseTool = baseToolRegistry[id as keyof typeof baseToolRegistry];
|
const baseTool = baseToolRegistry[id as keyof typeof baseToolRegistry];
|
||||||
const category = baseTool?.category || "Other";
|
const category = baseTool?.category || "OTHER";
|
||||||
const subcategory = baseTool?.subcategory || "General";
|
const subcategory = baseTool?.subcategory || "General";
|
||||||
|
if (!grouped[category]) grouped[category] = {};
|
||||||
if (!grouped[category]) {
|
if (!grouped[category][subcategory]) grouped[category][subcategory] = [];
|
||||||
grouped[category] = {};
|
|
||||||
}
|
|
||||||
if (!grouped[category][subcategory]) {
|
|
||||||
grouped[category][subcategory] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
grouped[category][subcategory].push({ id, tool });
|
grouped[category][subcategory].push({ id, tool });
|
||||||
});
|
});
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
}, [toolRegistry]);
|
}, [toolRegistry]);
|
||||||
|
|
||||||
// Sort categories in custom order and subcategories alphabetically - O(c * s * log(s))
|
const sections = useMemo(() => {
|
||||||
const sortedCategories = useMemo(() => {
|
const mapping: Record<string, "QUICK ACCESS" | "ALL TOOLS"> = {
|
||||||
const categoryOrder = ['RECOMMENDED TOOLS', 'STANDARD TOOLS', 'ADVANCED 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return Object.entries(groupedTools)
|
const sortSubs = (obj: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>>) =>
|
||||||
.map(([category, subcategories]) => ({
|
Object.entries(obj)
|
||||||
category,
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
subcategories: Object.entries(subcategories)
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b)) // Sort subcategories alphabetically
|
|
||||||
.map(([subcategory, tools]) => ({
|
.map(([subcategory, tools]) => ({
|
||||||
subcategory,
|
subcategory,
|
||||||
tools: tools.sort((a, b) => a.tool.name.localeCompare(b.tool.name)) // Sort tools alphabetically
|
tools: tools.sort((a, b) => a.tool.name.localeCompare(b.tool.name))
|
||||||
}))
|
}));
|
||||||
}))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aIndex = categoryOrder.indexOf(a.category.toUpperCase());
|
|
||||||
const bIndex = categoryOrder.indexOf(b.category.toUpperCase());
|
|
||||||
return aIndex - bIndex;
|
|
||||||
});
|
|
||||||
}, [groupedTools, t]);
|
|
||||||
|
|
||||||
// Filter tools based on search - O(n)
|
return [
|
||||||
const filteredCategories = useMemo(() => {
|
{ title: "QUICK ACCESS", ref: quickAccessRef, subcategories: sortSubs(quick) },
|
||||||
if (!search.trim()) return sortedCategories;
|
{ title: "ALL TOOLS", ref: allToolsRef, subcategories: sortSubs(all) }
|
||||||
|
];
|
||||||
|
}, [groupedTools]);
|
||||||
|
|
||||||
return sortedCategories.map(({ category, subcategories }) => ({
|
const visibleSections = useMemo(() => {
|
||||||
category,
|
if (!search.trim()) return sections;
|
||||||
subcategories: subcategories.map(({ subcategory, tools }) => ({
|
const term = search.toLowerCase();
|
||||||
subcategory,
|
return sections
|
||||||
tools: tools.filter(({ tool }) =>
|
.map(s => ({
|
||||||
tool.name.toLowerCase().includes(search.toLowerCase()) ||
|
...s,
|
||||||
tool.description.toLowerCase().includes(search.toLowerCase())
|
subcategories: s.subcategories
|
||||||
|
.map(sc => ({
|
||||||
|
...sc,
|
||||||
|
tools: sc.tools.filter(({ tool }) =>
|
||||||
|
tool.name.toLowerCase().includes(term) ||
|
||||||
|
tool.description.toLowerCase().includes(term)
|
||||||
)
|
)
|
||||||
})).filter(({ tools }) => tools.length > 0)
|
}))
|
||||||
})).filter(({ subcategories }) => subcategories.length > 0);
|
.filter(sc => sc.tools.length)
|
||||||
}, [sortedCategories, search, t]);
|
}))
|
||||||
|
.filter(s => s.subcategories.length);
|
||||||
|
}, [sections, search]);
|
||||||
|
|
||||||
const toggleCategory = (category: string) => {
|
const quickSection = useMemo(
|
||||||
setExpandedCategories(prev => {
|
() => visibleSections.find(s => s.title === "QUICK ACCESS"),
|
||||||
const newSet = new Set(prev);
|
[visibleSections]
|
||||||
if (newSet.has(category)) {
|
);
|
||||||
newSet.delete(category);
|
const allSection = useMemo(
|
||||||
} else {
|
() => visibleSections.find(s => s.title === "ALL TOOLS"),
|
||||||
newSet.add(category);
|
[visibleSections]
|
||||||
}
|
|
||||||
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 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 (
|
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
|
<Box
|
||||||
className="tool-picker-scrollable"
|
h="100vh"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "var(--bg-toolbar)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToolSearch value={search} onChange={setSearch} toolRegistry={toolRegistry} mode="filter" />
|
||||||
|
<Box
|
||||||
|
ref={scrollableRef}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'auto',
|
overflowY: "auto",
|
||||||
overflowX: 'hidden',
|
overflowX: "hidden",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
maxHeight: 'calc(100vh - 200px)'
|
height: "calc(100vh - 120px)"
|
||||||
}}
|
}}
|
||||||
|
className="tool-picker-scrollable"
|
||||||
>
|
>
|
||||||
<Stack align="flex-start" gap="xs">
|
{quickSection && (
|
||||||
{filteredCategories.length === 0 ? (
|
<>
|
||||||
<Text c="dimmed" size="sm">
|
<div
|
||||||
{t("toolPicker.noToolsFound", "No tools found")}
|
ref={quickHeaderRef}
|
||||||
</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={{
|
style={{
|
||||||
fontWeight: 'bold',
|
position: "sticky",
|
||||||
backgroundColor: 'var(--bg-toolbar)',
|
top: 0,
|
||||||
marginBottom: '0',
|
zIndex: 2,
|
||||||
borderTop: '1px solid var(--border-default)',
|
borderTop: `1px solid var(--tool-header-border)`,
|
||||||
borderBottom: '1px solid var(--border-default)',
|
borderBottom: `1px solid var(--tool-header-border)`,
|
||||||
borderRadius: '0',
|
marginBottom: -1,
|
||||||
padding: '0.75rem 1rem',
|
padding: "0.5rem 1rem",
|
||||||
color: 'var(--tools-text-and-icon-color)'
|
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
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{category.toUpperCase()}
|
{quickSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
|
||||||
</Button>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Subcategories */}
|
<Box ref={quickAccessRef} w="100%">
|
||||||
<Collapse in={expandedCategories.has(category)}>
|
<Stack p="sm" gap="xs">
|
||||||
<Stack gap="xs" style={{ paddingLeft: '1rem', paddingRight: '1rem' }}>
|
{quickSection.subcategories.map(sc => (
|
||||||
{subcategories.map(({ subcategory, tools }) => (
|
<Box key={sc.subcategory} w="100%">
|
||||||
<Box key={subcategory}>
|
{quickSection.subcategories.length > 1 && (
|
||||||
{/* Subcategory Header (only show if there are multiple subcategories) */}
|
|
||||||
{subcategories.length > 1 && (
|
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
fw={500}
|
fw={500}
|
||||||
style={{
|
mb="0.25rem"
|
||||||
marginBottom: '4px',
|
mt="1rem"
|
||||||
textTransform: 'uppercase',
|
className="tool-subcategory-title"
|
||||||
fontSize: '0.75rem',
|
|
||||||
borderBottom: '1px solid var(--border-default)',
|
|
||||||
paddingBottom: '0.5rem',
|
|
||||||
marginLeft: '1rem',
|
|
||||||
marginRight: '1rem',
|
|
||||||
color: 'var(--tools-text-and-icon-color)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{subcategory}
|
{sc.subcategory}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tools in this subcategory */}
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{tools.map(({ id, tool }, index) =>
|
{sc.tools.map(({ id, tool }) => (
|
||||||
renderToolButton(id, tool, index)
|
<ToolButton
|
||||||
)}
|
key={id}
|
||||||
|
id={id}
|
||||||
|
tool={tool}
|
||||||
|
isSelected={selectedToolKey === id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Collapse>
|
|
||||||
</Box>
|
</Box>
|
||||||
))
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!quickSection && !allSection && (
|
||||||
|
<Text c="dimmed" size="sm" p="sm">
|
||||||
|
{t("toolPicker.noToolsFound", "No tools found")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
32
frontend/src/components/tools/toolPicker/ToolButton.tsx
Normal file
32
frontend/src/components/tools/toolPicker/ToolButton.tsx
Normal 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;
|
@ -25,3 +25,28 @@
|
|||||||
.search-input {
|
.search-input {
|
||||||
margin: 1rem;
|
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;
|
||||||
|
}
|
128
frontend/src/components/tools/toolPicker/ToolSearch.tsx
Normal file
128
frontend/src/components/tools/toolPicker/ToolSearch.tsx
Normal 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;
|
@ -10,6 +10,7 @@ import { PageEditorFunctions } from "../types/pageEditor";
|
|||||||
import rainbowStyles from '../styles/rainbow.module.css';
|
import rainbowStyles from '../styles/rainbow.module.css';
|
||||||
|
|
||||||
import ToolPicker from "../components/tools/ToolPicker";
|
import ToolPicker from "../components/tools/ToolPicker";
|
||||||
|
import ToolSearch from "../components/tools/toolPicker/ToolSearch";
|
||||||
import TopControls from "../components/shared/TopControls";
|
import TopControls from "../components/shared/TopControls";
|
||||||
import FileEditor from "../components/fileEditor/FileEditor";
|
import FileEditor from "../components/fileEditor/FileEditor";
|
||||||
import PageEditor from "../components/pageEditor/PageEditor";
|
import PageEditor from "../components/pageEditor/PageEditor";
|
||||||
@ -29,6 +30,10 @@ function HomePageContent() {
|
|||||||
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
|
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
|
||||||
const { addToActiveFiles } = useFileHandler();
|
const { addToActiveFiles } = useFileHandler();
|
||||||
|
|
||||||
|
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
||||||
|
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||||
|
const [readerMode, setReaderMode] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
selectedTool,
|
selectedTool,
|
||||||
@ -37,11 +42,9 @@ function HomePageContent() {
|
|||||||
clearToolSelection,
|
clearToolSelection,
|
||||||
} = useToolManagement();
|
} = 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 [pageEditorFunctions, setPageEditorFunctions] = useState<PageEditorFunctions | null>(null);
|
||||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||||
|
const [toolSearch, setToolSearch] = useState("");
|
||||||
|
|
||||||
// Update file selection context when tool changes
|
// Update file selection context when tool changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -81,7 +84,13 @@ function HomePageContent() {
|
|||||||
setCurrentView(view as any);
|
setCurrentView(view as any);
|
||||||
}, [setCurrentView]);
|
}, [setCurrentView]);
|
||||||
|
|
||||||
|
const handleToolSearchSelect = useCallback((toolId: string) => {
|
||||||
|
selectTool(toolId);
|
||||||
|
setCurrentView('fileEditor');
|
||||||
|
setLeftPanelView('toolContent');
|
||||||
|
setReaderMode(false);
|
||||||
|
setToolSearch(''); // Clear search after selection
|
||||||
|
}, [selectTool, setCurrentView]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -129,8 +138,20 @@ function HomePageContent() {
|
|||||||
) : (
|
) : (
|
||||||
// Selected Tool Content View
|
// Selected Tool Content View
|
||||||
<div className="flex-1 flex flex-col">
|
<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 */}
|
{/* Back button */}
|
||||||
<div className="mb-4" style={{ padding: '0 1rem' }}>
|
<div className="mb-4" style={{ padding: '0 1rem', marginTop: '1rem'}}>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@ -109,6 +109,16 @@
|
|||||||
|
|
||||||
/* New theme colors for text and icons */
|
/* New theme colors for text and icons */
|
||||||
--tools-text-and-icon-color: #374151;
|
--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"] {
|
[data-mantine-color-scheme="dark"] {
|
||||||
@ -188,6 +198,16 @@
|
|||||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
|
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
--tools-text-and-icon-color: #D0D6DC;
|
--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 */
|
/* Smooth transitions for theme switching */
|
||||||
|
Loading…
x
Reference in New Issue
Block a user