diff --git a/frontend/src/components/shared/AllToolsNavButton.tsx b/frontend/src/components/shared/AllToolsNavButton.tsx index c1b5774d2..62fad704e 100644 --- a/frontend/src/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/components/shared/AllToolsNavButton.tsx @@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next'; import { Tooltip } from './Tooltip'; import AppsIcon from '@mui/icons-material/AppsRounded'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import { useSidebarNavigation } from '../../hooks/useSidebarNavigation'; +import { handleUnlessSpecialClick } from '../../utils/clickHandlers'; interface AllToolsNavButtonProps { activeButton: string; @@ -13,6 +15,7 @@ interface AllToolsNavButtonProps { const AllToolsNavButton: React.FC = ({ activeButton, setActiveButton }) => { const { t } = useTranslation(); const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); + const { getHomeNavigation } = useSidebarNavigation(); const handleClick = () => { setActiveButton('tools'); @@ -24,6 +27,12 @@ const AllToolsNavButton: React.FC = ({ activeButton, set // Do not highlight All Tools when a specific tool is open (indicator is shown) const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker'; + const navProps = getHomeNavigation(); + + const handleNavClick = (e: React.MouseEvent) => { + handleUnlessSpecialClick(e, handleClick); + }; + const iconNode = ( @@ -31,18 +40,21 @@ const AllToolsNavButton: React.FC = ({ activeButton, set ); return ( -
diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index e65843fa4..644712d7c 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -7,6 +7,8 @@ import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import { useSidebarNavigation } from '../../hooks/useSidebarNavigation'; +import { handleUnlessSpecialClick } from '../../utils/clickHandlers'; import { ButtonConfig } from '../../types/sidebar'; import './quickAccessBar/QuickAccessBar.css'; import AllToolsNavButton from './AllToolsNavButton'; @@ -23,6 +25,7 @@ const QuickAccessBar = forwardRef(({ const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); + const { getToolNavigation } = useSidebarNavigation(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); @@ -37,6 +40,52 @@ const QuickAccessBar = forwardRef(({ openFilesModal(); }; + // Helper function to render navigation buttons with URL support + const renderNavButton = (config: ButtonConfig, index: number) => { + const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); + + // Check if this button has URL navigation support + const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate') + ? getToolNavigation(config.id) + : null; + + const handleClick = (e?: React.MouseEvent) => { + if (navProps && e) { + handleUnlessSpecialClick(e, config.onClick); + } else { + config.onClick(); + } + }; + + // Render navigation button with conditional URL support + return ( +
+ handleClick(e), + 'aria-label': config.name + } : { + onClick: () => handleClick() + })} + size={isActive ? (config.size || 'lg') : 'lg'} + variant="subtle" + style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} + className={isActive ? 'activeIconScale' : ''} + data-testid={`${config.id}-button`} + > + + {config.icon} + + + + {config.name} + +
+ ); + }; + const buttonConfigs: ButtonConfig[] = [ { @@ -153,27 +202,7 @@ const QuickAccessBar = forwardRef(({ {buttonConfigs.slice(0, -1).map((config, index) => ( - -
- { - config.onClick(); - }} - style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} - className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''} - data-testid={`${config.id}-button`} - > - - {config.icon} - - - - {config.name} - -
- + {renderNavButton(config, index)} {/* Add divider after Automate button (index 1) and Files button (index 2) */} {index === 1 && ( diff --git a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx index 8362c8224..60c915593 100644 --- a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx +++ b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx @@ -16,6 +16,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { ActionIcon } from '@mantine/core'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext'; +import { useSidebarNavigation } from '../../../hooks/useSidebarNavigation'; +import { handleUnlessSpecialClick } from '../../../utils/clickHandlers'; import FitText from '../FitText'; import { Tooltip } from '../Tooltip'; @@ -28,6 +30,7 @@ const NAV_IDS = ['read', 'sign', 'automate']; const ActiveToolButton: React.FC = ({ activeButton, setActiveButton }) => { const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow(); + const { getHomeNavigation } = useSidebarNavigation(); // Determine if the indicator should be visible (do not require selectedTool to be resolved yet) const indicatorShouldShow = Boolean( @@ -142,21 +145,26 @@ const ActiveToolButton: React.FC = ({ activeButton, setAc
{ + handleUnlessSpecialClick(e, () => { + setActiveButton('tools'); + handleBackToTools(); + }); + }} size={'xl'} variant="subtle" onMouseEnter={() => setIsBackHover(true)} onMouseLeave={() => setIsBackHover(false)} - onClick={() => { - setActiveButton('tools'); - handleBackToTools(); - }} aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name} style={{ backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)', color: isBackHover ? '#fff' : 'var(--icon-tools-color)', border: 'none', borderRadius: '8px', - cursor: 'pointer' + cursor: 'pointer', + textDecoration: 'none' }} > diff --git a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx index fca3b5e56..7a67fceca 100644 --- a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx +++ b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Stack, Text, Divider, Card, Group } from '@mantine/core'; +import { Stack, Text, Divider, Card, Group, Anchor } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useSuggestedTools } from '../../../hooks/useSuggestedTools'; @@ -21,20 +21,25 @@ export function SuggestedToolsSection(): React.ReactElement { {suggestedTools.map((tool) => { const IconComponent = tool.icon; return ( - - - - - {tool.title} - - - + + + + + {tool.title} + + + + ); })} diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index bd54f7e13..ee9c6062c 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -2,6 +2,8 @@ import React from "react"; import { Button } from "@mantine/core"; import { Tooltip } from "../../shared/Tooltip"; import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; +import { useToolNavigation } from "../../../hooks/useToolNavigation"; +import { handleUnlessSpecialClick } from "../../../utils/clickHandlers"; import FitText from "../../shared/FitText"; interface ToolButtonProps { @@ -14,6 +16,8 @@ interface ToolButtonProps { const ToolButton: React.FC = ({ id, tool, isSelected, onSelect }) => { const isUnavailable = !tool.component && !tool.link; + const { getToolNavigation } = useToolNavigation(); + const handleClick = (id: string) => { if (isUnavailable) return; if (tool.link) { @@ -25,32 +29,84 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect onSelect(id); }; + // Get navigation props for URL support + const navProps = !isUnavailable && !tool.link ? getToolNavigation(id, tool) : null; + const tooltipContent = isUnavailable ? (Coming soon: {tool.description}) : tool.description; + const buttonContent = ( + <> +
{tool.icon}
+ + + ); + + const handleExternalClick = (e: React.MouseEvent) => { + handleUnlessSpecialClick(e, () => handleClick(id)); + }; + + const buttonElement = navProps ? ( + // For internal tools with URLs, render Button as an anchor for proper link behavior + + ) : tool.link && !isUnavailable ? ( + // For external links, render Button as an anchor with proper href + + ) : ( + // For unavailable tools, use regular button + + ); + return ( - + {buttonElement} ); }; diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 68883fe92..e19d121dc 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -355,6 +355,7 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, maxFiles: -1, + urlPath: '/pdf-to-single-page', endpoints: ["pdf-to-single-page"], operationConfig: singleLargePageOperationConfig, }, @@ -681,6 +682,7 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, maxFiles: -1, + urlPath: '/ocr-pdf', operationConfig: ocrOperationConfig, settingsComponent: OCRSettings, }, diff --git a/frontend/src/hooks/useSidebarNavigation.ts b/frontend/src/hooks/useSidebarNavigation.ts new file mode 100644 index 000000000..fb60e2502 --- /dev/null +++ b/frontend/src/hooks/useSidebarNavigation.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; +import { useToolNavigation } from './useToolNavigation'; +import { useToolManagement } from './useToolManagement'; +import { handleUnlessSpecialClick } from '../utils/clickHandlers'; + +export interface SidebarNavigationProps { + /** Full URL for the navigation (for href attribute) */ + href: string; + /** Click handler that maintains SPA behavior */ + onClick: (e: React.MouseEvent) => void; +} + +/** + * Hook that provides URL and navigation handlers for sidebar navigation buttons + * Supports special routes like home ('/') and specific tool routes + */ +export function useSidebarNavigation(): { + getHomeNavigation: () => SidebarNavigationProps; + getToolNavigation: (toolId: string) => SidebarNavigationProps | null; +} { + const { getToolNavigation: getToolNavProps } = useToolNavigation(); + const { getSelectedTool } = useToolManagement(); + + const defaultNavClick = useCallback((e: React.MouseEvent) => { + handleUnlessSpecialClick(e, () => { + // SPA navigation will be handled by the calling component + }); + }, []); + + const getHomeNavigation = useCallback((): SidebarNavigationProps => { + const href = '/'; // SSR-safe relative path + return { href, onClick: defaultNavClick }; + }, [defaultNavClick]); + + const getToolNavigation = useCallback((toolId: string): SidebarNavigationProps | null => { + // Handle special nav sections that aren't tools + if (toolId === 'read') return { href: '/read', onClick: defaultNavClick }; + if (toolId === 'automate') return { href: '/automate', onClick: defaultNavClick }; + + const tool = getSelectedTool(toolId); + if (!tool) return null; + + // Delegate to useToolNavigation for true tools + return getToolNavProps(toolId, tool); + }, [getToolNavProps, getSelectedTool, defaultNavClick]); + + return { + getHomeNavigation, + getToolNavigation + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useSuggestedTools.ts b/frontend/src/hooks/useSuggestedTools.ts index 8478bbc6b..377cf1245 100644 --- a/frontend/src/hooks/useSuggestedTools.ts +++ b/frontend/src/hooks/useSuggestedTools.ts @@ -1,5 +1,7 @@ import { useMemo } from 'react'; -import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext'; +import { useNavigationState } from '../contexts/NavigationContext'; +import { useToolNavigation } from './useToolNavigation'; +import { useToolManagement } from './useToolManagement'; import { ToolId } from '../types/toolId'; // Material UI Icons @@ -13,10 +15,11 @@ export interface SuggestedTool { id: ToolId; title: string; icon: React.ComponentType; - navigate: () => void; + href: string; + onClick: (e: React.MouseEvent) => void; } -const ALL_SUGGESTED_TOOLS: Omit[] = [ +const ALL_SUGGESTED_TOOLS: Omit[] = [ { id: 'compress', title: 'Compress', @@ -45,17 +48,31 @@ const ALL_SUGGESTED_TOOLS: Omit[] = [ ]; export function useSuggestedTools(): SuggestedTool[] { - const { actions } = useNavigationActions(); const { selectedTool } = useNavigationState(); + const { getToolNavigation } = useToolNavigation(); + const { getSelectedTool } = useToolManagement(); return useMemo(() => { // Filter out the current tool const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedTool); - // Add navigation function to each tool - return filteredTools.map(tool => ({ - ...tool, - navigate: () => actions.setSelectedTool(tool.id) - })); - }, [selectedTool, actions]); + // Add navigation props to each tool + return filteredTools.map(tool => { + const toolRegistryEntry = getSelectedTool(tool.id); + if (!toolRegistryEntry) { + // Fallback for tools not in registry + return { + ...tool, + href: `/${tool.id}`, + onClick: (e: React.MouseEvent) => { e.preventDefault(); } + }; + } + + const navProps = getToolNavigation(tool.id, toolRegistryEntry); + return { + ...tool, + ...navProps + }; + }); + }, [selectedTool, getToolNavigation, getSelectedTool]); } diff --git a/frontend/src/hooks/useToolNavigation.ts b/frontend/src/hooks/useToolNavigation.ts new file mode 100644 index 000000000..021ea6432 --- /dev/null +++ b/frontend/src/hooks/useToolNavigation.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; +import { ToolId } from '../types/toolId'; +import { ToolRegistryEntry, getToolUrlPath } from '../data/toolsTaxonomy'; +import { useToolWorkflow } from '../contexts/ToolWorkflowContext'; +import { handleUnlessSpecialClick } from '../utils/clickHandlers'; + +export interface ToolNavigationProps { + /** Full URL for the tool (for href attribute) */ + href: string; + /** Click handler that maintains SPA behavior */ + onClick: (e: React.MouseEvent) => void; +} + +/** + * Hook that provides URL and navigation handlers for tools + * Enables right-click "Open in New Tab" while maintaining SPA behavior for regular clicks + */ +export function useToolNavigation(): { + getToolNavigation: (toolId: string, tool: ToolRegistryEntry) => ToolNavigationProps; +} { + const { handleToolSelect } = useToolWorkflow(); + + const getToolNavigation = useCallback((toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => { + // Generate SSR-safe relative path + const path = getToolUrlPath(toolId, tool); + const href = path; // Relative path, no window.location needed + + // Click handler that maintains SPA behavior + const onClick = (e: React.MouseEvent) => { + handleUnlessSpecialClick(e, () => { + // Handle external links normally + if (tool.link) { + window.open(tool.link, '_blank', 'noopener,noreferrer'); + return; + } + + // Use SPA navigation for internal tools + handleToolSelect(toolId); + }); + }; + + return { href, onClick }; + }, [handleToolSelect]); + + return { getToolNavigation }; +} \ No newline at end of file diff --git a/frontend/src/utils/clickHandlers.ts b/frontend/src/utils/clickHandlers.ts new file mode 100644 index 000000000..0c70cfca8 --- /dev/null +++ b/frontend/src/utils/clickHandlers.ts @@ -0,0 +1,31 @@ +/** + * Utility functions for handling click events in navigation components + */ + +/** + * Determines if a click event is a "special" click that should use browser's default navigation + * instead of SPA navigation. Special clicks include: + * - Ctrl+click (or Cmd+click on Mac) + * - Shift+click + * - Middle mouse button click + */ +export function isSpecialClick(e: React.MouseEvent): boolean { + return e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1; +} + +/** + * Handles a click event for SPA navigation, but allows special clicks to use browser defaults + * + * @param e - The click event + * @param handleClick - Function to execute for regular clicks (SPA navigation) + * @returns true if the event was handled as a special click, false if it was handled as regular click + */ +export function handleUnlessSpecialClick(e: React.MouseEvent, handleClick: () => void): boolean { + if (isSpecialClick(e)) { + return true; // Let browser handle via href + } + + e.preventDefault(); + handleClick(); + return false; +} \ No newline at end of file