From a53cce80b6f0ed7e4daf9d17ef2105a3cb3b5e0b Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Mon, 21 Jul 2025 16:48:24 +0100 Subject: [PATCH 01/17] V2 Navbar Styling --- frontend/index.html | 1 + .../src/components/shared/QuickAccessBar.css | 13 ++ .../src/components/shared/QuickAccessBar.tsx | 220 ++++++++++++++---- frontend/src/global.d.ts | 1 + frontend/src/pages/HomePage.tsx | 2 +- 5 files changed, 193 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/shared/QuickAccessBar.css diff --git a/frontend/index.html b/frontend/index.html index 0fc165c66..c8f825666 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ + void; @@ -16,6 +19,17 @@ interface QuickAccessBarProps { readerMode: boolean; } +interface ButtonConfig { + id: string; + name: string; + icon: React.ReactNode; + tooltip: string; + color: string; + isRound?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; + onClick: () => void; +} + const QuickAccessBar = ({ onToolsClick, onReaderToggle, @@ -26,54 +40,174 @@ const QuickAccessBar = ({ }: QuickAccessBarProps) => { const { isRainbowMode } = useRainbowThemeContext(); const [configModalOpen, setConfigModalOpen] = useState(false); + const [activeButton, setActiveButton] = useState('tools'); + + const buttonConfigs: ButtonConfig[] = [ + { + id: 'tools', + name: 'All Tools', + icon: , + tooltip: 'View all available tools', + color: '#1E88E5', + size: 'lg', + onClick: () => { + setActiveButton('tools'); + onReaderToggle(); + onToolsClick(); + } + }, + { + id: 'read', + name: 'Read', + icon: , + tooltip: 'Read documents', + color: '#4CAF50', + size: 'lg', + onClick: () => { + setActiveButton('read'); + onReaderToggle(); + } + }, + { + id: 'sign', + name: 'Sign', + icon: + + signature + , + tooltip: 'Sign your document', + color: '#3BA99C', + size: 'lg', + onClick: () => setActiveButton('sign') + }, + { + id: 'automate', + name: 'Automate', + icon: , + tooltip: 'Automate workflows', + color: '#A576E3', + size: 'lg', + onClick: () => setActiveButton('automate') + }, + { + id: 'files', + name: 'Files', + icon: , + tooltip: 'Manage files', + color: '', // the round icons are blue always, this logic lives in getButtonStyle + isRound: true, + size: 'lg', + onClick: () => setActiveButton('files') + }, + /* Access isn't going to be available yet */ + + /* + { + id: 'access', + name: 'Access', + icon: , + tooltip: 'Manage access and permissions', + color: '#00BCD4', + isRound: true, + size: 'lg', + onClick: () => setActiveButton('access') + }, + */ + { + id: 'activity', + name: 'Activity', + icon: + + vital_signs + , + tooltip: 'View activity and analytics', + color: '', + isRound: true, + size: 'lg', + onClick: () => setActiveButton('activity') + }, + { + id: 'config', + name: 'Config', + icon: , + tooltip: 'Configure settings', + color: '#9CA3AF', + size: 'lg', + onClick: () => { + setConfigModalOpen(true); + } + } + ]; + + const getButtonStyle = (config: ButtonConfig) => { + const isActive = activeButton === config.id; + if (config.isRound && isActive) { + return { + backgroundColor: '#D3E7F7', + color: '#0A8BFF', + borderRadius: '50%', + }; + } + return { + backgroundColor: isActive ? config.color : '#9CA3AF', + color: 'white', + border: 'none', + borderRadius: config.isRound ? '50%' : '8px', + }; + }; + + const getTextStyle = (config: ButtonConfig) => { + const isActive = activeButton === config.id; + return { + marginTop: '12px', + fontSize: '12px', + color: isActive ? 'var(--text-primary)' : 'var(--color-gray-700)', + fontWeight: isActive ? 'bold' : 'normal', + textRendering: 'optimizeLegibility' as const, + fontSynthesis: 'none' as const + }; + }; return (
- {/* All Tools Button */} -
- - - - Tools -
- - {/* Reader Mode Button */} -
- - - - Read -
- - {/* Spacer */} -
- - {/* Config Modal Button (for testing) */} -
- setConfigModalOpen(true)} - > - - - Config -
+ {buttonConfigs.map((config, index) => ( + + +
+ + + {config.icon} + + + + {config.name} + +
+
+ + {/* Add divider after Automate button (index 3) */} + {index === 3 && } + + {/* Add spacer before Config button (index 7) */} + {index === 5 &&
} + + ))} { - setReaderMode(!readerMode); + setReaderMode(true); }, [readerMode]); const handleViewChange = useCallback((view: string) => { From 7d09bf9e45ec995cdaaadb4aa2fa2c8dfa52783b Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Mon, 21 Jul 2025 22:10:23 +0100 Subject: [PATCH 02/17] forgot to push --- .../src/components/shared/QuickAccessBar.css | 14 ++ .../src/components/shared/QuickAccessBar.tsx | 132 ++++++++++++++++-- frontend/src/pages/HomePage.tsx | 10 +- frontend/src/styles/theme.css | 106 ++++++++++---- 4 files changed, 221 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/shared/QuickAccessBar.css b/frontend/src/components/shared/QuickAccessBar.css index d429f9721..16f7def5e 100644 --- a/frontend/src/components/shared/QuickAccessBar.css +++ b/frontend/src/components/shared/QuickAccessBar.css @@ -10,4 +10,18 @@ justify-content: center; width: 32px; height: 32px; +} + +.fallbackDivider { + width: 60px; + height: 1px; + background-color: var(--color-gray-300); + margin: 8px 0; + border-radius: 1px; +} + +.mantineDivider { + width: 60px; + margin: 8px 0; + border-color: var(--color-gray-300); } \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 1a4a3cab8..a12298b3c 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -5,6 +5,8 @@ import AppsIcon from "@mui/icons-material/AppsRounded"; import SettingsIcon from "@mui/icons-material/SettingsRounded"; import AutoAwesomeIcon from "@mui/icons-material/AutoAwesomeRounded"; import FolderIcon from "@mui/icons-material/FolderRounded"; +import PersonIcon from "@mui/icons-material/PersonRounded"; +import NotificationsIcon from "@mui/icons-material/NotificationsRounded"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; import rainbowStyles from '../../styles/rainbow.module.css'; import AppConfigModal from './AppConfigModal'; @@ -30,6 +32,54 @@ interface ButtonConfig { onClick: () => void; } +function NavHeader() { + return ( + <> +
+ + + + + + + + + + +
+ {/* Divider after top icons */} + + + ); +} + const QuickAccessBar = ({ onToolsClick, onReaderToggle, @@ -46,7 +96,7 @@ const QuickAccessBar = ({ { id: 'tools', name: 'All Tools', - icon: , + icon: , tooltip: 'View all available tools', color: '#1E88E5', size: 'lg', @@ -141,16 +191,69 @@ const QuickAccessBar = ({ const getButtonStyle = (config: ButtonConfig) => { const isActive = activeButton === config.id; - if (config.isRound && isActive) { - return { - backgroundColor: '#D3E7F7', - color: '#0A8BFF', - borderRadius: '50%', - }; + + if (isActive) { + // Active state - use specific icon colors + if (config.id === 'tools') { + return { + backgroundColor: 'var(--icon-tools-bg)', + color: 'var(--icon-tools-color)', + border: 'none', + borderRadius: config.isRound ? '50%' : '8px', + }; + } + if (config.id === 'read') { + return { + backgroundColor: 'var(--icon-read-bg)', + color: 'var(--icon-read-color)', + border: 'none', + borderRadius: config.isRound ? '50%' : '8px', + }; + } + if (config.id === 'sign') { + return { + backgroundColor: 'var(--icon-sign-bg)', + color: 'var(--icon-sign-color)', + border: 'none', + borderRadius: config.isRound ? '50%' : '8px', + }; + } + if (config.id === 'automate') { + return { + backgroundColor: 'var(--icon-automate-bg)', + color: 'var(--icon-automate-color)', + border: 'none', + borderRadius: config.isRound ? '50%' : '8px', + }; + } + if (config.id === 'files') { + return { + backgroundColor: 'var(--icon-files-bg)', + color: 'var(--icon-files-color)', + borderRadius: '50%', + }; + } + if (config.id === 'activity') { + return { + backgroundColor: 'var(--icon-activity-bg)', + color: 'var(--icon-activity-color)', + borderRadius: '50%', + }; + } + if (config.id === 'config') { + return { + backgroundColor: 'var(--icon-config-bg)', + color: 'var(--icon-config-color)', + border: 'none', + borderRadius: config.isRound ? '50%' : '8px', + }; + } } + + // Inactive state - use consistent inactive colors return { - backgroundColor: isActive ? config.color : '#9CA3AF', - color: 'white', + backgroundColor: 'var(--icon-inactive-bg)', + color: 'var(--icon-inactive-color)', border: 'none', borderRadius: config.isRound ? '50%' : '8px', }; @@ -180,6 +283,7 @@ const QuickAccessBar = ({ }} > + {buttonConfigs.map((config, index) => ( @@ -202,7 +306,15 @@ const QuickAccessBar = ({ {/* Add divider after Automate button (index 3) */} - {index === 3 && } + {index === 3 && ( + + )} {/* Add spacer before Config button (index 7) */} {index === 5 &&
} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 0503a7d6d..51cb0d8f6 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -97,7 +97,7 @@ export default function HomePage() { {/* Left: Tool Picker or Selected Tool Panel */}
{/* Top Controls */} Date: Tue, 22 Jul 2025 16:48:11 +0100 Subject: [PATCH 03/17] use rem styling, make navbar scrollable on overflow, extrapolate styling to be re-usable and remove redundant nav item colors, rely on tailwind theme instead --- frontend/index.html | 1 - frontend/package-lock.json | 7 ++ frontend/package.json | 1 + .../src/components/shared/QuickAccessBar.css | 42 +++++-- .../src/components/shared/QuickAccessBar.tsx | 106 ++++++++---------- frontend/src/index.css | 6 + 6 files changed, 94 insertions(+), 69 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index c8f825666..0fc165c66 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,6 @@ - void; } +const actionIconStyle = { + backgroundColor: 'var(--icon-user-bg)', + color: 'var(--icon-user-color)', + borderRadius: '50%', + width: '1.5rem', + height: '1.5rem', +}; + function NavHeader() { return ( <> -
+
- + - +
@@ -72,7 +66,7 @@ function NavHeader() { @@ -98,8 +92,8 @@ const QuickAccessBar = ({ name: 'All Tools', icon: , tooltip: 'View all available tools', - color: '#1E88E5', size: 'lg', + isRound: false, onClick: () => { setActiveButton('tools'); onReaderToggle(); @@ -111,8 +105,8 @@ const QuickAccessBar = ({ name: 'Read', icon: , tooltip: 'Read documents', - color: '#4CAF50', size: 'lg', + isRound: false, onClick: () => { setActiveButton('read'); onReaderToggle(); @@ -122,12 +116,12 @@ const QuickAccessBar = ({ id: 'sign', name: 'Sign', icon: - + signature , tooltip: 'Sign your document', - color: '#3BA99C', size: 'lg', + isRound: false, onClick: () => setActiveButton('sign') }, { @@ -135,8 +129,8 @@ const QuickAccessBar = ({ name: 'Automate', icon: , tooltip: 'Automate workflows', - color: '#A576E3', size: 'lg', + isRound: false, onClick: () => setActiveButton('automate') }, { @@ -144,34 +138,18 @@ const QuickAccessBar = ({ name: 'Files', icon: , tooltip: 'Manage files', - color: '', // the round icons are blue always, this logic lives in getButtonStyle isRound: true, size: 'lg', onClick: () => setActiveButton('files') }, - /* Access isn't going to be available yet */ - - /* - { - id: 'access', - name: 'Access', - icon: , - tooltip: 'Manage access and permissions', - color: '#00BCD4', - isRound: true, - size: 'lg', - onClick: () => setActiveButton('access') - }, - */ { id: 'activity', name: 'Activity', icon: - + vital_signs , tooltip: 'View activity and analytics', - color: '', isRound: true, size: 'lg', onClick: () => setActiveButton('activity') @@ -181,7 +159,6 @@ const QuickAccessBar = ({ name: 'Config', icon: , tooltip: 'Configure settings', - color: '#9CA3AF', size: 'lg', onClick: () => { setConfigModalOpen(true); @@ -189,6 +166,13 @@ const QuickAccessBar = ({ } ]; + const ROUND_BORDER_RADIUS = '50%'; + const NOT_ROUND_BORDER_RADIUS = '8px'; + + const getBorderRadius = (config: ButtonConfig): string => { + return config.isRound ? ROUND_BORDER_RADIUS : NOT_ROUND_BORDER_RADIUS; + }; + const getButtonStyle = (config: ButtonConfig) => { const isActive = activeButton === config.id; @@ -199,7 +183,7 @@ const QuickAccessBar = ({ backgroundColor: 'var(--icon-tools-bg)', color: 'var(--icon-tools-color)', border: 'none', - borderRadius: config.isRound ? '50%' : '8px', + borderRadius: getBorderRadius(config), }; } if (config.id === 'read') { @@ -207,7 +191,7 @@ const QuickAccessBar = ({ backgroundColor: 'var(--icon-read-bg)', color: 'var(--icon-read-color)', border: 'none', - borderRadius: config.isRound ? '50%' : '8px', + borderRadius: getBorderRadius(config), }; } if (config.id === 'sign') { @@ -215,7 +199,7 @@ const QuickAccessBar = ({ backgroundColor: 'var(--icon-sign-bg)', color: 'var(--icon-sign-color)', border: 'none', - borderRadius: config.isRound ? '50%' : '8px', + borderRadius: getBorderRadius(config), }; } if (config.id === 'automate') { @@ -223,21 +207,21 @@ const QuickAccessBar = ({ backgroundColor: 'var(--icon-automate-bg)', color: 'var(--icon-automate-color)', border: 'none', - borderRadius: config.isRound ? '50%' : '8px', + borderRadius: getBorderRadius(config), }; } if (config.id === 'files') { return { backgroundColor: 'var(--icon-files-bg)', color: 'var(--icon-files-color)', - borderRadius: '50%', + borderRadius: ROUND_BORDER_RADIUS, }; } if (config.id === 'activity') { return { backgroundColor: 'var(--icon-activity-bg)', color: 'var(--icon-activity-color)', - borderRadius: '50%', + borderRadius: ROUND_BORDER_RADIUS, }; } if (config.id === 'config') { @@ -245,7 +229,7 @@ const QuickAccessBar = ({ backgroundColor: 'var(--icon-config-bg)', color: 'var(--icon-config-color)', border: 'none', - borderRadius: config.isRound ? '50%' : '8px', + borderRadius: getBorderRadius(config), }; } } @@ -255,15 +239,15 @@ const QuickAccessBar = ({ backgroundColor: 'var(--icon-inactive-bg)', color: 'var(--icon-inactive-color)', border: 'none', - borderRadius: config.isRound ? '50%' : '8px', + borderRadius: getBorderRadius(config), }; }; const getTextStyle = (config: ButtonConfig) => { const isActive = activeButton === config.id; return { - marginTop: '12px', - fontSize: '12px', + marginTop: '0.75rem', + fontSize: '0.75rem', color: isActive ? 'var(--text-primary)' : 'var(--color-gray-700)', fontWeight: isActive ? 'bold' : 'normal', textRendering: 'optimizeLegibility' as const, @@ -273,13 +257,19 @@ const QuickAccessBar = ({ return (
{ + // Prevent the wheel event from bubbling up to parent containers + e.stopPropagation(); }} > @@ -310,7 +300,7 @@ const QuickAccessBar = ({ diff --git a/frontend/src/index.css b/frontend/src/index.css index ec2585e8c..f7e5e0865 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,9 @@ +@import 'material-symbols/rounded.css'; + +.material-symbols-rounded { + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; +} + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', From 141dc8f8bb4a9be2b996ec9c5097cbc19030649a Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Tue, 22 Jul 2025 16:52:37 +0100 Subject: [PATCH 04/17] bad variable name --- frontend/src/components/shared/QuickAccessBar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 8b52091cf..9e7908714 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -166,11 +166,11 @@ const QuickAccessBar = ({ } ]; - const ROUND_BORDER_RADIUS = '50%'; - const NOT_ROUND_BORDER_RADIUS = '8px'; + const CIRCULAR_BORDER_RADIUS = '50%'; + const ROUND_BORDER_RADIUS = '8px'; const getBorderRadius = (config: ButtonConfig): string => { - return config.isRound ? ROUND_BORDER_RADIUS : NOT_ROUND_BORDER_RADIUS; + return config.isRound ? CIRCULAR_BORDER_RADIUS : ROUND_BORDER_RADIUS; }; const getButtonStyle = (config: ButtonConfig) => { @@ -214,14 +214,14 @@ const QuickAccessBar = ({ return { backgroundColor: 'var(--icon-files-bg)', color: 'var(--icon-files-color)', - borderRadius: ROUND_BORDER_RADIUS, + borderRadius: CIRCULAR_BORDER_RADIUS, }; } if (config.id === 'activity') { return { backgroundColor: 'var(--icon-activity-bg)', color: 'var(--icon-activity-color)', - borderRadius: ROUND_BORDER_RADIUS, + borderRadius: CIRCULAR_BORDER_RADIUS, }; } if (config.id === 'config') { From 9f58dc69e84e20a9fd168e00f97182b9c3d529bb Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Tue, 22 Jul 2025 16:59:28 +0100 Subject: [PATCH 05/17] thin out some code --- .../src/components/shared/QuickAccessBar.tsx | 61 ++----------------- 1 file changed, 6 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 9e7908714..350e0f8e6 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -177,61 +177,12 @@ const QuickAccessBar = ({ const isActive = activeButton === config.id; if (isActive) { - // Active state - use specific icon colors - if (config.id === 'tools') { - return { - backgroundColor: 'var(--icon-tools-bg)', - color: 'var(--icon-tools-color)', - border: 'none', - borderRadius: getBorderRadius(config), - }; - } - if (config.id === 'read') { - return { - backgroundColor: 'var(--icon-read-bg)', - color: 'var(--icon-read-color)', - border: 'none', - borderRadius: getBorderRadius(config), - }; - } - if (config.id === 'sign') { - return { - backgroundColor: 'var(--icon-sign-bg)', - color: 'var(--icon-sign-color)', - border: 'none', - borderRadius: getBorderRadius(config), - }; - } - if (config.id === 'automate') { - return { - backgroundColor: 'var(--icon-automate-bg)', - color: 'var(--icon-automate-color)', - border: 'none', - borderRadius: getBorderRadius(config), - }; - } - if (config.id === 'files') { - return { - backgroundColor: 'var(--icon-files-bg)', - color: 'var(--icon-files-color)', - borderRadius: CIRCULAR_BORDER_RADIUS, - }; - } - if (config.id === 'activity') { - return { - backgroundColor: 'var(--icon-activity-bg)', - color: 'var(--icon-activity-color)', - borderRadius: CIRCULAR_BORDER_RADIUS, - }; - } - if (config.id === 'config') { - return { - backgroundColor: 'var(--icon-config-bg)', - color: 'var(--icon-config-color)', - border: 'none', - borderRadius: getBorderRadius(config), - }; - } + return { + backgroundColor: `var(--icon-${config.id}-bg)`, + color: `var(--icon-${config.id}-color)`, + border: 'none', + borderRadius: getBorderRadius(config), + }; } // Inactive state - use consistent inactive colors From 63e2791a8b83dd0e46f0beaa7acce29e06864372 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Thu, 24 Jul 2025 16:55:11 +0100 Subject: [PATCH 06/17] stop using inline styling --- .../src/components/shared/QuickAccessBar.css | 110 ++++++++- .../src/components/shared/QuickAccessBar.tsx | 231 +++++++++++------- frontend/src/hooks/useIsOverflow.ts | 35 +++ 3 files changed, 281 insertions(+), 95 deletions(-) create mode 100644 frontend/src/hooks/useIsOverflow.ts diff --git a/frontend/src/components/shared/QuickAccessBar.css b/frontend/src/components/shared/QuickAccessBar.css index 6f4a89aeb..b484132b8 100644 --- a/frontend/src/components/shared/QuickAccessBar.css +++ b/frontend/src/components/shared/QuickAccessBar.css @@ -12,12 +12,118 @@ height: 32px; } -/* Scrollable navbar styling - scrollbars only show when scrolling */ +/* Action icon styles */ +.action-icon-style { + background-color: var(--icon-user-bg); + color: var(--icon-user-color); + border-radius: 50%; + width: 1.5rem; + height: 1.5rem; +} + +/* Main container styles */ +.quick-access-bar-main { + background-color: var(--bg-muted); + width: 5rem; + min-width: 5rem; + max-width: 5rem; + position: relative; + z-index: 10; +} + +/* Header padding */ +.quick-access-header { + padding: 1rem 0.5rem 0.5rem 0.5rem; +} + +/* Nav header divider */ +.nav-header-divider { + width: 3.75rem; + border-color: var(--color-gray-300); + margin-top: 0.5rem; + margin-bottom: 1rem; +} + +/* All tools text styles */ +.all-tools-text { + margin-top: 0.75rem; + font-size: 0.75rem; + text-rendering: optimizeLegibility; + font-synthesis: none; +} + +.all-tools-text.active { + color: var(--text-primary); + font-weight: bold; +} + +.all-tools-text.inactive { + color: var(--color-gray-700); + font-weight: normal; +} + +/* Overflow divider */ +.overflow-divider { + width: 3.75rem; + border-color: var(--color-gray-300); + margin: 0 0.5rem; +} + +/* Scrollable content area */ .quick-access-bar { overflow-x: auto; - overflow-y: hidden; + overflow-y: auto; scrollbar-gutter: stable both-edges; -webkit-overflow-scrolling: touch; + padding: 0 0.5rem 1rem 0.5rem; +} + +/* Scrollable content container */ +.scrollable-content { + display: flex; + flex-direction: column; + height: 100%; + min-height: 100%; +} + +/* Button text styles */ +.button-text { + margin-top: 0.75rem; + font-size: 0.75rem; + text-rendering: optimizeLegibility; + font-synthesis: none; +} + +.button-text.active { + color: var(--text-primary); + font-weight: bold; +} + +.button-text.inactive { + color: var(--color-gray-700); + font-weight: normal; +} + +/* Content divider */ +.content-divider { + width: 3.75rem; + border-color: var(--color-gray-300); +} + +/* Spacer */ +.spacer { + flex: 1; + margin-top: 1rem; +} + +/* Config button text */ +.config-button-text { + margin-top: 0.75rem; + font-size: 0.75rem; + color: var(--color-gray-700); + font-weight: normal; + text-rendering: optimizeLegibility; + font-synthesis: none; } /* Hide scrollbar by default, show on scroll (Webkit browsers - Chrome, Safari, Edge) */ diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 350e0f8e6..196fa01bb 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core"; import MenuBookIcon from "@mui/icons-material/MenuBookRounded"; import AppsIcon from "@mui/icons-material/AppsRounded"; @@ -10,6 +10,7 @@ import NotificationsIcon from "@mui/icons-material/NotificationsRounded"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; import rainbowStyles from '../../styles/rainbow.module.css'; import AppConfigModal from './AppConfigModal'; +import { useIsOverflow } from '../../hooks/useIsOverflow'; import './QuickAccessBar.css'; interface QuickAccessBarProps { @@ -31,15 +32,17 @@ interface ButtonConfig { onClick: () => void; } -const actionIconStyle = { - backgroundColor: 'var(--icon-user-bg)', - color: 'var(--icon-user-color)', - borderRadius: '50%', - width: '1.5rem', - height: '1.5rem', -}; - -function NavHeader() { +function NavHeader({ + activeButton, + setActiveButton, + onReaderToggle, + onToolsClick +}: { + activeButton: string; + setActiveButton: (id: string) => void; + onReaderToggle: () => void; + onToolsClick: () => void; +}) { return ( <>
@@ -47,7 +50,7 @@ function NavHeader() { @@ -56,7 +59,7 @@ function NavHeader() { @@ -65,11 +68,36 @@ function NavHeader() { {/* Divider after top icons */} + {/* All Tools button below divider */} + +
+ { + setActiveButton('tools'); + onReaderToggle(); + onToolsClick(); + }} + style={{ + backgroundColor: activeButton === 'tools' ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)', + color: activeButton === 'tools' ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)', + border: 'none', + borderRadius: '8px', + }} + className={activeButton === 'tools' ? 'activeIconScale' : ''} + > + + + + + + All Tools + +
+
); } @@ -85,21 +113,10 @@ const QuickAccessBar = ({ const { isRainbowMode } = useRainbowThemeContext(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); + const scrollableRef = useRef(null); + const isOverflow = useIsOverflow(scrollableRef); const buttonConfigs: ButtonConfig[] = [ - { - id: 'tools', - name: 'All Tools', - icon: , - tooltip: 'View all available tools', - size: 'lg', - isRound: false, - onClick: () => { - setActiveButton('tools'); - onReaderToggle(); - onToolsClick(); - } - }, { id: 'read', name: 'Read', @@ -194,74 +211,102 @@ const QuickAccessBar = ({ }; }; - const getTextStyle = (config: ButtonConfig) => { - const isActive = activeButton === config.id; - return { - marginTop: '0.75rem', - fontSize: '0.75rem', - color: isActive ? 'var(--text-primary)' : 'var(--color-gray-700)', - fontWeight: isActive ? 'bold' : 'normal', - textRendering: 'optimizeLegibility' as const, - fontSynthesis: 'none' as const - }; - }; - return (
{ - // Prevent the wheel event from bubbling up to parent containers - e.stopPropagation(); - }} + className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`} > - - - {buttonConfigs.map((config, index) => ( - - -
- - - {config.icon} - - - - {config.name} + {/* Fixed header outside scrollable area */} +
+ +
+ + {/* Conditional divider when overflowing */} + {isOverflow && ( + + )} + + {/* Scrollable content area */} +
{ + // Prevent the wheel event from bubbling up to parent containers + e.stopPropagation(); + }} + > +
+ {/* Top section with main buttons */} + + {buttonConfigs.slice(0, -1).map((config, index) => ( + + +
+ + + {config.icon} + + + + {config.name} + +
+
+ + {/* Add divider after Automate button (index 2) */} + {index === 2 && ( + + )} +
+ ))} +
+ + {/* Spacer to push Config button to bottom */} +
+ + {/* Config button at the bottom */} + +
+ { + setConfigModalOpen(true); + }} + style={{ + backgroundColor: 'var(--icon-inactive-bg)', + color: 'var(--icon-inactive-color)', + border: 'none', + borderRadius: '8px', + }} + > + + -
-
- - {/* Add divider after Automate button (index 3) */} - {index === 3 && ( - - )} - - {/* Add spacer before Config button (index 7) */} - {index === 5 &&
} - - ))} - + + + Config + +
+ +
+
, callback?: (isOverflow: boolean) => void) => { + const [isOverflow, setIsOverflow] = React.useState(undefined); + + React.useLayoutEffect(() => { + const { current } = ref; + + const trigger = () => { + if (!current) return; + + const hasOverflow = current.scrollHeight > current.clientHeight; + setIsOverflow(hasOverflow); + + if (callback) callback(hasOverflow); + }; + + if (current) { + if ('ResizeObserver' in window) { + const resizeObserver = new ResizeObserver(trigger); + resizeObserver.observe(current); + + // Cleanup function + return () => { + resizeObserver.disconnect(); + }; + } + + // Add a small delay to ensure the element is fully rendered + setTimeout(trigger, 0); + } + }, [callback, ref]); + + return isOverflow; +}; \ No newline at end of file From d7e0b506a8d8e90cc093709906ef781eeb030b14 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Thu, 24 Jul 2025 17:17:04 +0100 Subject: [PATCH 07/17] remove fixed pixel sizes and inline styling --- .../src/components/shared/QuickAccessBar.css | 36 +++++++++++++++---- .../src/components/shared/QuickAccessBar.tsx | 20 +++++------ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/shared/QuickAccessBar.css b/frontend/src/components/shared/QuickAccessBar.css index b484132b8..b1d22fcc3 100644 --- a/frontend/src/components/shared/QuickAccessBar.css +++ b/frontend/src/components/shared/QuickAccessBar.css @@ -8,8 +8,8 @@ display: flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 2rem; + height: 2rem; } /* Action icon styles */ @@ -31,11 +31,30 @@ z-index: 10; } +/* Rainbow mode container */ +.quick-access-bar-main.rainbow-mode { + background-color: var(--bg-muted); + width: 5rem; + min-width: 5rem; + max-width: 5rem; + position: relative; + z-index: 10; +} + /* Header padding */ .quick-access-header { padding: 1rem 0.5rem 0.5rem 0.5rem; } +.nav-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-bottom: 0; + gap: 0.5rem; +} + /* Nav header divider */ .nav-header-divider { width: 3.75rem; @@ -126,10 +145,15 @@ font-synthesis: none; } +/* Font size utility */ +.font-size-20 { + font-size: 20px; +} + /* Hide scrollbar by default, show on scroll (Webkit browsers - Chrome, Safari, Edge) */ .quick-access-bar::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 0.5rem; + height: 0.5rem; background: transparent; } @@ -141,7 +165,7 @@ .quick-access-bar::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); - border-radius: 4px; + border-radius: 0.25rem; } .quick-access-bar::-webkit-scrollbar-track { @@ -152,4 +176,4 @@ .quick-access-bar { scrollbar-width: auto; scrollbar-color: rgba(0, 0, 0, 0.2) transparent; -} \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 196fa01bb..8bd4b7a6d 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -45,7 +45,7 @@ function NavHeader({ }) { return ( <> -
+
- + @@ -120,7 +120,7 @@ const QuickAccessBar = ({ { id: 'read', name: 'Read', - icon: , + icon: , tooltip: 'Read documents', size: 'lg', isRound: false, @@ -133,7 +133,7 @@ const QuickAccessBar = ({ id: 'sign', name: 'Sign', icon: - + signature , tooltip: 'Sign your document', @@ -144,7 +144,7 @@ const QuickAccessBar = ({ { id: 'automate', name: 'Automate', - icon: , + icon: , tooltip: 'Automate workflows', size: 'lg', isRound: false, @@ -153,7 +153,7 @@ const QuickAccessBar = ({ { id: 'files', name: 'Files', - icon: , + icon: , tooltip: 'Manage files', isRound: true, size: 'lg', @@ -163,7 +163,7 @@ const QuickAccessBar = ({ id: 'activity', name: 'Activity', icon: - + vital_signs , tooltip: 'View activity and analytics', @@ -174,7 +174,7 @@ const QuickAccessBar = ({ { id: 'config', name: 'Config', - icon: , + icon: , tooltip: 'Configure settings', size: 'lg', onClick: () => { @@ -213,7 +213,7 @@ const QuickAccessBar = ({ return (
{/* Fixed header outside scrollable area */}
@@ -297,7 +297,7 @@ const QuickAccessBar = ({ }} > - + From 8d96d0d31ad28e4ac264dd52e57138640a84632c Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Thu, 24 Jul 2025 21:21:46 +0100 Subject: [PATCH 08/17] add some comments to useIsOverflowing --- frontend/src/hooks/useIsOverflow.ts | 40 ++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useIsOverflow.ts b/frontend/src/hooks/useIsOverflow.ts index fdf0340e5..9a2e381cd 100644 --- a/frontend/src/hooks/useIsOverflow.ts +++ b/frontend/src/hooks/useIsOverflow.ts @@ -1,31 +1,69 @@ import * as React from 'react'; + +/** + Hook to detect if an element's content overflows its container + + + Parameters: + - ref: React ref to the element to monitor + - callback: Optional callback function called when overflow state changes + + Returns: boolean | undefined - true if overflowing, false if not, undefined before first check + + Usage example: + + useEffect(() => { + if (isOverflow) { + // Do something + } + }, [isOverflow]); + + const scrollableRef = useRef(null); + const isOverflow = useIsOverflow(scrollableRef); + + Fallback example (for browsers without ResizeObserver): + + return ( +
+ {Content that might overflow} +
+ ); +*/ + + export const useIsOverflow = (ref: React.RefObject, callback?: (isOverflow: boolean) => void) => { + // State to track overflow status const [isOverflow, setIsOverflow] = React.useState(undefined); React.useLayoutEffect(() => { const { current } = ref; + // Function to check if element is overflowing const trigger = () => { if (!current) return; + // Compare scroll height (total content height) vs client height (visible height) const hasOverflow = current.scrollHeight > current.clientHeight; setIsOverflow(hasOverflow); + // Call optional callback with overflow state if (callback) callback(hasOverflow); }; if (current) { + // Use ResizeObserver for modern browsers (real-time detection) if ('ResizeObserver' in window) { const resizeObserver = new ResizeObserver(trigger); resizeObserver.observe(current); - // Cleanup function + // Cleanup function to disconnect observer return () => { resizeObserver.disconnect(); }; } + // Fallback for browsers without ResizeObserver support // Add a small delay to ensure the element is fully rendered setTimeout(trigger, 0); } From 7403dee98dabcc5cdbd251b76eaad4531d48beb6 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Thu, 24 Jul 2025 21:23:45 +0100 Subject: [PATCH 09/17] rename useIsOverflow hook and add comments to explain usage --- frontend/src/components/shared/QuickAccessBar.tsx | 4 ++-- frontend/src/hooks/{useIsOverflow.ts => useIsOverflowing.ts} | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename frontend/src/hooks/{useIsOverflow.ts => useIsOverflowing.ts} (91%) diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 8bd4b7a6d..22a49617e 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -10,7 +10,7 @@ import NotificationsIcon from "@mui/icons-material/NotificationsRounded"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; import rainbowStyles from '../../styles/rainbow.module.css'; import AppConfigModal from './AppConfigModal'; -import { useIsOverflow } from '../../hooks/useIsOverflow'; +import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import './QuickAccessBar.css'; interface QuickAccessBarProps { @@ -114,7 +114,7 @@ const QuickAccessBar = ({ const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); - const isOverflow = useIsOverflow(scrollableRef); + const isOverflow = useIsOverflowing(scrollableRef); const buttonConfigs: ButtonConfig[] = [ { diff --git a/frontend/src/hooks/useIsOverflow.ts b/frontend/src/hooks/useIsOverflowing.ts similarity index 91% rename from frontend/src/hooks/useIsOverflow.ts rename to frontend/src/hooks/useIsOverflowing.ts index 9a2e381cd..b5e6d3962 100644 --- a/frontend/src/hooks/useIsOverflow.ts +++ b/frontend/src/hooks/useIsOverflowing.ts @@ -20,7 +20,7 @@ import * as React from 'react'; }, [isOverflow]); const scrollableRef = useRef(null); - const isOverflow = useIsOverflow(scrollableRef); + const isOverflow = useIsOverflowing(scrollableRef); Fallback example (for browsers without ResizeObserver): @@ -32,7 +32,7 @@ import * as React from 'react'; */ -export const useIsOverflow = (ref: React.RefObject, callback?: (isOverflow: boolean) => void) => { +export const useIsOverflowing = (ref: React.RefObject, callback?: (isOverflow: boolean) => void) => { // State to track overflow status const [isOverflow, setIsOverflow] = React.useState(undefined); From d37c7dcfb4210bef56d90d7c1abbac2c862c0f45 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Fri, 25 Jul 2025 12:13:10 +0100 Subject: [PATCH 10/17] Squashed commit of the following: commit 770f500ff3932479326b527682707914387c88a2 Author: EthanHealy01 Date: Fri Jul 25 12:06:20 2025 +0100 change descriptoin commit 0168fd9825d0098f2449bae803e21cb121f7329d Author: EthanHealy01 Date: Fri Jul 25 11:56:24 2025 +0100 change logos, favicon and browser tab title --- frontend/index.html | 4 ++-- frontend/public/favicon.ico | Bin 3870 -> 15406 bytes frontend/public/logo192.png | Bin 5347 -> 3161 bytes frontend/public/logo512.png | Bin 9664 -> 8151 bytes 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 0fc165c66..c4a808349 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,12 +7,12 @@ - Vite App + Stirling PDF diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index a11777cc471a4344702741ab1c8a588998b1311a..6d6c8521c12d1770066f2d3778a514492be5f8f0 100644 GIT binary patch literal 15406 zcmeHOYit!o6kei<{xbT*|NPOYzi5%-6RY?LQUQ%<6fvM^)r3H-4@A*mjE@4hrMxS& z6k4EA1X?NrLQ%p?XnCoKwADfd5hzHZQ2IEYZ?>~{?%tijxzxCaqiqDDlh+0(1S-=*#Ua<%{9dGw0u;0@aX91q`iCpuy}yar=NaH z{rgXMQ)pF@XEQb|d*#X~1w_65?36D#TR-+gx;kBcfxf6($(?Kqd+(j$C?hj-1ub8` zk6wPc)bYEQO!d3dIEQ@9ahTiWe4x@mKIU4y6DCwftc_|tfB9$|lkzb^o<4nnMvq<= zp{FVz7^Y0AqI>RfZYzHn!flWGs(fH*ZEd5EKKjPOV3!XJ`T08qT|DyaG_Y+RR(V#)*ny$)l%lnlN40!76BlmH33`>{prRSe7_T3k;$_IwO{%WSRYk$`E zM_Ji^8aHm0uW^aFANw)1Z!Ax9bE}xQ?fT0yFvv52Tl>pz@4ma1T3W8e0)hACjjnAl zV1RiBkuto}5hF@?k9^)YhM?sG-=mMtqy77j`@)b6`M_}3U3pYd@slTpnwntSU+o`W zs|j;TdwYi#$Mosn)6}U|j$;7hFV_B7<;y(msJZ#FBZkjE-z4?}@4Z(Ug&`>Uz=3t{ z=+U!L7$9Hb95bd=?2}ac3qpU%|DlKG(5_t#5g1hYsQ2WPg|v9_9?=$h{iF9lQifNW zmX=GUrTch)V8XD=2R(4knX{eG`cD0ColC5C^O9-*j&b?fXPfBCm3DHHkNU75_z3Lt zANHTv1KWAVQ^vk%!i2S2Jv94zaUNrNJ#hZW5$6w_!+P@kCC`Dt&`syCsPm`9AkSk? z=aP8FM3}}L=NN0c^S8wCUrXU9VHd42ieJBe60Rzq^z_2(`}CQ9OKR%0OzyJ^Ztq4; z+rsT0dA17r_byqGS;6nCz%po1-gOe6KBZo%sX4dvJB2aT^Ba$!SUcCmsVDoLLv{RS zr=(21!!W3q4nLo5`fZYb#M@Jv^`vVWm5bJ#~6(nvs|2W{K&)k7;y;15xnA1e)##Q!}F3gzelwv_I68_)KGQx zG0MnT5cS>8ANY%ls|miMQ0TkB{APAJYMIBqSew+}WFGi|1y4gm6TR}va^Yu_-|eZy zFV7W-TRri_0`@KVAA@$DKFZp4RmBe-a4!1z;|*H>mmNR!C@A=WUV3R6rKLH?@OqNX#rAcO9haWn?*RXBdZ}j4eODx}+8-8gM@X2E?;C_$LJsEnq;fD@*8XC^> z*jO#%I)3outUPbt4sm`@24wGbP%C>e(;d>o5YN<9hui}U| z?-xIGfIoBf>Vx8S!>xXUU1w#LYvW<@;+Ax=;D-*diN?lrR9UI_H>>!OhmYoqFE+Cd zvH4#mMz=Ip{LleUQ`1G-xbZN3^;P%|h8?%PJZsiAjtR~WbghdQKXiZ}5j3i@gCUqh7E_KeAjmDvJ4$Q`D7#S-6Pj{wSB$vLE$(1 zp_9&^zf4=U)cV#z+QhJ71#zv_SmWV)t*t$6`JsU&kq7sqcJ6G5X>NM|{jarqUavlY z4$nTjm^N)X!uz)NM4(%=n48wEJLJ0`62UL^8#r(-VaVSR!kt5D`FTM2XY5dYAu;0fS2J37r z&((q-dLV{ZRJ4aKUTpQP0_5_T()k~2xXD*63 zFZ>ef9owG5FZFrjjg|EK?@hecx!(@~eypj8J7T{HpPddneouD$OZ>otJ9WEvA9d{l zi63-WABPStV1L~f5l42?qr31+Jg{r{S9JGuRQyu+^z_*@dGZF~XR+(ho%kgl*hFFB z55nJN?+hpWpu=5ntSwmQRUN$Wb02Dba1u{EDRjUd_|HEh?+NMfLk8|K!q2MOgd6@Y z?2Yl@^FxF6di3bhi2pNi!w;UYqoU&_J% zALQk2CEVFTEJFUSrriDmnwc v^m)n0cmjrc*r+*X!uJO3=w%spA!Ea=M-ShhnU*Q%8Hw-z^#2wJss;W5j>QEm literal 3870 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..2994ca293ac701349f32ab1450e74a0d29aa7233 100644 GIT binary patch literal 3161 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 literal 5347 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 literal 9664 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN From 6f7a1573406e2e34d75c5b32c40ea865d48e8530 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Mon, 28 Jul 2025 14:41:33 +0100 Subject: [PATCH 11/17] parking this to work on OCR, I added all the icons, tool names / descriptions and changed the styling of the all tools bar --- .../public/locales/en-GB/translation.json | 45 ++- .../src/components/shared/QuickAccessBar.tsx | 8 +- frontend/src/components/tools/ToolPicker.css | 27 ++ frontend/src/components/tools/ToolPicker.tsx | 257 +++++++++++++++--- frontend/src/data/toolRegistry.tsx | 84 ++++++ frontend/src/hooks/useToolManagement.tsx | 55 +--- frontend/src/pages/HomePage.tsx | 12 +- frontend/src/styles/theme.css | 8 +- 8 files changed, 407 insertions(+), 89 deletions(-) create mode 100644 frontend/src/components/tools/ToolPicker.css create mode 100644 frontend/src/data/toolRegistry.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 081f746ee..08995c2e0 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -363,6 +363,10 @@ "title": "Add image", "desc": "Adds a image onto a set location on the PDF" }, + "attachments": { + "title": "Add Attachments", + "desc": "Add or remove embedded files (attachments) to/from a PDF" + }, "watermark": { "title": "Add Watermark", "desc": "Add a custom watermark to your PDF document." @@ -578,6 +582,34 @@ "replaceColorPdf": { "title": "Advanced Colour options", "desc": "Replace colour for text and background in PDF and invert full colour of pdf to reduce file size" + }, + "EMLToPDF": { + "title": "Email to PDF", + "desc": "Converts email (EML) files to PDF format including headers, body, and inline images" + }, + "fakeScan": { + "title": "Fake Scan", + "desc": "Create a PDF that looks like it was scanned" + }, + "editTableOfContents": { + "title": "Edit Table of Contents", + "desc": "Add or edit bookmarks and table of contents in PDF documents" + }, + "automate": { + "title": "Automate", + "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." + }, + "manageCertificates": { + "title": "Manage Certificates", + "desc": "Import, export, or delete digital certificate files used for signing PDFs." + }, + "read": { + "title": "Read", + "desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration." + }, + "reorganizePages": { + "title": "Reorganize Pages", + "desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control." } }, "viewPdf": { @@ -691,6 +723,15 @@ "upload": "Add image", "submit": "Add image" }, + "attachments": { + "tags": "attachments,add,remove,embed,file", + "title": "Add Attachments", + "header": "Add Attachments", + "add": "Add Attachment", + "remove": "Remove Attachment", + "embed": "Embed Attachment", + "submit": "Add Attachments" + }, "watermark": { "tags": "Text,repeating,label,own,copyright,trademark,img,jpg,picture,photo", "title": "Add Watermark", @@ -1526,7 +1567,7 @@ "title": "How we use Cookies", "description": { "1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.", - "2": "If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." + "2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." }, "acceptAllBtn": "Okay", "acceptNecessaryBtn": "No Thanks", @@ -1550,7 +1591,7 @@ "1": "Strictly Necessary Cookies", "2": "Always Enabled" }, - "description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off." + "description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off." }, "analytics": { "title": "Analytics", diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 22a49617e..710dc5b4a 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -144,7 +144,10 @@ const QuickAccessBar = ({ { id: 'automate', name: 'Automate', - icon: , + icon: + + automation + , tooltip: 'Automate workflows', size: 'lg', isRound: false, @@ -214,6 +217,9 @@ const QuickAccessBar = ({ return (
{/* Fixed header outside scrollable area */}
diff --git a/frontend/src/components/tools/ToolPicker.css b/frontend/src/components/tools/ToolPicker.css new file mode 100644 index 000000000..dbd49f4f6 --- /dev/null +++ b/frontend/src/components/tools/ToolPicker.css @@ -0,0 +1,27 @@ +.tool-picker-scrollable { + overflow-y: auto !important; + overflow-x: hidden !important; + scrollbar-width: thin; + scrollbar-color: var(--mantine-color-gray-4) transparent; +} + +.tool-picker-scrollable::-webkit-scrollbar { + width: 6px; +} + +.tool-picker-scrollable::-webkit-scrollbar-track { + background: transparent; +} + +.tool-picker-scrollable::-webkit-scrollbar-thumb { + background-color: var(--mantine-color-gray-4); + border-radius: 3px; +} + +.tool-picker-scrollable::-webkit-scrollbar-thumb:hover { + background-color: var(--mantine-color-gray-5); +} + +.search-input { + margin: 1rem; +} \ No newline at end of file diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index c22a0f60f..4e75fb73c 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -1,7 +1,21 @@ -import React, { useState } from "react"; -import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core"; +import React, { useState, useMemo } from "react"; +import { Box, Text, Stack, Button, TextInput, Group, Tooltip, Collapse, ActionIcon } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { ToolRegistry } from "../../types/tool"; +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; +}; interface ToolPickerProps { selectedToolKey: string | null; @@ -9,45 +23,218 @@ interface ToolPickerProps { toolRegistry: ToolRegistry; } +interface GroupedTools { + [category: string]: { + [subcategory: string]: Array<{ id: string; tool: Tool }>; + }; +} + const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => { const { t } = useTranslation(); const [search, setSearch] = useState(""); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); - const filteredTools = Object.entries(toolRegistry).filter(([_, { name }]) => - name.toLowerCase().includes(search.toLowerCase()) + // Group tools by category and subcategory in a single pass - O(n) + 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 subcategory = baseTool?.subcategory || "General"; + + 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; + }); + }, [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) => ( + + + ); return ( - - setSearch(e.currentTarget.value)} - mb="md" - autoComplete="off" - /> - - {filteredTools.length === 0 ? ( - - {t("toolPicker.noToolsFound", "No tools found")} - - ) : ( - filteredTools.map(([id, { icon, name }]) => ( - - )) - )} - + + setSearch(e.currentTarget.value)} + autoComplete="off" + className="search-input rounded-lg" + leftSection={} + /> + + + {filteredCategories.length === 0 ? ( + + {t("toolPicker.noToolsFound", "No tools found")} + + ) : ( + filteredCategories.map(({ category, subcategories }) => ( + + {/* Category Header */} + + + {/* Subcategories */} + + + {subcategories.map(({ subcategory, tools }) => ( + + {/* Subcategory Header (only show if there are multiple subcategories) */} + {subcategories.length > 1 && ( + + {subcategory} + + )} + + {/* Tools in this subcategory */} + + {tools.map(({ id, tool }, index) => + renderToolButton(id, tool, index) + )} + + + ))} + + + + )) + )} + + ); }; diff --git a/frontend/src/data/toolRegistry.tsx b/frontend/src/data/toolRegistry.tsx new file mode 100644 index 000000000..b24fd89bc --- /dev/null +++ b/frontend/src/data/toolRegistry.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import SplitPdfPanel from "../tools/Split"; +import CompressPdfPanel from "../tools/Compress"; +import MergePdfPanel from "../tools/Merge"; + +export type ToolRegistryEntry = { + icon: React.ReactNode; + name: string; + component: React.ComponentType | null; + view: string; + description: string; + category: string; + subcategory: string | null; +}; + +export type ToolRegistry = { + [key: string]: ToolRegistryEntry; +}; + +export const baseToolRegistry: ToolRegistry = { + "add-attachments": { icon: attachment, name: "home.attachments.title", component: null, view: "format", description: "home.attachments.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "add-image": { icon: image, name: "home.addImage.title", component: null, view: "format", description: "home.addImage.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "add-page-numbers": { icon: 123, name: "home.add-page-numbers.title", component: null, view: "format", description: "home.add-page-numbers.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "add-password": { icon: password, name: "home.addPassword.title", component: null, view: "security", description: "home.addPassword.desc", category: "Standard Tools", subcategory: "Document Security" }, + "add-stamp": { icon: approval, name: "home.AddStampRequest.title", component: null, view: "format", description: "home.AddStampRequest.desc", category: "Standard Tools", subcategory: "Document Security" }, + "add-watermark": { icon: branding_watermark, name: "home.watermark.title", component: null, view: "format", description: "home.watermark.desc", category: "Standard Tools", subcategory: "Document Security" }, + "adjust-colors-contrast": { icon: palette, name: "home.adjust-contrast.title", component: null, view: "format", description: "home.adjust-contrast.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "adjust-page-size-scale": { icon: crop_free, name: "home.scalePages.title", component: null, view: "format", description: "home.scalePages.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "auto-rename-pdf-file": { icon: match_word, name: "home.auto-rename.title", component: null, view: "format", description: "home.auto-rename.desc", category: "Advanced Tools", subcategory: "Automation" }, + "auto-split-by-size-count": { icon: content_cut, name: "home.autoSizeSplitPDF.title", component: null, view: "format", description: "home.autoSizeSplitPDF.desc", category: "Advanced Tools", subcategory: "Automation" }, + "auto-split-pages": { icon: split_scene_right, name: "home.autoSplitPDF.title", component: null, view: "format", description: "home.autoSplitPDF.desc", category: "Advanced Tools", subcategory: "Automation" }, + "automate": { icon: automation, name: "home.automate.title", component: null, view: "format", description: "home.automate.desc", category: "Advanced Tools", subcategory: "Automation" }, + "certSign": { icon: workspace_premium, name: "home.certSign.title", component: null, view: "sign", description: "home.certSign.desc", category: "Standard Tools", subcategory: "Signing" }, + "change-metadata": { icon: assignment, name: "home.changeMetadata.title", component: null, view: "format", description: "home.changeMetadata.desc", category: "Standard Tools", subcategory: "Document Review" }, + "change-permissions": { icon: admin_panel_settings, name: "home.permissions.title", component: null, view: "security", description: "home.permissions.desc", category: "Standard Tools", subcategory: "Document Review" }, + "compare": { icon: compare, name: "home.compare.title", component: null, view: "format", description: "home.compare.desc", category: "Recommended Tools", subcategory: null }, + "compressPdfs": { icon: zoom_in_map, name: "home.compressPdfs.title", component: CompressPdfPanel, view: "compress", description: "home.compressPdfs.desc", category: "Recommended Tools", subcategory: null }, + "convert": { icon: sync_alt, name: "home.fileToPDF.title", component: null, view: "convert", description: "home.fileToPDF.desc", category: "Recommended Tools", subcategory: null }, + "cropPdf": { icon: crop, name: "home.crop.title", component: null, view: "format", description: "home.crop.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "detect-split-scanned-photos": { icon: scanner, name: "home.ScannerImageSplit.title", component: null, view: "format", description: "home.ScannerImageSplit.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "edit-table-of-contents": { icon: bookmark_add, name: "home.editTableOfContents.title", component: null, view: "format", description: "home.editTableOfContents.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "extract-images": { icon: filter, name: "home.extractImages.title", component: null, view: "extract", description: "home.extractImages.desc", category: "Standard Tools", subcategory: "Extraction" }, + "extract-pages": { icon: upload, name: "home.extractPage.title", component: null, view: "extract", description: "home.extractPage.desc", category: "Standard Tools", subcategory: "Extraction" }, + "flatten": { icon: layers_clear, name: "home.flatten.title", component: null, view: "format", description: "home.flatten.desc", category: "Standard Tools", subcategory: "Document Security" }, + "get-all-info-on-pdf": { icon: fact_check, name: "home.getPdfInfo.title", component: null, view: "extract", description: "home.getPdfInfo.desc", category: "Standard Tools", subcategory: "Verification" }, + "manage-certificates": { icon: license, name: "home.manageCertificates.title", component: null, view: "security", description: "home.manageCertificates.desc", category: "Standard Tools", subcategory: "Document Security" }, + "mergePdfs": { icon: library_add, name: "home.merge.title", component: MergePdfPanel, view: "merge", description: "home.merge.desc", category: "Recommended Tools", subcategory: null }, + "multi-page-layout": { icon: dashboard, name: "home.pageLayout.title", component: null, view: "format", description: "home.pageLayout.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "multi-tool": { icon: dashboard_customize, name: "home.multiTool.title", component: null, view: "pageEditor", description: "home.multiTool.desc", category: "Recommended Tools", subcategory: null }, + "ocr": { icon: quick_reference_all, name: "home.ocr.title", component: null, view: "convert", description: "home.ocr.desc", category: "Recommended Tools", subcategory: null }, + "overlay-pdfs": { icon: layers, name: "home.overlay-pdfs.title", component: null, view: "format", description: "home.overlay-pdfs.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "read": { icon: article, name: "home.read.title", component: null, view: "view", description: "home.read.desc", category: "Standard Tools", subcategory: "Document Review" }, + "redact": { icon: visibility_off, name: "home.redact.title", component: null, view: "redact", description: "home.redact.desc", category: "Recommended Tools", subcategory: null }, + "remove": { icon: delete, name: "home.removePages.title", component: null, view: "remove", description: "home.removePages.desc", category: "Standard Tools", subcategory: "Removal" }, + "remove-annotations": { icon: thread_unread, name: "home.removeAnnotations.title", component: null, view: "remove", description: "home.removeAnnotations.desc", category: "Standard Tools", subcategory: "Removal" }, + "remove-blank-pages": { icon: scan_delete, name: "home.removeBlanks.title", component: null, view: "remove", description: "home.removeBlanks.desc", category: "Standard Tools", subcategory: "Removal" }, + "remove-certificate-sign": { icon: remove_moderator, name: "home.removeCertSign.title", component: null, view: "security", description: "home.removeCertSign.desc", category: "Standard Tools", subcategory: "Removal" }, + "remove-image": { icon: remove_selection, name: "home.removeImagePdf.title", component: null, view: "format", description: "home.removeImagePdf.desc", category: "Standard Tools", subcategory: "Removal" }, + "remove-password": { icon: lock_open_right, name: "home.removePassword.title", component: null, view: "security", description: "home.removePassword.desc", category: "Standard Tools", subcategory: "Removal" }, + "repair": { icon: build, name: "home.repair.title", component: null, view: "format", description: "home.repair.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "replace-and-invert-color": { icon: format_color_fill, name: "home.replaceColorPdf.title", component: null, view: "format", description: "home.replaceColorPdf.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "reorganize-pages": { icon: move_down, name: "home.reorganizePages.title", component: null, view: "pageEditor", description: "home.reorganizePages.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "rotate": { icon: rotate_right, name: "home.rotate.title", component: null, view: "format", description: "home.rotate.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "sanitize": { icon: sanitizer, name: "home.sanitizePdf.title", component: null, view: "security", description: "home.sanitizePdf.desc", category: "Standard Tools", subcategory: "Document Security" }, + "scanner-effect": { icon: scanner, name: "home.fakeScan.title", component: null, view: "format", description: "home.fakeScan.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "show-javascript": { icon: javascript, name: "home.showJS.title", component: null, view: "extract", description: "home.showJS.desc", category: "Advanced Tools", subcategory: "Developer Tools" }, + "sign": { icon: signature, name: "home.sign.title", component: null, view: "sign", description: "home.sign.desc", category: "Standard Tools", subcategory: "Signing" }, + "single-large-page": { icon: looks_one, name: "home.PdfToSinglePage.title", component: null, view: "format", description: "home.PdfToSinglePage.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "split": { icon: content_cut, name: "home.split.title", component: null, view: "format", description: "home.split.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "split-by-chapters": { icon: collections_bookmark, name: "home.splitPdfByChapters.title", component: null, view: "format", description: "home.splitPdfByChapters.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "split-by-sections": { icon: grid_on, name: "home.split-by-sections.title", component: null, view: "format", description: "home.split-by-sections.desc", category: "Advanced Tools", subcategory: "Advanced Formatting" }, + "splitPdf": { icon: content_cut, name: "home.split.title", component: SplitPdfPanel, view: "split", description: "home.split.desc", category: "Standard Tools", subcategory: "Page Formatting" }, + "unlock-pdf-forms": { icon: preview_off, name: "home.unlockPDFForms.title", component: null, view: "security", description: "home.unlockPDFForms.desc", category: "Standard Tools", subcategory: "Document Security" }, + "validate-pdf-signature": { icon: verified, name: "home.validateSignature.title", component: null, view: "security", description: "home.validateSignature.desc", category: "Standard Tools", subcategory: "Verification" }, + "view-pdf": { icon: article, name: "home.viewPdf.title", component: null, view: "view", description: "home.viewPdf.desc", category: "Recommended Tools", subcategory: null + } +}; + +export const toolEndpoints: Record = { + split: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"], + compressPdfs: ["compress-pdf"], + merge: ["merge-pdfs"], + // Add more endpoint mappings as needed +}; \ No newline at end of file diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 7ada59024..ec2630267 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -1,38 +1,11 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import ContentCutIcon from "@mui/icons-material/ContentCut"; -import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; -import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool"; - - -// Add entry here with maxFiles, endpoints, and lazy component -const toolDefinitions: Record = { - split: { - id: "split", - icon: , - component: React.lazy(() => import("../tools/Split")), - maxFiles: 1, - category: "manipulation", - description: "Split PDF files into smaller parts", - endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"] - }, - compress: { - id: "compress", - icon: , - component: React.lazy(() => import("../tools/Compress")), - maxFiles: -1, - category: "optimization", - description: "Reduce PDF file size", - endpoints: ["compress-pdf"] - }, - -}; - +import { baseToolRegistry, toolEndpoints, type ToolRegistry, type ToolRegistryEntry } from "../data/toolRegistry"; interface ToolManagementResult { selectedToolKey: string | null; - selectedTool: Tool | null; + selectedTool: ToolRegistryEntry | null; toolSelectedFileIds: string[]; toolRegistry: ToolRegistry; selectTool: (toolKey: string) => void; @@ -47,30 +20,30 @@ export const useToolManagement = (): ToolManagementResult => { const [toolSelectedFileIds, setToolSelectedFileIds] = useState([]); const allEndpoints = Array.from(new Set( - Object.values(toolDefinitions).flatMap(tool => tool.endpoints || []) + Object.values(toolEndpoints).flat() as string[] )); const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const isToolAvailable = useCallback((toolKey: string): boolean => { if (endpointsLoading) return true; - const tool = toolDefinitions[toolKey]; - if (!tool?.endpoints) return true; - return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true); + const endpoints = toolEndpoints[toolKey] || []; + return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true); }, [endpointsLoading, endpointStatus]); const toolRegistry: ToolRegistry = useMemo(() => { - const availableTools: ToolRegistry = {}; - Object.keys(toolDefinitions).forEach(toolKey => { + const availableToolRegistry: ToolRegistry = {}; + Object.keys(baseToolRegistry).forEach(toolKey => { if (isToolAvailable(toolKey)) { - const toolDef = toolDefinitions[toolKey]; - availableTools[toolKey] = { - ...toolDef, - name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1)) + const baseTool = baseToolRegistry[toolKey as keyof typeof baseToolRegistry]; + availableToolRegistry[toolKey] = { + ...baseTool, + name: t(baseTool.name), + description: t(baseTool.description) }; } }); - return availableTools; - }, [t, isToolAvailable]); + return availableToolRegistry; + }, [isToolAvailable, t]); useEffect(() => { if (!endpointsLoading && selectedToolKey && !toolRegistry[selectedToolKey]) { diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d24c58b44..3a60071ef 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -104,10 +104,10 @@ function HomePageContent() { {/* Left: Tool Picker or Selected Tool Panel */}
{/* Back button */} -
+
- + const sortSubs = (obj: Record>) => + Object.entries(obj) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([subcategory, tools]) => ({ + subcategory, + tools: tools.sort((a, b) => a.tool.name.localeCompare(b.tool.name)) + })); + + return [ + { title: "QUICK ACCESS", ref: quickAccessRef, subcategories: sortSubs(quick) }, + { title: "ALL TOOLS", ref: allToolsRef, subcategories: sortSubs(all) } + ]; + }, [groupedTools]); + + const visibleSections = useMemo(() => { + if (!search.trim()) return sections; + const term = search.toLowerCase(); + return sections + .map(s => ({ + ...s, + subcategories: s.subcategories + .map(sc => ({ + ...sc, + tools: sc.tools.filter(({ tool }) => + tool.name.toLowerCase().includes(term) || + tool.description.toLowerCase().includes(term) + ) + })) + .filter(sc => sc.tools.length) + })) + .filter(s => s.subcategories.length); + }, [sections, search]); + + const quickSection = useMemo( + () => visibleSections.find(s => s.title === "QUICK ACCESS"), + [visibleSections] + ); + const allSection = useMemo( + () => visibleSections.find(s => s.title === "ALL TOOLS"), + [visibleSections] ); + const scrollTo = (ref: React.RefObject) => { + const container = scrollableRef.current; + const target = ref.current; + if (container && target) { + const stackedOffset = ref === allToolsRef + ? (quickHeaderHeight + allHeaderHeight) + : quickHeaderHeight; + const top = target.offsetTop - container.offsetTop - (stackedOffset || 0); + container.scrollTo({ + top: Math.max(0, top), + behavior: "smooth" + }); + } + }; + return ( - - setSearch(e.currentTarget.value)} - autoComplete="off" - className="search-input rounded-lg" - leftSection={} - /> + + - - {filteredCategories.length === 0 ? ( - - {t("toolPicker.noToolsFound", "No tools found")} - - ) : ( - filteredCategories.map(({ category, subcategories }) => ( - - {/* Category Header */} - + {quickSection && ( + <> +
scrollTo(quickAccessRef)} + > + QUICK ACCESS + + {quickSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} + +
- {/* Subcategories */} - - - {subcategories.map(({ subcategory, tools }) => ( - - {/* Subcategory Header (only show if there are multiple subcategories) */} - {subcategories.length > 1 && ( - - {subcategory} - - )} + + + {quickSection.subcategories.map(sc => ( + + {quickSection.subcategories.length > 1 && ( + + {sc.subcategory} + + )} + + {sc.tools.map(({ id, tool }) => ( + + ))} + + + ))} + + + + )} - {/* Tools in this subcategory */} - - {tools.map(({ id, tool }, index) => - renderToolButton(id, tool, index) - )} - - - ))} - - -
- )) - )} -
+ {allSection && ( + <> +
scrollTo(allToolsRef)} + > + ALL TOOLS + + {allSection.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)} + +
+ + + + {allSection.subcategories.map(sc => ( + + {allSection.subcategories.length > 1 && ( + + {sc.subcategory} + + )} + + {sc.tools.map(({ id, tool }) => ( + + ))} + + + ))} + + + + )} + + {!quickSection && !allSection && ( + + {t("toolPicker.noToolsFound", "No tools found")} + + )}
); diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx new file mode 100644 index 000000000..06e3d5fd2 --- /dev/null +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Button, Tooltip } from "@mantine/core"; +import { type ToolRegistryEntry } from "../../../data/toolRegistry"; + +interface ToolButtonProps { + id: string; + tool: ToolRegistryEntry; + isSelected: boolean; + onSelect: (id: string) => void; +} + +const ToolButton: React.FC = ({ id, tool, isSelected, onSelect }) => { + return ( + + + + ); +}; + +export default ToolButton; \ No newline at end of file diff --git a/frontend/src/components/tools/ToolPicker.css b/frontend/src/components/tools/toolPicker/ToolPicker.css similarity index 51% rename from frontend/src/components/tools/ToolPicker.css rename to frontend/src/components/tools/toolPicker/ToolPicker.css index dbd49f4f6..964e2f4ff 100644 --- a/frontend/src/components/tools/ToolPicker.css +++ b/frontend/src/components/tools/toolPicker/ToolPicker.css @@ -20,8 +20,33 @@ .tool-picker-scrollable::-webkit-scrollbar-thumb:hover { background-color: var(--mantine-color-gray-5); -} +} .search-input { margin: 1rem; +} + +.tool-subcategory-title { + text-transform: uppercase; + padding-bottom: 0.5rem; + font-size: 0.75rem; + color: var(--tool-subcategory-text-color); + /* Align the text with tool labels to account for icon gutter */ + padding-left: 1rem; +} + +/* Compact tool buttons */ +.tool-button { + font-size: 0.875rem; /* default 1rem - 0.125rem? We'll apply exact -0.25rem via calc below */ + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} + +.tool-button .mantine-Button-label { + font-size: .85rem; +} + +.tool-button-icon { + font-size: 1rem; + line-height: 1; } \ No newline at end of file diff --git a/frontend/src/components/tools/toolPicker/ToolSearch.tsx b/frontend/src/components/tools/toolPicker/ToolSearch.tsx new file mode 100644 index 000000000..901c0a054 --- /dev/null +++ b/frontend/src/components/tools/toolPicker/ToolSearch.tsx @@ -0,0 +1,128 @@ +import React, { useState, useRef, useEffect, useMemo } from "react"; +import { TextInput, Stack, Button, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import SearchIcon from "@mui/icons-material/Search"; +import { type ToolRegistryEntry } from "../../../data/toolRegistry"; + +interface ToolSearchProps { + value: string; + onChange: (value: string) => void; + toolRegistry: Readonly>; + onToolSelect?: (toolId: string) => void; + mode: 'filter' | 'dropdown'; + selectedToolKey?: string | null; +} + +const ToolSearch = ({ + value, + onChange, + toolRegistry, + onToolSelect, + mode = 'filter', + selectedToolKey +}: ToolSearchProps) => { + const { t } = useTranslation(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const searchRef = useRef(null); + + const filteredTools = useMemo(() => { + if (!value.trim()) return []; + return Object.entries(toolRegistry) + .filter(([id, tool]) => { + if (mode === 'dropdown' && id === selectedToolKey) return false; + return tool.name.toLowerCase().includes(value.toLowerCase()) || + tool.description.toLowerCase().includes(value.toLowerCase()); + }) + .slice(0, 6) + .map(([id, tool]) => ({ id, tool })); + }, [value, toolRegistry, mode, selectedToolKey]); + + const handleSearchChange = (searchValue: string) => { + onChange(searchValue); + if (mode === 'dropdown') { + setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0); + } + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const searchInput = ( + handleSearchChange(e.currentTarget.value)} + autoComplete="off" + className="search-input rounded-lg" + leftSection={} + /> + ); + + if (mode === 'filter') { + return searchInput; + } + + return ( +
+ {searchInput} + {dropdownOpen && filteredTools.length > 0 && ( +
+ + {filteredTools.map(({ id, tool }) => ( + + ))} + +
+ )} +
+ ); +}; + +export default ToolSearch; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index e5c9a151e..f86d0d229 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -10,6 +10,7 @@ import { PageEditorFunctions } from "../types/pageEditor"; import rainbowStyles from '../styles/rainbow.module.css'; import ToolPicker from "../components/tools/ToolPicker"; +import ToolSearch from "../components/tools/toolPicker/ToolSearch"; import TopControls from "../components/shared/TopControls"; import FileEditor from "../components/fileEditor/FileEditor"; import PageEditor from "../components/pageEditor/PageEditor"; @@ -29,6 +30,10 @@ function HomePageContent() { const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); const { addToActiveFiles } = useFileHandler(); + const [sidebarsVisible, setSidebarsVisible] = useState(true); + const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); + const [readerMode, setReaderMode] = useState(false); + const { selectedToolKey, selectedTool, @@ -37,11 +42,9 @@ function HomePageContent() { clearToolSelection, } = useToolManagement(); - const [sidebarsVisible, setSidebarsVisible] = useState(true); - const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); - const [readerMode, setReaderMode] = useState(false); const [pageEditorFunctions, setPageEditorFunctions] = useState(null); const [previewFile, setPreviewFile] = useState(null); + const [toolSearch, setToolSearch] = useState(""); // Update file selection context when tool changes useEffect(() => { @@ -81,7 +84,13 @@ function HomePageContent() { setCurrentView(view as any); }, [setCurrentView]); - + const handleToolSearchSelect = useCallback((toolId: string) => { + selectTool(toolId); + setCurrentView('fileEditor'); + setLeftPanelView('toolContent'); + setReaderMode(false); + setToolSearch(''); // Clear search after selection + }, [selectTool, setCurrentView]); return ( @@ -129,8 +138,20 @@ function HomePageContent() { ) : ( // Selected Tool Content View
+ {/* Search bar for quick tool switching */} +
+ +
+ {/* Back button */} -
+