re-structure baseToolRegistry to be a nested shape, preserving the order of tools and links

This commit is contained in:
EthanHealy01 2025-08-11 18:49:55 +01:00
parent b6d56ba587
commit 8581829f6e
9 changed files with 394 additions and 55 deletions

View File

@ -22,6 +22,7 @@ export interface TooltipProps {
title: string;
logo?: React.ReactNode;
};
delay?: number;
}
export const Tooltip: React.FC<TooltipProps> = ({
@ -37,12 +38,20 @@ export const Tooltip: React.FC<TooltipProps> = ({
arrow = false,
portalTarget,
header,
delay = 0,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const triggerRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = () => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
};
// Get sidebar context for tooltip positioning
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
@ -52,6 +61,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const open = isControlled ? controlledOpen : internalOpen;
const handleOpenChange = (newOpen: boolean) => {
clearTimers();
if (isControlled) {
onOpenChange?.(newOpen);
} else {
@ -62,6 +72,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
if (!newOpen) {
setIsPinned(false);
}
};
const handleTooltipClick = (e: React.MouseEvent) => {
@ -96,6 +107,13 @@ export const Tooltip: React.FC<TooltipProps> = ({
}
}, [isPinned]);
useEffect(() => {
return () => {
clearTimers();
};
}, []);
const getArrowClass = () => {
// No arrow for sidebar tooltips
if (sidebarTooltip) return null;
@ -175,27 +193,27 @@ export const Tooltip: React.FC<TooltipProps> = ({
) : null;
const handleMouseEnter = (e: React.MouseEvent) => {
// Clear any existing timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
}
// Only show on hover if not pinned
if (!isPinned) {
handleOpenChange(true);
const effectiveDelay = Math.max(0, delay || 0);
openTimeoutRef.current = setTimeout(() => {
handleOpenChange(true);
}, effectiveDelay);
}
(children.props as any)?.onMouseEnter?.(e);
};
const handleMouseLeave = (e: React.MouseEvent) => {
// Only hide on mouse leave if not pinned
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
if (!isPinned) {
// Add a small delay to prevent flickering
hoverTimeoutRef.current = setTimeout(() => {
handleOpenChange(false);
}, 100);
handleOpenChange(false);
}
(children.props as any)?.onMouseLeave?.(e);
@ -206,6 +224,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
if (open) {
setIsPinned(!isPinned);
} else {
clearTimers();
handleOpenChange(true);
setIsPinned(true);
}

View File

@ -14,6 +14,7 @@ A flexible, accessible tooltip component that supports both regular positioning
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior
- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay`
## Behavior
@ -61,6 +62,7 @@ function MyComponent() {
| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element |
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into |
| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo |
| `delay` | `number` | `0` | Optional hover-open delay (ms). If omitted or 0, opens immediately |
### TooltipTip Interface
@ -106,6 +108,17 @@ interface TooltipTip {
</Tooltip>
```
### Optional Hover Delay
```tsx
// Show after a 1s hover
<Tooltip content="Appears after a long hover" delay={1000} />
// Custom long-hover duration (2 seconds)
<Tooltip content="Appears after 2s" delay={2000} />
```
### Custom JSX Content
```tsx
@ -214,6 +227,12 @@ Links automatically get proper styling with hover states and open in new tabs wh
- Calculates optimal position based on trigger element's `getBoundingClientRect()`
- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped
## Timing Details
- Opening uses `delay` (ms) if provided; otherwise opens immediately. Closing occurs immediately when the cursor leaves (unless pinned).
- All internal timers are cleared on state changes, mouse transitions, and unmount to avoid overlaps.
- Only one tooltip can be open at a time; hovering a new trigger closes others immediately.
### Sidebar Tooltips
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
- Vertical positioning follows the trigger but clamps to viewport

View File

@ -0,0 +1,69 @@
import React, { useMemo } from 'react';
import { Box, Stack, Text } from '@mantine/core';
import { type ToolRegistryEntry } from '../../data/toolRegistry';
import ToolButton from './toolPicker/ToolButton';
interface SearchResultsProps {
filteredTools: [string, ToolRegistryEntry][];
onSelect: (id: string) => void;
}
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }) => {
const groups = useMemo(() => {
const subMap: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const seen = new Set<string>();
filteredTools.forEach(([id, tool]) => {
if (seen.has(id)) return;
seen.add(id);
const sub = tool?.subcategory || 'General';
if (!subMap[sub]) subMap[sub] = [];
subMap[sub].push({ id, tool });
});
return Object.entries(subMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([subcategory, tools]) => ({
subcategory,
tools
}));
}, [filteredTools]);
if (groups.length === 0) {
return (
<Text c="dimmed" size="sm" p="sm">
No tools found
</Text>
);
}
return (
<Stack p="sm" gap="xs">
{groups.map(group => (
<Box key={group.subcategory} w="100%">
<Text size="sm" fw={500} mb="0.25rem" mt="1rem" className="tool-subcategory-title">
{group.subcategory}
</Text>
<Stack gap="xs">
{group.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
/>
))}
</Stack>
{/* bottom spacer within each group not strictly required, outer list can add a spacer if needed */}
</Box>
))}
{/* global spacer to allow scrolling past last row in search mode */}
<div aria-hidden style={{ height: 44 * 4 }} />
</Stack>
);
};
export default SearchResults;

View File

@ -1,9 +1,10 @@
import React from 'react';
import { TextInput } from '@mantine/core';
import { TextInput, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolPanelState, useToolSelection, useWorkbenchState } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker';
import SearchResults from './SearchResults';
import ToolRenderer from './ToolRenderer';
import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
@ -13,6 +14,7 @@ import rainbowStyles from '../../styles/rainbow.module.css';
export default function ToolPanel() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { colorScheme } = useMantineColorScheme();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs;
@ -37,10 +39,7 @@ export default function ToolPanel() {
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ''
}`}
style={{
width: isPanelVisible ? '20rem' : '0',
padding: isPanelVisible ? '0.5rem' : '0'
}}
style={{width: isPanelVisible ? '20rem' : '0'}}
>
<div
style={{
@ -52,23 +51,57 @@ export default function ToolPanel() {
}}
>
{/* Search Bar - Always visible at the top */}
<div className="mb-4">
<div
style={{
backgroundColor: colorScheme === 'dark' ? '#1F2329' : '#EFF1F4',
padding: '0.75rem 1rem',
marginBottom: (leftPanelView === 'toolContent') ? '1rem' : 0,
}}
>
<TextInput
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
autoComplete="off"
size="sm"
styles={{
root: {
marginTop: '0.5rem',
marginBottom: '0.5rem',
},
input: {
backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF',
color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382',
border: 'none',
boxShadow: 'none',
borderBottom: leftPanelView === 'toolContent' ? `1px solid ${colorScheme === 'dark' ? '#3A4047' : '#E0E0E0'}` : 'none',
},
section: {
color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382',
}
}}
leftSection={<span className="material-symbols-rounded" style={{ fontSize: 16, color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}>search</span>}
/>
</div>
{leftPanelView === 'toolPicker' ? (
{searchQuery.trim().length > 0 ? (
// Searching view (replaces both picker and content)
<div className="flex-1 flex flex-col">
<div className="flex-1 min-h-0">
<SearchResults
filteredTools={filteredTools}
onSelect={handleToolSelect}
/>
</div>
</div>
) : leftPanelView === 'toolPicker' ? (
// Tool Picker View
<div className="flex-1 flex flex-col">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={handleToolSelect}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
/>
</div>
) : (

View File

@ -1,7 +1,7 @@
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
import { Box, Text, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { baseToolRegistry, type ToolRegistryEntry } from "../../data/toolRegistry";
import { type ToolRegistryEntry, SUBCATEGORY_ORDER } from "../../data/toolRegistry";
import ToolButton from "./toolPicker/ToolButton";
import "./toolPicker/ToolPicker.css";
@ -9,6 +9,7 @@ interface ToolPickerProps {
selectedToolKey: string | null;
onSelect: (id: string) => void;
filteredTools: [string, ToolRegistryEntry][];
isSearching?: boolean;
}
interface GroupedTools {
@ -17,7 +18,7 @@ interface GroupedTools {
};
}
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProps) => {
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
const { t } = useTranslation();
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
@ -45,9 +46,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp
const groupedTools = useMemo(() => {
const grouped: GroupedTools = {};
filteredTools.forEach(([id, tool]) => {
const baseTool = baseToolRegistry[id as keyof typeof baseToolRegistry];
const category = baseTool?.category || "OTHER";
const subcategory = baseTool?.subcategory || "General";
const category = tool?.category || "OTHER";
const subcategory = tool?.subcategory || "General";
if (!grouped[category]) grouped[category] = {};
if (!grouped[category][subcategory]) grouped[category][subcategory] = [];
grouped[category][subcategory].push({ id, tool });
@ -56,34 +56,54 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp
}, [filteredTools]);
const sections = useMemo(() => {
const mapping: Record<string, "QUICK ACCESS" | "ALL TOOLS"> = {
"RECOMMENDED TOOLS": "QUICK ACCESS",
"STANDARD TOOLS": "ALL TOOLS",
"ADVANCED TOOLS": "ALL TOOLS"
const getOrderIndex = (name: string) => {
const idx = SUBCATEGORY_ORDER.indexOf(name);
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
};
// Build two buckets: Quick includes only Recommended; All includes all categories (including Recommended)
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;
const upperCat = origCat.toUpperCase();
// Always add to ALL
Object.entries(subs).forEach(([sub, tools]) => {
if (!target[sub]) target[sub] = [];
target[sub].push(...tools);
if (!all[sub]) all[sub] = [];
all[sub].push(...tools);
});
// Add Recommended to QUICK ACCESS
if (upperCat === 'RECOMMENDED TOOLS') {
Object.entries(subs).forEach(([sub, tools]) => {
if (!quick[sub]) quick[sub] = [];
quick[sub].push(...tools);
});
}
});
const sortSubs = (obj: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>>) =>
Object.entries(obj)
.sort(([a], [b]) => a.localeCompare(b))
.sort(([a], [b]) => {
const ai = getOrderIndex(a);
const bi = getOrderIndex(b);
if (ai !== bi) return ai - bi;
return a.localeCompare(b);
})
.map(([subcategory, tools]) => ({
subcategory,
tools: tools.sort((a, b) => a.tool.name.localeCompare(b.tool.name))
// preserve original insertion order coming from filteredTools
tools
}));
return [
// Build sections and filter out any with no tools (avoids empty headers during search)
const built = [
{ title: "QUICK ACCESS", ref: quickAccessRef, subcategories: sortSubs(quick) },
{ title: "ALL TOOLS", ref: allToolsRef, subcategories: sortSubs(all) }
];
return built.filter(section => section.subcategories.some(sc => sc.tools.length > 0));
}, [groupedTools]);
const visibleSections = sections;
@ -112,6 +132,27 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp
}
};
// Build flat list by subcategory for search mode
const searchGroups = useMemo(() => {
if (!isSearching) return [] as Array<{ subcategory: string; tools: Array<{ id: string; tool: ToolRegistryEntry }> }>;
const subMap: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const seen = new Set<string>();
filteredTools.forEach(([id, tool]) => {
if (seen.has(id)) return;
seen.add(id);
const sub = tool?.subcategory || 'General';
if (!subMap[sub]) subMap[sub] = [];
subMap[sub].push({ id, tool });
});
return Object.entries(subMap)
.sort(([a],[b]) => a.localeCompare(b))
.map(([subcategory, tools]) => ({
subcategory,
// preserve insertion order
tools
}));
}, [isSearching, filteredTools]);
return (
<Box
h="100vh"
@ -132,6 +173,35 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp
}}
className="tool-picker-scrollable"
>
{isSearching ? (
<Stack p="sm" gap="xs">
{searchGroups.length === 0 ? (
<Text c="dimmed" size="sm" p="sm">
{t("toolPicker.noToolsFound", "No tools found")}
</Text>
) : (
searchGroups.map(group => (
<Box key={group.subcategory} w="100%">
<Text size="sm" fw={500} mb="0.25rem" mt="1rem" className="tool-subcategory-title">
{group.subcategory}
</Text>
<Stack gap="xs">
{group.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
))
)}
</Stack>
) : (
<>
{quickSection && (
<>
<div
@ -165,15 +235,15 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp
fontWeight: 700
}}
>
{quickSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
{quickSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</span>
</div>
<Box ref={quickAccessRef} w="100%">
<Stack p="sm" gap="xs">
{quickSection.subcategories.map(sc => (
{quickSection?.subcategories.map(sc => (
<Box key={sc.subcategory} w="100%">
{quickSection.subcategories.length > 1 && (
{quickSection?.subcategories.length > 1 && (
<Text
size="sm"
fw={500}
@ -234,15 +304,15 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp
fontWeight: 700
}}
>
{allSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
{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 => (
{allSection?.subcategories.map(sc => (
<Box key={sc.subcategory} w="100%">
{allSection.subcategories.length > 1 && (
{allSection?.subcategories.length > 1 && (
<Text
size="sm"
fw={500}
@ -276,6 +346,11 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools }: ToolPickerProp
{t("toolPicker.noToolsFound", "No tools found")}
</Text>
)}
{/* bottom spacer to allow scrolling past the last row */}
<div aria-hidden style={{ height: 200 }} />
</>
)}
</Box>
</Box>
);

View File

@ -1,5 +1,6 @@
import React from "react";
import { Button, Tooltip } from "@mantine/core";
import { Button } from "@mantine/core";
import { Tooltip } from "../../shared/Tooltip";
import { type ToolRegistryEntry } from "../../../data/toolRegistry";
interface ToolButtonProps {
@ -11,7 +12,7 @@ interface ToolButtonProps {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
return (
<Tooltip key={id} label={tool.description} withArrow openDelay={500}>
<Tooltip content={tool.description} position="right" arrow={true} delay={500}>
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={() => onSelect(id)}

View File

@ -5,7 +5,7 @@
import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
import { useToolManagement } from '../hooks/useToolManagement';
import { ToolConfiguration } from '../types/tool';
import { type ToolRegistryEntry } from '../data/toolRegistry';
import { PageEditorFunctions } from '../types/pageEditor';
// State interface
@ -69,8 +69,8 @@ function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowActio
interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: string | null;
selectedTool: ToolConfiguration | null;
toolRegistry: any; // From useToolManagement
selectedTool: ToolRegistryEntry | null;
toolRegistry: Record<string, ToolRegistryEntry>; // From useToolManagement
// UI Actions
setSidebarsVisible: (visible: boolean) => void;
@ -90,7 +90,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
handleReaderToggle: () => void;
// Computed values
filteredTools: [string, any][]; // Filtered by search
filteredTools: [string, ToolRegistryEntry][]; // Filtered by search
isPanelVisible: boolean;
}
@ -146,7 +146,9 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
onViewChange?.('fileEditor');
setLeftPanelView('toolContent');
setReaderMode(false);
}, [selectTool, onViewChange, setLeftPanelView, setReaderMode]);
// Clear search so the tool content becomes visible immediately
setSearchQuery('');
}, [selectTool, onViewChange, setLeftPanelView, setReaderMode, setSearchQuery]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');

View File

@ -19,7 +19,32 @@ export type ToolRegistry = {
[key: string]: ToolRegistryEntry;
};
export const baseToolRegistry: ToolRegistry = {
/**
* Shape overview:
* - flatToolRegistryMap: { [toolId]: ToolRegistryEntry }
* - buildStructuredRegistry(): {
* QUICK_ACCESS: Array<ToolRegistryEntry & { id: string }>,
* ALL_TOOLS: { [category]: { [subcategory]: Array<ToolRegistryEntry & { id: string }> } }
* }
* - baseToolRegistry: [ { QUICK_ACCESS }, { ALL_TOOLS } ]
* Quick reference helpers are provided below for convenience.
*/
// Ordered list used elsewhere for display ordering
export const SUBCATEGORY_ORDER: string[] = [
'Signing',
'Document Security',
'Verification',
'Document Review',
'Page Formatting',
'Extraction',
'Removal',
'Automation',
'General', // for now, this is the same as quick access, until we add suggested tools & favorites & whatnot
'Advanced Formatting',
'Developer Tools',
];
export const flatToolRegistryMap: ToolRegistry = {
"add-attachments": {
icon: <span className="material-symbols-rounded">attachment</span>,
name: "home.attachments.title",
@ -434,6 +459,43 @@ export const baseToolRegistry: ToolRegistry = {
category: "Advanced Tools",
subcategory: "Developer Tools"
},
// External Developer Resources (open in new tab)
"dev-api": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
name: "API",
component: null,
view: "external",
description: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html",
category: "Advanced Tools",
subcategory: "Developer Tools"
},
"dev-folder-scanning": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
name: "Automated Folder Scanning",
component: null,
view: "external",
description: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/",
category: "Advanced Tools",
subcategory: "Developer Tools"
},
"dev-sso-guide": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
name: "SSO Guide",
component: null,
view: "external",
description: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
category: "Advanced Tools",
subcategory: "Developer Tools"
},
"dev-airgapped": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
name: "Air-gapped Setup",
component: null,
view: "external",
description: "https://docs.stirlingpdf.com/Pro/#activation",
category: "Advanced Tools",
subcategory: "Developer Tools"
},
"sign": {
icon: <span className="material-symbols-rounded">signature</span>,
name: "home.sign.title",
@ -517,8 +579,66 @@ export const baseToolRegistry: ToolRegistry = {
}
};
export const toolEndpoints: Record<string,
string[]> = {
// Build structured registry that preserves order for sections
export type ToolConfig = ToolRegistryEntry & { id: string };
export type ToolRegistryStructured = {
QUICK_ACCESS: ToolConfig[];
ALL_TOOLS: Record<string, Record<string, ToolConfig[]>>;
};
function buildStructuredRegistry(): ToolRegistryStructured {
const entries: Array<[string, ToolRegistryEntry]> = Object.entries(flatToolRegistryMap);
const quick: ToolConfig[] = [];
const all: Record<string, Record<string, ToolConfig[]>> = {};
for (const [id, tool] of entries) {
const sub = tool.subcategory ?? 'General';
const cat = tool.category ?? 'OTHER';
// Quick access: use the existing "Recommended Tools" category
if (tool.category === 'Recommended Tools') {
quick.push({ id, ...tool });
}
if (!all[cat]) all[cat] = {};
if (!all[cat][sub]) all[cat][sub] = [];
all[cat][sub].push({ id, ...tool });
}
// Preserve subcategory ordering within each category
for (const cat of Object.keys(all)) {
const subcats = all[cat];
const ordered: Record<string, ToolConfig[]> = {};
SUBCATEGORY_ORDER.forEach(orderName => {
if (subcats[orderName]) ordered[orderName] = subcats[orderName];
});
// Append any remaining subcategories not in the predefined order
Object.keys(subcats)
.filter(name => !(name in ordered))
.sort((a, b) => a.localeCompare(b))
.forEach(name => (ordered[name] = subcats[name]));
all[cat] = ordered;
}
return { QUICK_ACCESS: quick, ALL_TOOLS: all };
}
export const baseToolRegistry: [
{ QUICK_ACCESS: ToolConfig[] },
{ ALL_TOOLS: Record<string, Record<string, ToolConfig[]>> }
] = [
{ QUICK_ACCESS: buildStructuredRegistry().QUICK_ACCESS },
{ ALL_TOOLS: buildStructuredRegistry().ALL_TOOLS }
];
// Convenience accessors for the structured shape
export const getQuickAccessTools = (): ToolConfig[] => baseToolRegistry[0].QUICK_ACCESS;
export const getAllToolsStructured = (): Record<string, Record<string, ToolConfig[]>> => baseToolRegistry[1].ALL_TOOLS;
// Compatibility: provide a flat registry for existing hooks/components
export function getFlatToolRegistry(): ToolRegistry {
return flatToolRegistryMap;
}
export const toolEndpoints: Record<string, string[]> = {
split: ["split-pages",
"split-pdf-by-sections",
"split-by-size-or-count",

View File

@ -1,6 +1,6 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { baseToolRegistry, toolEndpoints, type ToolRegistryEntry } from "../data/toolRegistry";
import { getFlatToolRegistry, toolEndpoints, type ToolRegistryEntry } from "../data/toolRegistry";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
interface ToolManagementResult {
@ -32,9 +32,10 @@ export const useToolManagement = (): ToolManagementResult => {
const toolRegistry: Record<string, ToolRegistryEntry> = useMemo(() => {
const availableToolRegistry: Record<string, ToolRegistryEntry> = {};
Object.keys(baseToolRegistry).forEach(toolKey => {
const base = getFlatToolRegistry();
Object.keys(base).forEach(toolKey => {
if (isToolAvailable(toolKey)) {
const baseTool = baseToolRegistry[toolKey as keyof typeof baseToolRegistry];
const baseTool = base[toolKey as keyof typeof base];
availableToolRegistry[toolKey] = {
...baseTool,
name: t(baseTool.name),