From a16ee308e558e25255a736a0acda96bff9955468 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 4 Sep 2025 11:36:55 +0100 Subject: [PATCH] Redesign tools to be data-driven --- .../public/locales/en-GB/translation.json | 38 +++++- .../components/tools/shared/GenericTool.tsx | 69 ++++++++++ .../components/tools/shared/toolDefinition.ts | 119 ++++++++++++++++++ .../components/tooltips/useCompressTips.ts | 30 ----- frontend/src/tools/Compress.tsx | 59 +-------- frontend/src/tools/Repair.tsx | 43 +------ frontend/src/tools/Split.tsx | 58 ++------- .../tools/definitions/compressDefinition.ts | 51 ++++++++ .../src/tools/definitions/repairDefinition.ts | 28 +++++ .../src/tools/definitions/splitDefinition.ts | 28 +++++ 10 files changed, 349 insertions(+), 174 deletions(-) create mode 100644 frontend/src/components/tools/shared/GenericTool.tsx create mode 100644 frontend/src/components/tools/shared/toolDefinition.ts delete mode 100644 frontend/src/components/tooltips/useCompressTips.ts create mode 100644 frontend/src/tools/definitions/compressDefinition.ts create mode 100644 frontend/src/tools/definitions/repairDefinition.ts create mode 100644 frontend/src/tools/definitions/splitDefinition.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 637ab59e1..4156caa68 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -669,7 +669,16 @@ "8": "Document #6: Page 10" }, "splitPages": "Enter pages to split on:", - "submit": "Split" + "submit": "Split", + "settings": { + "title": "Split" + }, + "results": { + "title": "Split Results" + }, + "error": { + "failed": "An error occurred while splitting the PDF." + } }, "rotate": { "tags": "server side", @@ -1787,7 +1796,32 @@ "4": "Auto mode - Auto adjusts quality to get PDF to exact size", "5": "Expected PDF Size (e.g. 25MB, 10.8MB, 25KB)" }, - "submit": "Compress" + "submit": "Compress", + "tooltip": { + "header": { + "title": "Compress Settings Overview" + }, + "description": { + "title": "Description", + "text": "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually." + }, + "qualityAdjustment": { + "title": "Quality Adjustment", + "text": "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity.", + "bullet1": "Lower values preserve quality", + "bullet2": "Higher values reduce file size" + }, + "grayscale": { + "title": "Grayscale", + "text": "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents." + } + }, + "settings": { + "title": "Settings" + }, + "error": { + "failed": "An error occurred while compressing the PDF." + } }, "decrypt": { "passwordPrompt": "This file is password-protected. Please enter the password:", diff --git a/frontend/src/components/tools/shared/GenericTool.tsx b/frontend/src/components/tools/shared/GenericTool.tsx new file mode 100644 index 000000000..ab0744c01 --- /dev/null +++ b/frontend/src/components/tools/shared/GenericTool.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from 'react-i18next'; +import { GenericToolProps } from './toolDefinition'; +import { useBaseTool } from '../../../hooks/tools/shared/useBaseTool'; +import { createToolFlow, MiddleStepConfig } from './createToolFlow'; + +/** + * Generic tool component that renders any tool from its definition. + * Eliminates boilerplate by using declarative configuration. + */ +function GenericTool(props: GenericToolProps) { + const { definition } = props; + const { t } = useTranslation(); + + // Use the base tool hook with the definition's hooks + const base = useBaseTool( + definition.id, + definition.useParameters, + definition.useOperation, + props + ); + + // Build steps from definition - filter and map in separate operations for better typing + const visibleSteps = definition.steps.filter((stepDef) => { + const isVisible = typeof stepDef.isVisible === 'function' + ? stepDef.isVisible(base.params.parameters, base.hasFiles, base.hasResults) + : stepDef.isVisible ?? true; + return isVisible; + }); + + const steps: MiddleStepConfig[] = visibleSteps.map((stepDef) => ({ + title: stepDef.title(t), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined, + tooltip: stepDef.tooltip?.(t), + content: ( + + ), + })); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps, + executeButton: { + text: definition.executeButton.text(t), + isVisible: !base.hasResults, + loadingText: definition.executeButton.loadingText?.(t) || t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + testId: definition.executeButton.testId, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: definition.review.title(t), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + testId: definition.review.testId, + }, + }); +} + +export default GenericTool; diff --git a/frontend/src/components/tools/shared/toolDefinition.ts b/frontend/src/components/tools/shared/toolDefinition.ts new file mode 100644 index 000000000..a84d747b9 --- /dev/null +++ b/frontend/src/components/tools/shared/toolDefinition.ts @@ -0,0 +1,119 @@ +import React from 'react'; +import { TFunction } from 'i18next'; +import { BaseParametersHook } from '../../../hooks/tools/shared/useBaseParameters'; +import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation'; +import { BaseToolProps } from '../../../types/tool'; +import { TooltipTip } from '../../../types/tips'; + +/** + * Configuration for a single tool step/section + */ +export interface ToolStepDefinition { + /** Unique identifier for this step */ + key: string; + + /** Display title for the step */ + title: (t: TFunction) => string; + + /** Settings component to render in this step */ + component: React.ComponentType<{ + parameters: TParams; + onParameterChange: (key: keyof TParams, value: TParams[keyof TParams]) => void; + disabled?: boolean; + }>; + + /** Tooltip configuration for this step */ + tooltip?: (t: TFunction) => { + content?: React.ReactNode; + tips?: TooltipTip[]; + header?: { + title: string; + logo?: React.ReactNode; + }; + }; + + /** Whether this step is visible (defaults to true) */ + isVisible?: boolean | ((params: TParams, hasFiles: boolean, hasResults: boolean) => boolean); +} + +/** + * Configuration for the execute button + */ +export interface ToolExecuteButtonDefinition { + /** Button text */ + text: (t: TFunction) => string; + + /** Loading state text */ + loadingText?: (t: TFunction) => string; + + /** Test ID for the button */ + testId?: string; +} + +/** + * Configuration for the review/results section + */ +export interface ToolReviewDefinition { + /** Title for the review section */ + title: (t: TFunction) => string; + + /** Test ID for the review section */ + testId?: string; +} + +/** + * Complete tool definition for declarative tool creation + */ +export interface ToolDefinition { + /** Unique tool identifier */ + id: string; + + /** Hook that provides parameter management */ + useParameters: () => BaseParametersHook; + + /** Hook that provides operation execution */ + useOperation: () => ToolOperationHook; + + /** Configuration steps for the tool */ + steps: ToolStepDefinition[]; + + /** Execute button configuration */ + executeButton: ToolExecuteButtonDefinition; + + /** Review section configuration */ + review: ToolReviewDefinition; + + /** Optional tooltip for when using this tool */ + tooltip?: (t: TFunction) => { + content?: React.ReactNode; + tips?: TooltipTip[]; + header?: { + title: string; + logo?: React.ReactNode; + }; + }; +} + +/** + * Props for GenericTool component + */ +export interface GenericToolProps extends BaseToolProps { + /** Tool definition to render */ + definition: ToolDefinition; +} + +/** + * Registry entry for a tool + */ +export interface ToolRegistryEntry { + /** Tool definition */ + definition: ToolDefinition; + + /** Display metadata */ + metadata: { + name: string; + category: string; + description?: string; + icon?: React.ReactNode; + }; +} diff --git a/frontend/src/components/tooltips/useCompressTips.ts b/frontend/src/components/tooltips/useCompressTips.ts deleted file mode 100644 index c42e8d63a..000000000 --- a/frontend/src/components/tooltips/useCompressTips.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { TooltipContent } from '../../types/tips'; - -export const useCompressTips = (): TooltipContent => { - const { t } = useTranslation(); - - return { - header: { - title: t("compress.tooltip.header.title", "Compress Settings Overview") - }, - tips: [ - { - title: t("compress.tooltip.description.title", "Description"), - description: t("compress.tooltip.description.text", "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually.") - }, - { - title: t("compress.tooltip.qualityAdjustment.title", "Quality Adjustment"), - description: t("compress.tooltip.qualityAdjustment.text", "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity."), - bullets: [ - t("compress.tooltip.qualityAdjustment.bullet1", "Lower values preserve quality"), - t("compress.tooltip.qualityAdjustment.bullet2", "Higher values reduce file size") - ] - }, - { - title: t("compress.tooltip.grayscale.title", "Grayscale"), - description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.") - } - ] - }; -}; diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index 6fbc92b09..2a091d40b 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -1,59 +1,12 @@ -import { useTranslation } from "react-i18next"; -import { createToolFlow } from "../components/tools/shared/createToolFlow"; -import CompressSettings from "../components/tools/compress/CompressSettings"; -import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters"; -import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation"; -import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; -import { BaseToolProps, ToolComponent } from "../types/tool"; -import { useCompressTips } from "../components/tooltips/useCompressTips"; +import GenericTool from '../components/tools/shared/GenericTool'; +import { compressDefinition } from './definitions/compressDefinition'; +import { BaseToolProps, ToolComponent } from '../types/tool'; const Compress = (props: BaseToolProps) => { - const { t } = useTranslation(); - const compressTips = useCompressTips(); - - const base = useBaseTool( - 'compress', - useCompressParameters, - useCompressOperation, - props - ); - - return createToolFlow({ - files: { - selectedFiles: base.selectedFiles, - isCollapsed: base.hasResults, - }, - steps: [ - { - title: "Settings", - isCollapsed: base.settingsCollapsed, - onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, - tooltip: compressTips, - content: ( - - ), - }, - ], - executeButton: { - text: t("compress.submit", "Compress"), - isVisible: !base.hasResults, - loadingText: t("loading"), - onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, - }, - review: { - isVisible: base.hasResults, - operation: base.operation, - title: t("compress.title", "Compression Results"), - onFileClick: base.handleThumbnailClick, - onUndo: base.handleUndo, - }, - }); + return ; }; +// Static method to get the operation hook for automation +Compress.tool = () => compressDefinition.useOperation; export default Compress as ToolComponent; diff --git a/frontend/src/tools/Repair.tsx b/frontend/src/tools/Repair.tsx index c805592c5..275c704bb 100644 --- a/frontend/src/tools/Repair.tsx +++ b/frontend/src/tools/Repair.tsx @@ -1,45 +1,12 @@ -import { useTranslation } from "react-i18next"; -import { createToolFlow } from "../components/tools/shared/createToolFlow"; -import { useRepairParameters } from "../hooks/tools/repair/useRepairParameters"; -import { useRepairOperation } from "../hooks/tools/repair/useRepairOperation"; -import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; -import { BaseToolProps, ToolComponent } from "../types/tool"; +import GenericTool from '../components/tools/shared/GenericTool'; +import { repairDefinition } from './definitions/repairDefinition'; +import { BaseToolProps, ToolComponent } from '../types/tool'; const Repair = (props: BaseToolProps) => { - const { t } = useTranslation(); - - const base = useBaseTool( - 'repair', - useRepairParameters, - useRepairOperation, - props - ); - - return createToolFlow({ - files: { - selectedFiles: base.selectedFiles, - isCollapsed: base.hasResults, - placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"), - }, - steps: [], - executeButton: { - text: t("repair.submit", "Repair PDF"), - isVisible: !base.hasResults, - loadingText: t("loading"), - onClick: base.handleExecute, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, - }, - review: { - isVisible: base.hasResults, - operation: base.operation, - title: t("repair.results.title", "Repair Results"), - onFileClick: base.handleThumbnailClick, - onUndo: base.handleUndo, - }, - }); + return ; }; // Static method to get the operation hook for automation -Repair.tool = () => useRepairOperation; +Repair.tool = () => repairDefinition.useOperation; export default Repair as ToolComponent; diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index 6a0cef697..0a6e4d840 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -1,56 +1,12 @@ -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { createToolFlow } from "../components/tools/shared/createToolFlow"; -import SplitSettings from "../components/tools/split/SplitSettings"; -import { useSplitParameters } from "../hooks/tools/split/useSplitParameters"; -import { useSplitOperation } from "../hooks/tools/split/useSplitOperation"; -import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; -import { BaseToolProps, ToolComponent } from "../types/tool"; +import GenericTool from '../components/tools/shared/GenericTool'; +import { splitDefinition } from './definitions/splitDefinition'; +import { BaseToolProps, ToolComponent } from '../types/tool'; const Split = (props: BaseToolProps) => { - const { t } = useTranslation(); - - const base = useBaseTool( - 'split', - useSplitParameters, - useSplitOperation, - props - ); - - return createToolFlow({ - files: { - selectedFiles: base.selectedFiles, - isCollapsed: base.hasResults, - }, - steps: [ - { - title: "Settings", - isCollapsed: base.settingsCollapsed, - onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined, - content: ( - - ), - }, - ], - executeButton: { - text: t("split.submit", "Split PDF"), - loadingText: t("loading"), - onClick: base.handleExecute, - isVisible: !base.hasResults, - disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, - }, - review: { - isVisible: base.hasResults, - operation: base.operation, - title: "Split Results", - onFileClick: base.handleThumbnailClick, - onUndo: base.handleUndo, - }, - }); + return ; }; +// Static method to get the operation hook for automation +Split.tool = () => splitDefinition.useOperation; + export default Split as ToolComponent; diff --git a/frontend/src/tools/definitions/compressDefinition.ts b/frontend/src/tools/definitions/compressDefinition.ts new file mode 100644 index 000000000..2a0f6503b --- /dev/null +++ b/frontend/src/tools/definitions/compressDefinition.ts @@ -0,0 +1,51 @@ +import { ToolDefinition } from '../../components/tools/shared/toolDefinition'; +import { CompressParameters, useCompressParameters } from '../../hooks/tools/compress/useCompressParameters'; +import { useCompressOperation } from '../../hooks/tools/compress/useCompressOperation'; +import CompressSettings from '../../components/tools/compress/CompressSettings'; + +export const compressDefinition: ToolDefinition = { + id: 'compress', + + useParameters: useCompressParameters, + useOperation: useCompressOperation, + + steps: [ + { + key: 'settings', + title: (t) => t("compress.settings.title", "Settings"), + component: CompressSettings, + tooltip: (t) => ({ + header: { + title: t("compress.tooltip.header.title", "Compress Settings Overview") + }, + tips: [ + { + title: t("compress.tooltip.description.title", "Description"), + description: t("compress.tooltip.description.text", "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually.") + }, + { + title: t("compress.tooltip.qualityAdjustment.title", "Quality Adjustment"), + description: t("compress.tooltip.qualityAdjustment.text", "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity."), + bullets: [ + t("compress.tooltip.qualityAdjustment.bullet1", "Lower values preserve quality"), + t("compress.tooltip.qualityAdjustment.bullet2", "Higher values reduce file size") + ] + }, + { + title: t("compress.tooltip.grayscale.title", "Grayscale"), + description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.") + } + ] + }), + }, + ], + + executeButton: { + text: (t) => t("compress.submit", "Compress"), + loadingText: (t) => t("loading"), + }, + + review: { + title: (t) => t("compress.title", "Compression Results"), + }, +}; diff --git a/frontend/src/tools/definitions/repairDefinition.ts b/frontend/src/tools/definitions/repairDefinition.ts new file mode 100644 index 000000000..519c7863e --- /dev/null +++ b/frontend/src/tools/definitions/repairDefinition.ts @@ -0,0 +1,28 @@ +import { ToolDefinition } from '../../components/tools/shared/toolDefinition'; +import { RepairParameters, useRepairParameters } from '../../hooks/tools/repair/useRepairParameters'; +import { useRepairOperation } from '../../hooks/tools/repair/useRepairOperation'; +import RepairSettings from '../../components/tools/repair/RepairSettings'; + +export const repairDefinition: ToolDefinition = { + id: 'repair', + + useParameters: useRepairParameters, + useOperation: useRepairOperation, + + steps: [ + { + key: 'settings', + title: (t) => t("repair.settings.title", "Settings"), + component: RepairSettings, + }, + ], + + executeButton: { + text: (t) => t("repair.submit", "Repair PDF"), + loadingText: (t) => t("loading"), + }, + + review: { + title: (t) => t("repair.results.title", "Repair Results"), + }, +}; diff --git a/frontend/src/tools/definitions/splitDefinition.ts b/frontend/src/tools/definitions/splitDefinition.ts new file mode 100644 index 000000000..4569432bd --- /dev/null +++ b/frontend/src/tools/definitions/splitDefinition.ts @@ -0,0 +1,28 @@ +import { ToolDefinition } from '../../components/tools/shared/toolDefinition'; +import { SplitParameters, useSplitParameters } from '../../hooks/tools/split/useSplitParameters'; +import { useSplitOperation } from '../../hooks/tools/split/useSplitOperation'; +import SplitSettings from '../../components/tools/split/SplitSettings'; + +export const splitDefinition: ToolDefinition = { + id: 'split', + + useParameters: useSplitParameters, + useOperation: useSplitOperation, + + steps: [ + { + key: 'settings', + title: (t) => t("split.settings.title", "Settings"), + component: SplitSettings, + }, + ], + + executeButton: { + text: (t) => t("split.submit", "Split PDF"), + loadingText: (t) => t("loading"), + }, + + review: { + title: (t) => t("split.results.title", "Split Results"), + }, +};