Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/pagemanager-improvements

This commit is contained in:
Reece Browne 2025-08-25 17:30:37 +01:00
commit 9cdeb013e6
123 changed files with 6996 additions and 1248 deletions

6
frontend/.gitignore vendored
View File

@ -24,4 +24,8 @@ yarn-debug.log*
yarn-error.log*
playwright-report
test-results
test-results
# auto-generated files
/src/assets/material-symbols-icons.json
/src/assets/material-symbols-icons.d.ts

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@iconify/react": "^6.0.0",
"@mantine/core": "^8.0.1",
"@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "^8.0.1",
@ -25,7 +26,6 @@
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1",
"material-symbols": "^0.33.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174",
"react": "^19.1.0",
@ -36,10 +36,14 @@
"web-vitals": "^2.1.4"
},
"scripts": {
"predev": "npm run generate-icons",
"dev": "npx tsc --noEmit && vite",
"prebuild": "npm run generate-icons",
"build": "npx tsc --noEmit && vite build",
"preview": "vite preview",
"generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js",
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
"test": "vitest",
"test:watch": "vitest --watch",
"test:coverage": "vitest --coverage",
@ -66,6 +70,8 @@
]
},
"devDependencies": {
"@iconify-json/material-symbols": "^1.2.33",
"@iconify/utils": "^3.0.1",
"@playwright/test": "^1.40.0",
"@types/node": "^24.2.1",
"@types/react": "^19.1.4",
@ -74,6 +80,7 @@
"@vitest/coverage-v8": "^1.0.0",
"jsdom": "^23.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
"postcss": "^8.5.3",
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0",

View File

@ -85,6 +85,7 @@
"warning": {
"tooltipTitle": "Warning"
},
"edit": "Edit",
"delete": "Delete",
"username": "Username",
"password": "Password",
@ -538,10 +539,6 @@
"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."
@ -601,8 +598,16 @@
"changePermissions": {
"title": "Change Permissions",
"desc": "Change document restrictions and permissions"
},
"automate": {
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
}
},
"landing": {
"addFiles": "Add Files",
"uploadFromComputer": "Upload from computer"
},
"viewPdf": {
"tags": "view,read,annotate,text,image,highlight,edit",
"title": "View/Edit PDF",
@ -731,7 +736,8 @@
"officeDocs": "Office Documents (Word, Excel, PowerPoint)",
"imagesExt": "Images (JPG, PNG, etc.)",
"markdown": "Markdown",
"textRtf": "Text/RTF"
"textRtf": "Text/RTF",
"grayscale": "Greyscale"
},
"imageToPdf": {
"tags": "conversion,img,jpg,picture,photo"
@ -986,6 +992,7 @@
},
"submit": "Change"
},
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",
@ -1920,23 +1927,41 @@
"currentPage": "Current Page",
"totalPages": "Total Pages"
},
"rightRail": {
"closeSelected": "Close Selected Files",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"selectByNumber": "Select by Page Numbers",
"deleteSelected": "Delete Selected Pages",
"closePdf": "Close PDF",
"exportAll": "Export PDF",
"downloadSelected": "Download Selected Files",
"downloadAll": "Download All",
"toggleTheme": "Toggle Theme",
"language": "Language"
},
"toolPicker": {
"searchPlaceholder": "Search tools...",
"noToolsFound": "No tools found",
"allTools": "ALL TOOLS",
"quickAccess": "QUICK ACCESS",
"categories": {
"standardTools": "Standard Tools",
"advancedTools": "Advanced Tools",
"recommendedTools": "Recommended Tools"
},
"subcategories": {
"Signing": "Signing",
"Document Security": "Document Security",
"Verification": "Verification",
"Document Review": "Document Review",
"Page Formatting": "Page Formatting",
"Extraction": "Extraction",
"Removal": "Removal",
"Automation": "Automation",
"General": "General",
"Advanced Formatting": "Advanced Formatting",
"Developer Tools": "Developer Tools"
"signing": "Signing",
"documentSecurity": "Document Security",
"verification": "Verification",
"documentReview": "Document Review",
"pageFormatting": "Page Formatting",
"extraction": "Extraction",
"removal": "Removal",
"automation": "Automation",
"general": "General",
"advancedFormatting": "Advanced Formatting",
"developerTools": "Developer Tools"
}
},
"quickAccess": {
@ -1963,6 +1988,7 @@
"dropFilesHere": "Drop files here or click to upload",
"pdfFilesOnly": "PDF files only",
"supportedFileTypes": "Supported file types",
"upload": "Upload",
"uploadFile": "Upload File",
"uploadFiles": "Upload Files",
"noFilesInStorage": "No files available in storage. Upload some files first.",
@ -2016,7 +2042,8 @@
"downloadSelected": "Download Selected",
"selectedCount": "{{count}} selected",
"download": "Download",
"delete": "Delete"
"delete": "Delete",
"unsupported":"Unsupported"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
@ -2186,5 +2213,68 @@
"results": {
"title": "Decrypted PDFs"
}
}
},
"automate": {
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks.",
"invalidStep": "Invalid step",
"files": {
"placeholder": "Select files to process with this automation"
},
"selection": {
"title": "Automation Selection",
"saved": {
"title": "Saved"
},
"createNew": {
"title": "Create New Automation"
},
"suggested": {
"title": "Suggested"
}
},
"creation": {
"createTitle": "Create Automation",
"editTitle": "Edit Automation",
"description": "Automations run tools sequentially. To get started, add tools in the order you want them to run.",
"name": {
"placeholder": "Automation name"
},
"tools": {
"selectTool": "Select a tool...",
"selected": "Selected Tools",
"remove": "Remove tool",
"configure": "Configure tool",
"notConfigured": "! Not Configured",
"addTool": "Add Tool",
"add": "Add a tool..."
},
"save": "Save Automation",
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.",
"cancel": "Cancel",
"confirm": "Go Back"
}
},
"run": {
"title": "Run Automation"
},
"sequence": {
"unnamed": "Unnamed Automation",
"steps": "{{count}} steps",
"running": "Running Automation...",
"run": "Run Automation",
"finish": "Finish"
},
"reviewTitle": "Automation Results",
"config": {
"loading": "Loading tool configuration...",
"noSettings": "This tool does not have configurable settings.",
"title": "Configure {{toolName}}",
"description": "Configure the settings for this tool. These settings will be applied when the automation runs.",
"cancel": "Cancel",
"save": "Save Configuration"
}
}
}

View File

@ -55,6 +55,7 @@
"bored": "Bored Waiting?",
"alphabet": "Alphabet",
"downloadPdf": "Download PDF",
"text": "Text",
"font": "Font",
"selectFillter": "-- Select --",
@ -607,6 +608,10 @@
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
}
},
"landing": {
"addFiles": "Add Files",
"uploadFromComputer": "Upload from computer"
},
"viewPdf": {
"tags": "view,read,annotate,text,image,highlight,edit",
"title": "View/Edit PDF",
@ -2068,6 +2073,18 @@
}
}
},
"rightRail": {
"closePdf": "Close PDF",
"closeSelected": "Close Selected Files",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"selectByNumber": "Select by Page Numbers",
"deleteSelected": "Delete Selected Pages",
"toggleTheme": "Toggle Theme",
"exportAll": "Export PDF",
"downloadSelected": "Download Selected Files",
"downloadAll": "Download All"
},
"removePassword": {
"title": "Remove Password",
"desc": "Remove password protection from your PDF document.",

View File

@ -0,0 +1,175 @@
#!/usr/bin/env node
const { icons } = require('@iconify-json/material-symbols');
const { getIcons } = require('@iconify/utils');
const fs = require('fs');
const path = require('path');
// Check for verbose flag
const isVerbose = process.argv.includes('--verbose') || process.argv.includes('-v');
// Logging functions
const info = (message) => console.log(message);
const debug = (message) => {
if (isVerbose) {
console.log(message);
}
};
// Function to scan codebase for LocalIcon usage
function scanForUsedIcons() {
const usedIcons = new Set();
const srcDir = path.join(__dirname, '..', 'src');
info('🔍 Scanning codebase for LocalIcon usage...');
if (!fs.existsSync(srcDir)) {
console.error('❌ Source directory not found:', srcDir);
process.exit(1);
}
// Recursively scan all .tsx and .ts files
function scanDirectory(dir) {
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
scanDirectory(filePath);
} else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
const content = fs.readFileSync(filePath, 'utf8');
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
if (localIconMatches) {
localIconMatches.forEach(match => {
const iconMatch = match.match(/icon="([^"]+)"/);
if (iconMatch) {
usedIcons.add(iconMatch[1]);
debug(` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
}
});
}
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
if (spanMatches) {
spanMatches.forEach(match => {
const iconMatch = match.match(/>([^<]+)<\/span>/);
if (iconMatch && iconMatch[1].trim()) {
const iconName = iconMatch[1].trim();
usedIcons.add(iconName);
debug(` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`);
}
});
}
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
if (iconMatches) {
iconMatches.forEach(match => {
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
if (iconMatch) {
usedIcons.add(iconMatch[1]);
debug(` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
}
});
}
}
});
}
scanDirectory(srcDir);
const iconArray = Array.from(usedIcons).sort();
info(`📋 Found ${iconArray.length} unique icons across codebase`);
return iconArray;
}
// Auto-detect used icons
const usedIcons = scanForUsedIcons();
// Check if we need to regenerate (compare with existing)
const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json');
let needsRegeneration = true;
if (fs.existsSync(outputPath)) {
try {
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
const existingIcons = Object.keys(existingSet.icons || {}).sort();
const currentIcons = [...usedIcons].sort();
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
}
} catch (error) {
// If we can't parse existing file, regenerate
needsRegeneration = true;
}
}
if (!needsRegeneration) {
info('🎉 No regeneration needed!');
process.exit(0);
}
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
// Extract only our used icons from the full set
const extractedIcons = getIcons(icons, usedIcons);
if (!extractedIcons) {
console.error('❌ Failed to extract icons');
process.exit(1);
}
// Check for missing icons
const extractedIconNames = Object.keys(extractedIcons.icons || {});
const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon));
if (missingIcons.length > 0) {
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
}
// Create output directory
const outputDir = path.join(__dirname, '..', 'src', 'assets');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write the extracted icon set to a file (outputPath already defined above)
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`);
info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`);
info(`💾 Saved to: ${outputPath}`);
// Generate TypeScript types
const typesContent = `// Auto-generated icon types
// This file is automatically generated by scripts/generate-icons.js
// Do not edit manually - changes will be overwritten
export type MaterialSymbolIcon = ${usedIcons.map(icon => `'${icon}'`).join(' | ')};
export interface IconSet {
prefix: string;
icons: Record<string, any>;
width?: number;
height?: number;
}
// Re-export the icon set as the default export with proper typing
declare const iconSet: IconSet;
export default iconSet;
`;
const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts');
fs.writeFileSync(typesPath, typesContent);
info(`📝 Generated types: ${typesPath}`);
info(`🎉 Icon extraction complete!`);

View File

@ -1,24 +1,30 @@
import React, { Suspense } from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import { FileContextProvider } from './contexts/FileContext';
import { NavigationProvider } from './contexts/NavigationContext';
import { FilesModalProvider } from './contexts/FilesModalContext';
import HomePage from './pages/HomePage';
import React, { Suspense } from "react";
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
import { FileContextProvider } from "./contexts/FileContext";
import { NavigationProvider } from "./contexts/NavigationContext";
import { FilesModalProvider } from "./contexts/FilesModalContext";
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
// Import global styles
import './styles/tailwind.css';
import './index.css';
import "./styles/tailwind.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
// Loading component for i18next suspense
const LoadingFallback = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
fontSize: '18px',
color: '#666'
}}>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
fontSize: "18px",
color: "#666",
}}
>
Loading...
</div>
);
@ -27,13 +33,21 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<HomePage />
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
<ErrorBoundary>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<SidebarProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SidebarProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</Suspense>
);

View File

@ -21,6 +21,13 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
"moduleUrl": "https://github.com/atlassian/pragmatic-drag-and-drop",
"moduleVersion": "1.7.4",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "@babel/code-frame",
"moduleUrl": "https://github.com/babel/babel",
@ -59,7 +66,7 @@
{
"moduleName": "@babel/parser",
"moduleUrl": "https://github.com/babel/babel",
"moduleVersion": "7.27.3",
"moduleVersion": "7.28.3",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@ -87,7 +94,7 @@
{
"moduleName": "@babel/types",
"moduleUrl": "https://github.com/babel/babel",
"moduleVersion": "7.27.3",
"moduleVersion": "7.28.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@ -217,6 +224,20 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@iconify/react",
"moduleUrl": "https://github.com/iconify/iconify",
"moduleVersion": "6.0.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@iconify/types",
"moduleUrl": "https://github.com/iconify/iconify",
"moduleVersion": "2.0.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@isaacs/fs-minipass",
"moduleUrl": "https://github.com/npm/fs-minipass",
@ -399,6 +420,20 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tanstack/react-virtual",
"moduleUrl": "https://github.com/TanStack/virtual",
"moduleVersion": "3.13.12",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tanstack/virtual-core",
"moduleUrl": "https://github.com/TanStack/virtual",
"moduleVersion": "3.13.12",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@testing-library/dom",
"moduleUrl": "https://github.com/testing-library/dom-testing-library",
@ -567,6 +602,13 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "bind-event-listener",
"moduleUrl": "https://github.com/alexreardon/bind-event-listener",
"moduleVersion": "3.0.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "brace-expansion",
"moduleUrl": "https://github.com/juliangruber/brace-expansion",
@ -1246,13 +1288,6 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "material-symbols",
"moduleUrl": "https://github.com/marella/material-symbols",
"moduleVersion": "0.33.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "math-intrinsics",
"moduleUrl": "https://github.com/es-shims/math-intrinsics",
@ -1494,7 +1529,7 @@
{
"moduleName": "postcss",
"moduleUrl": "https://github.com/postcss/postcss",
"moduleVersion": "8.5.3",
"moduleVersion": "8.5.6",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@ -1526,6 +1561,13 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "raf-schd",
"moduleUrl": "https://github.com/alexreardon/raf-schd",
"moduleVersion": "4.0.3",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "react-dom",
"moduleUrl": "https://github.com/facebook/react",

View File

@ -111,7 +111,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onClose={closeFilesModal}
size={isMobile ? "100%" : "auto"}
centered
radius={30}
radius="md"
className="overflow-hidden p-0"
withCloseButton={false}
styles={{
@ -144,7 +144,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
height: '100%',
width: '100%',
border: 'none',
borderRadius: '30px',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-file-manager)'
}}
styles={{

View File

@ -1,7 +1,6 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
@ -466,21 +465,6 @@ const FileEditor = ({
<LoadingOverlay visible={false} />
<Box p="md" pt="xl">
<Group mb="md">
{toolMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
{showBulkActions && !toolMode && (
<>
<Button onClick={closeAllFiles} variant="light" color="orange">
Close All
</Button>
</>
)}
</Group>
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
@ -573,25 +557,29 @@ const FileEditor = ({
/>
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
<Portal>
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10001 }}
>
{status}
</Notification>
</Portal>
)}
{error && (
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
>
{error}
</Notification>
<Portal>
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
>
{error}
</Notification>
</Portal>
)}
</Box>
</Dropzone>

View File

@ -17,7 +17,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
return (
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
<Box bg="blue.6" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
<Text size="sm" fw={500} ta="center" c="white">
{t('fileManager.details', 'File Details')}
</Text>
@ -31,7 +31,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileFormat', 'Format')}</Text>
{currentFile ? (
@ -43,7 +43,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
)}
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileSize', 'Size')}</Text>
<Text size="sm" fw={500}>
@ -51,7 +51,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
<Text size="sm" fw={500}>
@ -64,4 +64,4 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
);
};
export default FileInfoCard;
export default FileInfoCard;

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Stack, Text, Button, Group } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import FolderIcon from '@mui/icons-material/Folder';
import UploadIcon from '@mui/icons-material/Upload';
import CloudIcon from '@mui/icons-material/Cloud';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
@ -10,7 +10,7 @@ interface FileSourceButtonsProps {
horizontal?: boolean;
}
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
@ -44,11 +44,11 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
>
{horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')}
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
leftSection={<FolderIcon />}
leftSection={<UploadIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={onLocalFileClick}
fullWidth={!horizontal}
@ -63,9 +63,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
}
}}
>
{horizontal ? t('fileManager.localFiles', 'Local') : t('fileManager.localFiles', 'Local Files')}
{horizontal ? t('fileUpload.uploadFiles', 'Upload') : t('fileUpload.uploadFiles', 'Upload Files')}
</Button>
<Button
variant={buttonProps.variant('drive')}
leftSection={<CloudIcon />}
@ -100,4 +100,4 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
);
};
export default FileSourceButtons;
export default FileSourceButtons;

View File

@ -162,6 +162,7 @@ export default function Workbench() {
className="flex-1 min-h-0 relative z-10"
style={{
transition: 'opacity 0.15s ease-in-out',
marginTop: '1rem',
}}
>
{renderMainContent()}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
import { Group, TextInput, Button, Text } from '@mantine/core';
interface BulkSelectionPanelProps {
csvInput: string;
@ -15,7 +15,7 @@ const BulkSelectionPanel = ({
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
return (
<Paper p="md" mb="md" withBorder>
<>
<Group>
<TextInput
value={csvInput}
@ -35,7 +35,7 @@ const BulkSelectionPanel = ({
Selected: {selectedPages.length} pages
</Text>
)}
</Paper>
</>
);
};

View File

@ -141,7 +141,6 @@ const FileThumbnail = ({
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
>
{selectionMode && (
<div
className={styles.checkboxContainer}
data-testid="file-thumbnail-checkbox"
@ -175,7 +174,6 @@ const FileThumbnail = ({
size="sm"
/>
</div>
)}
{/* File content area */}
<div className="file-container w-[90%] h-[80%] relative">

View File

@ -1,8 +1,8 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
import {
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
Button, Text, Center, Box,
Notification, TextInput, LoadingOverlay, Modal, Alert,
Stack, Group
Stack, Group, Portal
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
@ -62,20 +62,19 @@ export interface PageEditorProps {
const PageEditor = ({
onFunctionsReady,
}: PageEditorProps) => {
const { t } = useTranslation();
// Use split contexts to prevent re-renders
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
const selectedFiles = selectors.getSelectedFiles();
// Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
const processingProgress = state.ui.processingProgress;
@ -101,20 +100,20 @@ const PageEditor = ({
setSelectionMode, setSelectedPageNumbers, setMovingPage, setIsAnimating, setSplitPositions, setExportLoading,
togglePage, toggleSelectAll, animateReorder
} = usePageEditorState();
// Grid container ref for positioning split indicators
const gridContainerRef = useRef<HTMLDivElement>(null);
// Undo/Redo state
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
// Update undo/redo state
const updateUndoRedoState = useCallback(() => {
setCanUndo(undoManagerRef.current.canUndo());
setCanRedo(undoManagerRef.current.canRedo());
}, []);
// Set up undo manager callback
useEffect(() => {
undoManagerRef.current.setStateChangeCallback(updateUndoRedoState);
@ -143,12 +142,12 @@ const PageEditor = ({
const createDeleteCommand = useCallback((pageIds: string[]) => ({
execute: () => {
if (!displayDocument) return;
const pagesToDelete = pageIds.map(pageId => {
const page = displayDocument.pages.find(p => p.id === pageId);
return page?.pageNumber || 0;
}).filter(num => num > 0);
if (pagesToDelete.length > 0) {
const deleteCommand = new DeletePagesCommand(
pagesToDelete,
@ -181,8 +180,8 @@ const PageEditor = ({
command.execute();
}
}, []);
const handleUndo = useCallback(() => {
undoManagerRef.current.undo();
}, []);
@ -200,13 +199,13 @@ const PageEditor = ({
return page?.id || '';
}).filter(id => id)
: displayDocument.pages.map(p => p.id);
handleRotatePages(pagesToRotate, rotation);
}, [displayDocument, selectedPageNumbers, selectionMode, handleRotatePages]);
const handleDelete = useCallback(() => {
if (!displayDocument || selectedPageNumbers.length === 0) return;
const deleteCommand = new DeletePagesCommand(
selectedPageNumbers,
() => displayDocument,
@ -221,7 +220,7 @@ const PageEditor = ({
const handleDeletePage = useCallback((pageNumber: number) => {
if (!displayDocument) return;
const deleteCommand = new DeletePagesCommand(
[pageNumber],
() => displayDocument,
@ -236,9 +235,9 @@ const PageEditor = ({
const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageNumbers.length === 0) return;
console.log('Toggle split markers at selected page positions:', selectedPageNumbers);
// Convert page numbers to positions (0-based indices)
const positions: number[] = [];
selectedPageNumbers.forEach(pageNum => {
@ -261,7 +260,7 @@ const PageEditor = ({
const handleSplitAll = useCallback(() => {
if (!displayDocument) return;
const splitAllCommand = new SplitAllCommand(
displayDocument.pages.length,
() => splitPositions,
@ -272,9 +271,9 @@ const PageEditor = ({
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageNumbers.length === 0) return;
console.log('Insert page breaks after selected pages:', selectedPageNumbers);
const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
() => displayDocument,
@ -286,7 +285,7 @@ const PageEditor = ({
const handlePageBreakAll = useCallback(() => {
if (!displayDocument) return;
const pageBreakAllCommand = new BulkPageBreakCommand(
() => displayDocument,
setEditedDocument,
@ -297,7 +296,7 @@ const PageEditor = ({
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => {
if (!displayDocument) return;
const reorderCommand = new ReorderPagesCommand(
sourcePageNumber,
targetIndex,
@ -311,12 +310,12 @@ const PageEditor = ({
// Helper function to collect source files for multi-file export
const getSourceFiles = useCallback((): Map<string, File> | null => {
const sourceFiles = new Map<string, File>();
// Check if we have multiple files by looking at active file IDs
if (activeFileIds.length <= 1) {
return null; // Use single-file export method
}
// Collect all source files
activeFileIds.forEach(fileId => {
const file = selectors.getFile(fileId);
@ -324,7 +323,7 @@ const PageEditor = ({
sourceFiles.set(fileId, file);
}
});
return sourceFiles.size > 0 ? sourceFiles : null;
}, [activeFileIds, selectors]);
@ -332,7 +331,7 @@ const PageEditor = ({
const onExportSelected = useCallback(async () => {
if (!displayDocument || selectedPageNumbers.length === 0) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
@ -342,10 +341,10 @@ const PageEditor = ({
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// For selected pages export, we work with the first document (or single document)
const documentWithDOMState = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
// Step 2: Convert selected page numbers to page IDs from the document with DOM state
const selectedPageIds = selectedPageNumbers.map(pageNum => {
const page = documentWithDOMState.pages.find(p => p.pageNumber === pageNum);
@ -354,9 +353,9 @@ const PageEditor = ({
// Step 3: Export with pdfExportService
console.log('Exporting selected pages:', selectedPageNumbers, 'with DOM rotations applied');
const sourceFiles = getSourceFiles();
const result = sourceFiles
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(
documentWithDOMState,
sourceFiles,
@ -371,7 +370,7 @@ const PageEditor = ({
// Step 4: Download the result
pdfExportService.downloadFile(result.blob, result.filename);
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
@ -381,7 +380,7 @@ const PageEditor = ({
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
@ -391,34 +390,34 @@ const PageEditor = ({
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// Step 2: Check if we have multiple documents (splits) or single document
if (Array.isArray(processedDocuments)) {
// Multiple documents (splits) - export as ZIP
console.log('Exporting multiple split documents:', processedDocuments.length);
const blobs: Blob[] = [];
const filenames: string[] = [];
const sourceFiles = getSourceFiles();
for (const doc of processedDocuments) {
const result = sourceFiles
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: doc.name })
: await pdfExportService.exportPDF(doc, [], { filename: doc.name });
blobs.push(result.blob);
filenames.push(result.filename);
}
// Create ZIP file
const JSZip = await import('jszip');
const zip = new JSZip.default();
blobs.forEach((blob, index) => {
zip.file(filenames[index], blob);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipFilename = displayDocument.name.replace(/\.pdf$/i, '.zip');
pdfExportService.downloadFile(zipBlob, zipFilename);
} else {
// Single document - regular export
@ -439,7 +438,7 @@ const PageEditor = ({
pdfExportService.downloadFile(result.blob, result.filename);
}
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
@ -450,18 +449,18 @@ const PageEditor = ({
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(() => {
if (!displayDocument) return;
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
setEditedDocument(documentToSet);
console.log('Changes applied to document');
}, [displayDocument, mergedPdfDocument, splitPositions]);
@ -473,10 +472,10 @@ const PageEditor = ({
setSelectionMode(false);
}, [actions]);
// Export preview function - defined after export functions to avoid circular dependency
// Export preview function - defined after export functions to avoid circular dependency
const handleExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!displayDocument) return;
// For now, trigger the actual export directly
// In the original, this would show a preview modal first
if (selectedOnly) {
@ -514,7 +513,7 @@ const PageEditor = ({
}
}, [
onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, handleSplitAll,
handlePageBreak, handlePageBreakAll, handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading,
handlePageBreak, handlePageBreakAll, handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading,
selectionMode, selectedPageNumbers, splitPositions, displayDocument?.pages.length, closePdf
]);
@ -552,8 +551,8 @@ const PageEditor = ({
style={{ minWidth: 300 }}
/>
<Group>
<Button
variant={selectionMode ? "filled" : "outline"}
<Button
variant={selectionMode ? "filled" : "outline"}
onClick={() => setSelectionMode(!selectionMode)}
>
{selectionMode ? "Exit Selection" : "Select Pages"}
@ -573,7 +572,7 @@ const PageEditor = ({
{/* Split Lines Overlay */}
<div
<div
style={{
position: 'absolute',
top: 0,
@ -590,14 +589,14 @@ const PageEditor = ({
const ITEM_HEIGHT = 340; // 20rem + gap
const ITEM_GAP = 24; // 1.5rem
const ITEMS_PER_ROW = 4; // Default, could be dynamic
const row = Math.floor(position / ITEMS_PER_ROW);
const col = position % ITEMS_PER_ROW;
// Position after the current item
const leftPosition = (col + 1) * (ITEM_WIDTH + ITEM_GAP) - ITEM_GAP / 2;
const topPosition = row * ITEM_HEIGHT + 100; // Offset for header controls
return (
<div
key={`split-${position}`}
@ -659,4 +658,4 @@ const PageEditor = ({
);
};
export default PageEditor;
export default PageEditor;

View File

@ -2,17 +2,16 @@ import React from "react";
import {
Tooltip,
ActionIcon,
Paper
} from "@mantine/core";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import DownloadIcon from "@mui/icons-material/Download";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
import DownloadIcon from "@mui/icons-material/Download";
interface PageEditorControlsProps {
// Close/Reset functions
@ -98,9 +97,9 @@ const PageEditorControls = ({
<div
style={{
position: 'absolute',
left: '50%',
bottom: '20px',
transform: 'translateX(-50%)',
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: 'flex',
justifyContent: 'center',
@ -108,34 +107,28 @@ const PageEditorControls = ({
background: 'transparent',
}}
>
<Paper
radius="xl"
shadow="lg"
p={16}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
borderRadius: 32,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '16px 16px 0 0',
pointerEvents: 'auto',
minWidth: 400,
justifyContent: 'center'
minWidth: 420,
maxWidth: 700,
flexWrap: 'wrap',
justifyContent: 'center',
padding: "1rem",
paddingBottom: "2rem"
}}
>
{/* Close PDF */}
<Tooltip label="Close PDF">
<ActionIcon
onClick={onClosePdf}
color="red"
variant="light"
size="lg"
>
<CloseIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Undo/Redo */}
<Tooltip label="Undo">
@ -210,8 +203,6 @@ const PageEditorControls = ({
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Export Controls */}
{selectionMode && (
<Tooltip label="Export Selected">
@ -237,7 +228,7 @@ const PageEditorControls = ({
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Paper>
</div>
</div>
);
};

View File

@ -290,7 +290,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
{selectionMode && (
{
<div
className={styles.checkboxContainer}
style={{
@ -323,7 +323,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
style={{ pointerEvents: 'none' }}
/>
</div>
)}
}
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div

View File

@ -0,0 +1,56 @@
import React from 'react';
import { Text, Button, Stack } from '@mantine/core';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{error?: Error; retry: () => void}>;
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
retry = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
const Fallback = this.props.fallback;
return <Fallback error={this.state.error} retry={this.retry} />;
}
return (
<Stack align="center" justify="center" style={{ minHeight: '200px', padding: '2rem' }}>
<Text size="lg" fw={500} c="red">Something went wrong</Text>
{process.env.NODE_ENV === 'development' && this.state.error && (
<Text size="sm" c="dimmed" style={{ textAlign: 'center', fontFamily: 'monospace' }}>
{this.state.error.message}
</Text>
)}
<Button onClick={this.retry} variant="light">
Try Again
</Button>
</Stack>
);
}
return this.props.children;
}
}

View File

@ -1,5 +1,6 @@
import React from 'react';
import { Box } from '@mantine/core';
import { Box, Center } from '@mantine/core';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import { FileMetadata } from '../../types/file';
import DocumentThumbnail from './filePreview/DocumentThumbnail';
import DocumentStack from './filePreview/DocumentStack';
@ -38,7 +39,21 @@ const FilePreview: React.FC<FilePreviewProps> = ({
onPrevious,
onNext
}) => {
if (!file) return null;
if (!file) {
return (
<Box style={{ width: '100%', height: '100%' }}>
<Center style={{ width: '100%', height: '100%' }}>
<InsertDriveFileIcon
style={{
fontSize: '4rem',
color: 'var(--mantine-color-gray-4)',
opacity: 0.6
}}
/>
</Center>
</Box>
);
}
const hasMultipleFiles = totalFiles > 1;

View File

@ -1,21 +1,28 @@
import React from 'react';
import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import AddIcon from '@mui/icons-material/Add';
import LocalIcon from './LocalIcon';
import { useTranslation } from 'react-i18next';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
const LandingPage = () => {
const { addMultipleFiles } = useFileHandler();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { colorScheme } = useMantineColorScheme();
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false);
const handleFileDrop = async (files: File[]) => {
await addMultipleFiles(files);
};
const handleAddFilesClick = () => {
const handleOpenFilesModal = () => {
openFilesModal();
};
const handleNativeUploadClick = () => {
fileInputRef.current?.click();
};
@ -44,7 +51,7 @@ const LandingPage = () => {
borderRadius: '0.5rem 0.5rem 0 0',
filter: 'var(--drop-shadow-filter)',
backgroundColor: 'var(--landing-paper-bg)',
transition: 'background-color 0.2s ease',
transition: 'background-color 0.4s ease',
}}
activateOnClick={false}
styles={{
@ -99,26 +106,73 @@ const LandingPage = () => {
/>
</Group>
{/* Add Files Button */}
<Button
{/* Add Files + Native Upload Buttons */}
<div
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
width: '80%',
marginTop: '0.8rem',
marginBottom: '0.8rem',
marginBottom: '0.8rem'
}}
onClick={handleAddFilesClick}
onMouseLeave={() => setIsUploadHover(false)}
>
<AddIcon className="text-[var(--accent-interactive)]" />
<span>
{t('fileUpload.uploadFiles', 'Upload Files')}
</span>
</Button>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</div>
{/* Hidden file input for native file picker */}
<input

View File

@ -1,11 +1,17 @@
import React, { useState, useEffect } from 'react';
import { Menu, Button, ScrollArea } from '@mantine/core';
import { Menu, Button, ScrollArea, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n';
import LanguageIcon from '@mui/icons-material/Language';
import LocalIcon from './LocalIcon';
import styles from './LanguageSelector.module.css';
const LanguageSelector = () => {
interface LanguageSelectorProps {
position?: React.ComponentProps<typeof Menu>['position'];
offset?: number;
compact?: boolean; // icon-only trigger
}
const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = false }: LanguageSelectorProps) => {
const { i18n } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
@ -21,26 +27,27 @@ const LanguageSelector = () => {
}));
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
// Create ripple effect at click position
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
setRippleEffect({ x, y, key: Date.now() });
// Create ripple effect at click position (only for button mode)
if (!compact) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
setRippleEffect({ x, y, key: Date.now() });
}
// Start transition animation
setIsChanging(true);
setPendingLanguage(value);
// Simulate processing time for smooth transition
setTimeout(() => {
i18n.changeLanguage(value);
setTimeout(() => {
setIsChanging(false);
setPendingLanguage(null);
setOpened(false);
// Clear ripple effect
setTimeout(() => setRippleEffect(null), 100);
}, 300);
@ -64,19 +71,9 @@ const LanguageSelector = () => {
<style>
{`
@keyframes ripple-expand {
0% {
width: 0;
height: 0;
opacity: 0.6;
}
50% {
opacity: 0.3;
}
100% {
width: 100px;
height: 100px;
opacity: 0;
}
0% { width: 0; height: 0; opacity: 0.6; }
50% { opacity: 0.3; }
100% { width: 100px; height: 100px; opacity: 0; }
}
`}
</style>
@ -84,8 +81,8 @@ const LanguageSelector = () => {
opened={opened}
onChange={setOpened}
width={600}
position="bottom-start"
offset={8}
position={position}
offset={offset}
transitionProps={{
transition: 'scale-y',
duration: 200,
@ -93,29 +90,45 @@ const LanguageSelector = () => {
}}
>
<Menu.Target>
<Button
variant="subtle"
size="sm"
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
styles={{
root: {
border: 'none',
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
{compact ? (
<ActionIcon
variant="subtle"
radius="md"
title={currentLanguage}
className="right-rail-icon"
styles={{
root: {
color: 'var(--right-rail-icon)',
'&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
}
}
},
label: {
fontSize: '12px',
fontWeight: 500,
}
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
</Button>
}}
>
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
</ActionIcon>
) : (
<Button
variant="subtle"
size="sm"
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
styles={{
root: {
border: 'none',
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
}
},
label: { fontSize: '12px', fontWeight: 500 }
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
</Button>
)}
</Menu.Target>
<Menu.Dropdown
@ -181,9 +194,7 @@ const LanguageSelector = () => {
}}
>
{option.label}
{/* Ripple effect */}
{rippleEffect && pendingLanguage === option.value && (
{!compact && rippleEffect && pendingLanguage === option.value && (
<div
key={rippleEffect.key}
style={{

View File

@ -0,0 +1,52 @@
import React from 'react';
import { addCollection, Icon } from '@iconify/react';
import iconSet from '../../assets/material-symbols-icons.json';
// Load icons synchronously at import time - guaranteed to be ready on first render
let iconsLoaded = false;
let localIconCount = 0;
try {
if (iconSet) {
addCollection(iconSet);
iconsLoaded = true;
localIconCount = Object.keys(iconSet.icons || {}).length;
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
}
} catch (error) {
console.info(' Local icons not available - using CDN fallback');
}
interface LocalIconProps {
icon: string;
width?: string | number;
height?: string | number;
style?: React.CSSProperties;
className?: string;
}
/**
* LocalIcon component that uses our locally bundled Material Symbols icons
* instead of loading from CDN
*/
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
// Convert our icon naming convention to the local collection format
const iconName = icon.startsWith('material-symbols:')
? icon
: `material-symbols:${icon}`;
// Development logging (only in dev mode)
if (process.env.NODE_ENV === 'development') {
const logKey = `icon-${iconName}`;
if (!sessionStorage.getItem(logKey)) {
const source = iconsLoaded ? 'local' : 'CDN';
console.debug(`🎯 Icon: ${iconName} (${source})`);
sessionStorage.setItem(logKey, 'logged');
}
}
// Always render the icon - Iconify will use local if available, CDN if not
return <Icon icon={iconName} {...props} />;
};
export default LocalIcon;

View File

@ -1,9 +1,7 @@
import React, { useState, useRef, forwardRef, useEffect } from "react";
import { ActionIcon, Stack, Divider } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
import SettingsIcon from "@mui/icons-material/SettingsRounded";
import FolderIcon from "@mui/icons-material/FolderRounded";
import LocalIcon from './LocalIcon';
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import AppConfigModal from './AppConfigModal';
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
@ -13,9 +11,9 @@ import { ButtonConfig } from '../../types/sidebar';
import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
import {
isNavButtonActive,
getNavButtonStyle,
import {
isNavButtonActive,
getNavButtonStyle,
getActiveNavButton,
} from './quickAccessBar/QuickAccessBar';
@ -39,12 +37,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
openFilesModal();
};
const buttonConfigs: ButtonConfig[] = [
{
id: 'read',
name: t("quickAccess.read", "Read"),
icon: <MenuBookIcon sx={{ fontSize: "1.5rem" }} />,
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
@ -54,28 +52,23 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
handleReaderToggle();
}
},
{
id: 'sign',
name: t("quickAccess.sign", "Sign"),
icon:
<span className="material-symbols-rounded font-size-20">
signature
</span>,
size: 'lg',
isRound: false,
type: 'navigation',
onClick: () => {
setActiveButton('sign');
handleToolSelect('sign');
}
},
// TODO: Add sign
//{
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
// size: 'lg',
// isRound: false,
// type: 'navigation',
// onClick: () => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
//},
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
icon:
<span className="material-symbols-rounded font-size-20">
automation
</span>,
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
@ -87,28 +80,26 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
{
id: 'files',
name: t("quickAccess.files", "Files"),
icon: <FolderIcon sx={{ fontSize: "1.25rem" }} />,
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'lg',
type: 'modal',
onClick: handleFilesButtonClick
},
{
id: 'activity',
name: t("quickAccess.activity", "Activity"),
icon:
<span className="material-symbols-rounded font-size-20">
vital_signs
</span>,
isRound: true,
size: 'lg',
type: 'navigation',
onClick: () => setActiveButton('activity')
},
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
{
id: 'config',
name: t("quickAccess.config", "Config"),
icon: <SettingsIcon sx={{ fontSize: "1rem" }} />,
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
type: 'modal',
onClick: () => {
@ -179,8 +170,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
</div>
{/* Add divider after Automate button (index 2) */}
{index === 2 && (
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
{index === 1 && (
<Divider
size="xs"
className="content-divider"
@ -226,4 +217,4 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
);
});
export default QuickAccessBar;
export default QuickAccessBar;

View File

@ -0,0 +1,377 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { ActionIcon, Divider, Popover } from '@mantine/core';
import LocalIcon from './LocalIcon';
import './rightRail/RightRail.css';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useRightRail } from '../../contexts/RightRailContext';
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import LanguageSelector from '../shared/LanguageSelector';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
export default function RightRail() {
const { t } = useTranslation();
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions } = useRightRail();
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
// Access PageEditor functions for page-editor-specific actions
const { pageEditorFunctions } = useToolWorkflow();
// CSV input state for page selection
const [csvInput, setCsvInput] = useState<string>("");
// Navigation view
const { currentMode: currentView } = useNavigationState();
// File state and selection
const { state, selectors } = useFileState();
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
const { removeFiles } = useFileManagement();
const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
const fileRecords = selectors.getFileRecords();
// Compute selection state and total items
const getSelectionState = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
const totalItems = activeFiles.length;
const selectedCount = selectedFileIds.length;
return { totalItems, selectedCount };
}
if (currentView === 'pageEditor') {
let totalItems = 0;
fileRecords.forEach(rec => {
const pf = rec.processedFile;
if (pf) {
totalItems += (pf.totalPages as number) || (pf.pages?.length || 0);
}
});
const selectedCount = Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length : 0;
return { totalItems, selectedCount };
}
return { totalItems: 0, selectedCount: 0 };
}, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]);
const { totalItems, selectedCount } = getSelectionState();
const handleSelectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Select all file IDs
const allIds = state.files.ids;
setSelectedFiles(allIds);
return;
}
if (currentView === 'pageEditor') {
let totalPages = 0;
fileRecords.forEach(rec => {
const pf = rec.processedFile;
if (pf) {
totalPages += (pf.totalPages as number) || (pf.pages?.length || 0);
}
});
if (totalPages > 0) {
setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
}
}
}, [currentView, state.files.ids, fileRecords, setSelectedFiles, setSelectedPages]);
const handleDeselectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
setSelectedFiles([]);
return;
}
if (currentView === 'pageEditor') {
setSelectedPages([]);
}
}, [currentView, setSelectedFiles, setSelectedPages]);
const handleExportAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => {
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
});
} else if (currentView === 'pageEditor') {
// Export all pages (not just selected)
pageEditorFunctions?.onExportAll?.();
}
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
const handleCloseSelected = useCallback(() => {
if (currentView !== 'fileEditor') return;
if (selectedFileIds.length === 0) return;
// Close only selected files (do not delete from storage)
removeFiles(selectedFileIds, false);
// Clear selection after closing
setSelectedFiles([]);
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
// CSV parsing functions for page selection
const parseCSVInput = useCallback((csv: string) => {
const pageNumbers: number[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
for (let i = start; i <= end; i++) {
if (i > 0) {
pageNumbers.push(i);
}
}
} else {
const pageNum = parseInt(range);
if (pageNum > 0) {
pageNumbers.push(pageNum);
}
}
});
return pageNumbers;
}, []);
const updatePagesFromCSV = useCallback(() => {
const rawPages = parseCSVInput(csvInput);
// Determine max page count from processed records
const maxPages = fileRecords.reduce((sum, rec) => {
const pf = rec.processedFile;
if (!pf) return sum;
return sum + ((pf.totalPages as number) || (pf.pages?.length || 0));
}, 0);
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
setSelectedPages(normalized);
}, [csvInput, parseCSVInput, fileRecords, setSelectedPages]);
// Sync csvInput with selectedPageNumbers changes
useEffect(() => {
const sortedPageNumbers = Array.isArray(selectedPageNumbers)
? [...selectedPageNumbers].sort((a, b) => a - b)
: [];
const newCsvInput = sortedPageNumbers.join(', ');
setCsvInput(newCsvInput);
}, [selectedPageNumbers]);
// Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => {
setCsvInput("");
}, [filesSignature]);
// Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap
const [pageControlsMounted, setPageControlsMounted] = useState<boolean>(currentView === 'pageEditor');
const [pageControlsVisible, setPageControlsVisible] = useState<boolean>(currentView === 'pageEditor');
useEffect(() => {
if (currentView === 'pageEditor') {
// Mount and show
setPageControlsMounted(true);
// Next tick to ensure transition applies
requestAnimationFrame(() => setPageControlsVisible(true));
} else {
// Start exit animation
setPageControlsVisible(false);
// After transition, unmount to remove flex gap
const timer = setTimeout(() => setPageControlsMounted(false), 240);
return () => clearTimeout(timer);
}
}, [currentView]);
return (
<div className="right-rail">
<div className="right-rail-inner">
{topButtons.length > 0 && (
<>
<div className="right-rail-section">
{topButtons.map(btn => (
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => actions[btn.id]?.()}
disabled={btn.disabled}
>
{btn.icon}
</ActionIcon>
</Tooltip>
))}
</div>
<Divider className="right-rail-divider" />
</>
)}
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
<div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView === 'viewer'}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Select All Button */}
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleSelectAll}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
>
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
{/* Deselect All Button */}
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleDeselectAll}
disabled={currentView === 'viewer' || selectedCount === 0}
>
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
{/* Select by Numbers - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={!pageControlsVisible || totalItems === 0}
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: 280 }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={Array.isArray(selectedPageNumbers) ? selectedPageNumbers : []}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
</div>
</Popover.Dropdown>
</Popover>
</div>
</Tooltip>
)}
{/* Delete Selected Pages - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.handleDelete?.(); setSelectedPages([]); }}
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)}
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
>
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</div>
</Tooltip>
)}
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={currentView === 'pageEditor' ? () => pageEditorFunctions?.closePdf?.() : handleCloseSelected}
disabled={
currentView === 'viewer' ||
(currentView === 'fileEditor' && selectedCount === 0) ||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
}
>
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
</div>
<Divider className="right-rail-divider" />
</div>
{/* Theme toggle and Language dropdown */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={toggleTheme}
>
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
<LanguageSelector position="left-start" offset={6} compact />
<Tooltip content={
currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
</div>
<div className="right-rail-spacer" />
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import React, { forwardRef } from 'react';
import { useMantineColorScheme } from '@mantine/core';
import LocalIcon from './LocalIcon';
import styles from './textInput/TextInput.module.css';
/**
@ -96,7 +97,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
aria-label="Clear input"
>
<span className="material-symbols-rounded">close</span>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
</div>

View File

@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import LocalIcon from './LocalIcon';
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipTip } from '../../types/tips';
@ -124,8 +125,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
if (sidebarTooltip) return null;
switch (position) {
case 'top': return "tooltip-arrow tooltip-arrow-top";
case 'bottom': return "tooltip-arrow tooltip-arrow-bottom";
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
case 'left': return "tooltip-arrow tooltip-arrow-left";
case 'right': return "tooltip-arrow tooltip-arrow-right";
default: return "tooltip-arrow tooltip-arrow-right";
@ -150,7 +151,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
position: 'fixed',
top: coords.top,
left: coords.left,
width: (maxWidth !== undefined ? maxWidth : '25rem'),
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
minWidth: minWidth,
zIndex: 9999,
visibility: positionReady ? 'visible' : 'hidden',
@ -171,9 +172,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
}}
title="Close tooltip"
>
<span className="material-symbols-rounded">
close
</span>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
{arrow && getArrowClass() && (

View File

@ -1,23 +1,64 @@
import React, { useState, useCallback, useMemo } from "react";
import { Button, SegmentedControl, Loader } from "@mantine/core";
import React, { useState, useCallback } from "react";
import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector";
import rainbowStyles from '../../styles/rainbow.module.css';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
import { ModeType } from '../../contexts/NavigationContext';
import { ModeType, isValidMode } from '../../contexts/NavigationContext';
// Stable view option objects that don't recreate on every render
const VIEW_OPTIONS_BASE = [
{ value: "viewer", icon: VisibilityIcon },
{ value: "pageEditor", icon: EditNoteIcon },
{ value: "fileEditor", icon: FolderIcon },
] as const;
const viewOptionStyle = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
paddingTop: '0.3rem',
}
// Create view options with icons and loading states
const createViewOptions = (switchingTo: ModeType | null) => [
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
<span>Read</span>
</div>
),
value: "viewer",
},
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
)}
<span>Page Editor</span>
</div>
),
value: "pageEditor",
},
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" />
)}
<span>File Manager</span>
</div>
),
value: "fileEditor",
},
];
interface TopControlsProps {
currentView: ModeType;
@ -30,90 +71,60 @@ const TopControls = ({
setCurrentView,
selectedToolKey,
}: TopControlsProps) => {
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<ModeType | null>(null);
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
// Guard against redundant changes
if (view === currentView) return;
if (!isValidMode(view)) {
// Ignore invalid values defensively
return;
}
const mode = view as ModeType;
// Show immediate feedback
setSwitchingTo(view);
setSwitchingTo(mode as ModeType);
// Defer the heavy view change to next frame so spinner can render
requestAnimationFrame(() => {
// Give the spinner one more frame to show
requestAnimationFrame(() => {
setCurrentView(view as ModeType);
setCurrentView(mode as ModeType);
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
});
});
}, [setCurrentView, currentView]);
// Memoize the SegmentedControl data with stable references
const viewOptions = useMemo(() =>
VIEW_OPTIONS_BASE.map(option => ({
value: option.value,
label: (
<Group gap={option.value === "viewer" ? 5 : 4}>
{switchingTo === option.value ? (
<Loader size="xs" />
) : (
<option.icon fontSize="small" />
)}
</Group>
)
})), [switchingTo]);
const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
if (themeMode === "dark") return <LightModeIcon />;
return <DarkModeIcon />;
};
}, [setCurrentView]);
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className={`absolute left-4 pointer-events-auto flex gap-2 items-center ${
isToolSelected ? 'top-4' : 'top-1/2 -translate-y-1/2'
}`}>
<Button
onClick={toggleTheme}
variant="subtle"
size="md"
aria-label="Toggle theme"
disabled={isToggleDisabled}
className={isRainbowMode ? rainbowStyles.rainbowButton : ''}
title={
isToggleDisabled
? "Button disabled for 3 seconds..."
: isRainbowMode
? "Rainbow Mode Active! Click to exit"
: "Toggle theme (click rapidly 6 times for a surprise!)"
}
style={isToggleDisabled ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{getThemeIcon()}
</Button>
<LanguageSelector />
</div>
{!isToolSelected && (
<div className="flex justify-center items-center h-full pointer-events-auto">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={viewOptions}
data={createViewOptions(switchingTo)}
value={currentView}
onChange={handleViewChange}
color="blue"
radius="xl"
size="md"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
style={{
transition: 'all 0.2s ease',
opacity: switchingTo ? 0.8 : 1,
pointerEvents: 'auto'
}}
styles={{
root: {
borderRadius: 9999,
},
control: {
borderRadius: 9999,
},
indicator: {
borderRadius: 9999,
},
}}
/>
</div>

View File

@ -0,0 +1,108 @@
# RightRail Component
A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools.
## Structure
- **Top Section**: Dynamic buttons from tools (empty when none)
- **Middle Section**: Grid, Cut, Undo, Redo
- **Bottom Section**: Save, Print, Share
## Usage
### For Tools (Recommended)
```tsx
import { useRightRailButtons } from '../hooks/useRightRailButtons';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
function MyTool() {
const handleAction = useCallback(() => {
// Your action here
}, []);
useRightRailButtons([
{
id: 'my-action',
icon: <PlayArrowIcon />,
tooltip: 'Execute Action',
onClick: handleAction,
},
]);
return <div>My Tool</div>;
}
```
### Multiple Buttons
```tsx
useRightRailButtons([
{
id: 'primary',
icon: <StarIcon />,
tooltip: 'Primary Action',
order: 1,
onClick: handlePrimary,
},
{
id: 'secondary',
icon: <SettingsIcon />,
tooltip: 'Secondary Action',
order: 2,
onClick: handleSecondary,
},
]);
```
### Conditional Buttons
```tsx
useRightRailButtons([
// Always show
{
id: 'process',
icon: <PlayArrowIcon />,
tooltip: 'Process',
disabled: isProcessing,
onClick: handleProcess,
},
// Only show when condition met
...(hasResults ? [{
id: 'export',
icon: <DownloadIcon />,
tooltip: 'Export',
onClick: handleExport,
}] : []),
]);
```
## API
### Button Config
```typescript
interface RightRailButtonWithAction {
id: string; // Unique identifier
icon: React.ReactNode; // Icon component
tooltip: string; // Hover tooltip
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
order?: number; // Sort order (default: 0)
disabled?: boolean; // Disabled state (default: false)
visible?: boolean; // Visibility (default: true)
onClick: () => void; // Click handler
}
```
## Built-in Features
- **Undo/Redo**: Automatically integrates with Page Editor
- **Theme Support**: Light/dark mode with CSS variables
- **Auto Cleanup**: Buttons unregister when tool unmounts
## Best Practices
- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'`
- Choose appropriate Material-UI icons
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
- Use `useCallback` for click handlers to prevent re-registration

View File

@ -0,0 +1,127 @@
.right-rail {
background-color: var(--right-rail-bg);
width: 3.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
height: 100vh;
border-left: 1px solid var(--border-subtle);
}
.right-rail-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0.5rem;
}
.right-rail-section {
background-color: var(--right-rail-foreground);
border-radius: 12px;
padding: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.right-rail-divider {
width: 2.75rem;
border: none;
border-top: 1px solid var(--tool-subcategory-rule-color);
margin: 0.25rem 0;
}
.right-rail-icon {
color: var(--right-rail-icon);
}
.right-rail-icon[aria-disabled="true"],
.right-rail-icon[disabled] {
color: var(--right-rail-icon-disabled) !important;
background-color: transparent !important;
}
.right-rail-spacer {
flex: 1;
}
/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */
.right-rail-slot {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 450ms ease-out, opacity 300ms ease-out;
}
.right-rail-enter {
animation: rightRailGrowDown 450ms ease-out;
}
.right-rail-exit {
animation: rightRailShrinkUp 450ms ease-out;
}
.right-rail-slot.visible {
max-height: 18rem; /* increased to fit additional controls + divider */
opacity: 1;
}
@keyframes rightRailGrowDown {
0% {
max-height: 0;
opacity: 0;
}
100% {
max-height: 18rem;
opacity: 1;
}
}
@keyframes rightRailShrinkUp {
0% {
max-height: 18rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
/* Remove bottom margin from close icon */
.right-rail-slot .right-rail-icon {
margin-bottom: 0;
}
/* Inline appear/disappear animation for page-number selector button */
.right-rail-fade {
transition-property: opacity, transform, max-height, visibility;
transition-duration: 220ms, 220ms, 220ms, 0s;
transition-timing-function: ease, ease, ease, linear;
transition-delay: 0s, 0s, 0s, 0s;
transform-origin: top center;
overflow: hidden;
}
.right-rail-fade.enter {
opacity: 1;
transform: scale(1);
max-height: 3rem;
visibility: visible;
}
.right-rail-fade.exit {
opacity: 0;
transform: scale(0.85);
max-height: 0;
visibility: hidden;
/* delay visibility change so opacity/max-height can finish */
transition-delay: 0s, 0s, 0s, 220ms;
pointer-events: none;
}

View File

@ -160,7 +160,7 @@
.tooltip-arrow-top {
top: -0.25rem;
left: 50%;
transform: translateX(-50%) rotate(45deg);
transform: translateX(-50%) rotate(-135deg);
border-top: none;
border-left: none;
}

View File

@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { Box, Stack, Text } from '@mantine/core';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
import ToolButton from './toolPicker/ToolButton';
import { useTranslation } from 'react-i18next';
import { useToolSections } from '../../hooks/useToolSections';
@ -23,8 +23,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
return (
<Stack p="sm" gap="xs">
{searchGroups.map(group => (
<Box key={group.subcategory} w="100%">
<SubcategoryHeader label={t(`toolPicker.subcategories.${group.subcategory}`, group.subcategory)} />
<Box key={group.subcategoryId} w="100%">
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
<Stack gap="xs">
{group.tools.map(({ id, tool }) => (
<ToolButton

View File

@ -1,12 +1,11 @@
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
import { Box, Text, Stack } from "@mantine/core";
import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
import ToolButton from "./toolPicker/ToolButton";
import "./toolPicker/ToolPicker.css";
import { useToolSections } from "../../hooks/useToolSections";
import SubcategoryHeader from "./shared/SubcategoryHeader";
import NoToolsFound from "./shared/NoToolsFound";
import { renderToolButtons } from "./shared/renderToolButtons";
interface ToolPickerProps {
selectedToolKey: string | null;
@ -15,31 +14,6 @@ interface ToolPickerProps {
isSearching?: boolean;
}
// Helper function to render tool buttons for a subcategory
const renderToolButtons = (
subcategory: any,
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true
) => (
<Box key={subcategory.subcategory} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={subcategory.subcategory} />
)}
<Stack gap="xs">
{subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
);
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
const { t } = useTranslation();
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
@ -51,29 +25,49 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
const quickAccessRef = useRef<HTMLDivElement>(null);
const allToolsRef = useRef<HTMLDivElement>(null);
// On resize adjust headers height to offset height
// Keep header heights in sync with any dynamic size changes
useLayoutEffect(() => {
const update = () => {
if (quickHeaderRef.current) {
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight);
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
}
if (allHeaderRef.current) {
setAllHeaderHeight(allHeaderRef.current.offsetHeight);
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
}
};
update();
// Update on window resize
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
// Update on element resize (e.g., font load, badge count change, zoom)
const observers: ResizeObserver[] = [];
if (typeof ResizeObserver !== "undefined") {
const observe = (el: HTMLDivElement | null, cb: () => void) => {
if (!el) return;
const ro = new ResizeObserver(() => cb());
ro.observe(el);
observers.push(ro);
};
observe(quickHeaderRef.current, update);
observe(allHeaderRef.current, update);
}
return () => {
window.removeEventListener("resize", update);
observers.forEach(o => o.disconnect());
};
}, []);
const { sections: visibleSections } = useToolSections(filteredTools);
const quickSection = useMemo(
() => visibleSections.find(s => (s as any).key === 'quick'),
() => visibleSections.find(s => s.key === 'quick'),
[visibleSections]
);
const allSection = useMemo(
() => visibleSections.find(s => (s as any).key === 'all'),
() => visibleSections.find(s => s.key === 'all'),
[visibleSections]
);
@ -111,7 +105,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
overflowY: "auto",
overflowX: "hidden",
minHeight: 0,
height: "100%"
height: "100%",
marginTop: -2
}}
className="tool-picker-scrollable"
>
@ -120,7 +115,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
{searchGroups.length === 0 ? (
<NoToolsFound />
) : (
searchGroups.map(group => renderToolButtons(group, selectedToolKey, onSelect))
searchGroups.map(group => renderToolButtons(t, group, selectedToolKey, onSelect))
)}
</Stack>
) : (
@ -135,7 +130,6 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
marginBottom: -1,
padding: "0.5rem 1rem",
fontWeight: 700,
background: "var(--tool-header-bg)",
@ -143,7 +137,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
justifyContent: "space-between",
}}
onClick={() => scrollTo(quickAccessRef)}
>
@ -164,8 +158,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
<Box ref={quickAccessRef} w="100%">
<Stack p="sm" gap="xs">
{quickSection?.subcategories.map(sc =>
renderToolButtons(sc, selectedToolKey, onSelect, false)
{quickSection?.subcategories.map(sc =>
renderToolButtons(t, sc, selectedToolKey, onSelect, false)
)}
</Stack>
</Box>
@ -178,7 +172,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
ref={allHeaderRef}
style={{
position: "sticky",
top: quickSection ? quickHeaderHeight - 1: 0,
top: quickSection ? quickHeaderHeight -1 : 0,
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
@ -210,8 +204,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map(sc =>
renderToolButtons(sc, selectedToolKey, onSelect, true)
{allSection?.subcategories.map(sc =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true)
)}
</Stack>
</Box>

View File

@ -0,0 +1,70 @@
/**
* AddWatermarkSingleStepSettings - Used for automation only
*
* This component combines all watermark settings into a single step interface
* for use in the automation system. It includes type selection and all relevant
* settings in one unified component.
*/
import React from "react";
import { Stack } from "@mantine/core";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import WatermarkTypeSettings from "./WatermarkTypeSettings";
import WatermarkWording from "./WatermarkWording";
import WatermarkTextStyle from "./WatermarkTextStyle";
import WatermarkImageFile from "./WatermarkImageFile";
import WatermarkFormatting from "./WatermarkFormatting";
interface AddWatermarkSingleStepSettingsProps {
parameters: AddWatermarkParameters;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
const AddWatermarkSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: AddWatermarkSingleStepSettingsProps) => {
return (
<Stack gap="lg">
{/* Watermark Type Selection */}
<WatermarkTypeSettings
watermarkType={parameters.watermarkType}
onWatermarkTypeChange={(type) => onParameterChange("watermarkType", type)}
disabled={disabled}
/>
{/* Conditional settings based on watermark type */}
{parameters.watermarkType === "text" && (
<>
<WatermarkWording
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
<WatermarkTextStyle
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{parameters.watermarkType === "image" && (
<WatermarkImageFile
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
{/* Formatting settings for both text and image */}
{parameters.watermarkType && (
<WatermarkFormatting
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
</Stack>
);
};
export default AddWatermarkSingleStepSettings;

View File

@ -6,7 +6,7 @@ import NumberInputWithUnit from "../shared/NumberInputWithUnit";
interface WatermarkFormattingProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}

View File

@ -6,7 +6,7 @@ import FileUploadButton from "../../shared/FileUploadButton";
interface WatermarkImageFileProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@ -17,7 +17,7 @@ const WatermarkImageFile = ({ parameters, onParameterChange, disabled = false }:
<Stack gap="sm">
<FileUploadButton
file={parameters.watermarkImage}
onChange={(file) => onParameterChange('watermarkImage', file)}
onChange={(file) => onParameterChange('watermarkImage', file || undefined)}
accept="image/*"
disabled={disabled}
placeholder={t('watermark.settings.image.choose', 'Choose Image')}

View File

@ -5,7 +5,7 @@ import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAdd
interface WatermarkStyleSettingsProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@ -19,7 +19,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.rotation', 'Rotation (degrees)')}</Text>
<NumberInput
value={parameters.rotation}
onChange={(value) => onParameterChange('rotation', value || 0)}
onChange={(value) => onParameterChange('rotation', typeof value === 'number' ? value : (parseInt(value as string, 10) || 0))}
min={-360}
max={360}
disabled={disabled}
@ -28,7 +28,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.opacity', 'Opacity (%)')}</Text>
<NumberInput
value={parameters.opacity}
onChange={(value) => onParameterChange('opacity', value || 50)}
onChange={(value) => onParameterChange('opacity', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
min={0}
max={100}
disabled={disabled}
@ -40,7 +40,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.spacing.width', 'Width Spacing')}</Text>
<NumberInput
value={parameters.widthSpacer}
onChange={(value) => onParameterChange('widthSpacer', value || 50)}
onChange={(value) => onParameterChange('widthSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
min={0}
max={200}
disabled={disabled}
@ -49,7 +49,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.spacing.height', 'Height Spacing')}</Text>
<NumberInput
value={parameters.heightSpacer}
onChange={(value) => onParameterChange('heightSpacer', value || 50)}
onChange={(value) => onParameterChange('heightSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
min={0}
max={200}
disabled={disabled}

View File

@ -6,7 +6,7 @@ import { alphabetOptions } from "../../../constants/addWatermarkConstants";
interface WatermarkTextStyleProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}

View File

@ -6,7 +6,7 @@ import { removeEmojis } from "../../../utils/textUtils";
interface WatermarkWordingProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}

View File

@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Text,
Stack,
Group,
TextInput,
Divider,
Modal
} from '@mantine/core';
import CheckIcon from '@mui/icons-material/Check';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import ToolConfigurationModal from './ToolConfigurationModal';
import ToolList from './ToolList';
import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation';
import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm';
interface AutomationCreationProps {
mode: AutomationMode;
existingAutomation?: AutomationConfig;
onBack: () => void;
onComplete: (automation: AutomationConfig) => void;
toolRegistry: Record<string, ToolRegistryEntry>;
}
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {
const { t } = useTranslation();
const {
automationName,
setAutomationName,
selectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters
} = useAutomationForm({ mode, existingAutomation, toolRegistry });
const [configModalOpen, setConfigModalOpen] = useState(false);
const [configuraingToolIndex, setConfiguringToolIndex] = useState(-1);
const [unsavedWarningOpen, setUnsavedWarningOpen] = useState(false);
const configureTool = (index: number) => {
setConfiguringToolIndex(index);
setConfigModalOpen(true);
};
const handleToolConfigSave = (parameters: Record<string, any>) => {
if (configuraingToolIndex >= 0) {
updateTool(configuraingToolIndex, {
configured: true,
parameters
});
}
setConfigModalOpen(false);
setConfiguringToolIndex(-1);
};
const handleToolConfigCancel = () => {
setConfigModalOpen(false);
setConfiguringToolIndex(-1);
};
const handleToolAdd = () => {
const newTool: AutomationTool = {
id: `tool-${Date.now()}`,
operation: '',
name: t('automate.creation.tools.selectTool', 'Select a tool...'),
configured: false,
parameters: {}
};
updateTool(selectedTools.length, newTool);
};
const handleBackClick = () => {
if (hasUnsavedChanges()) {
setUnsavedWarningOpen(true);
} else {
onBack();
}
};
const handleConfirmBack = () => {
setUnsavedWarningOpen(false);
onBack();
};
const handleCancelBack = () => {
setUnsavedWarningOpen(false);
};
const saveAutomation = async () => {
if (!canSaveAutomation()) return;
const automation = {
name: automationName.trim(),
description: '',
operations: selectedTools.map(tool => ({
operation: tool.operation,
parameters: tool.parameters || {}
}))
};
try {
const { automationStorage } = await import('../../../services/automationStorage');
const savedAutomation = await automationStorage.saveAutomation(automation);
onComplete(savedAutomation);
} catch (error) {
console.error('Error saving automation:', error);
}
};
const currentConfigTool = configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
return (
<div>
<Text size="sm" mb="md" p="md" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
{t("automate.creation.description", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
</Text>
<Divider mb="md" />
<Stack gap="md">
{/* Automation Name */}
<TextInput
placeholder={t('automate.creation.name.placeholder', 'Automation name')}
value={automationName}
onChange={(e) => setAutomationName(e.currentTarget.value)}
size="sm"
/>
{/* Selected Tools List */}
{selectedTools.length > 0 && (
<ToolList
tools={selectedTools}
toolRegistry={toolRegistry}
onToolUpdate={updateTool}
onToolRemove={removeTool}
onToolConfigure={configureTool}
onToolAdd={handleToolAdd}
getToolName={getToolName}
getToolDefaultParameters={getToolDefaultParameters}
/>
)}
<Divider />
{/* Save Button */}
<Button
leftSection={<CheckIcon />}
onClick={saveAutomation}
disabled={!canSaveAutomation()}
fullWidth
>
{t('automate.creation.save', 'Save Automation')}
</Button>
</Stack>
{/* Tool Configuration Modal */}
{currentConfigTool && (
<ToolConfigurationModal
opened={configModalOpen}
tool={currentConfigTool}
onSave={handleToolConfigSave}
onCancel={handleToolConfigCancel}
toolRegistry={toolRegistry}
/>
)}
{/* Unsaved Changes Warning Modal */}
<Modal
opened={unsavedWarningOpen}
onClose={handleCancelBack}
title={t('automate.creation.unsavedChanges.title', 'Unsaved Changes')}
centered
>
<Stack gap="md">
<Text>
{t('automate.creation.unsavedChanges.message', 'You have unsaved changes. Are you sure you want to go back? All changes will be lost.')}
</Text>
<Group gap="md" justify="flex-end">
<Button variant="outline" onClick={handleCancelBack}>
{t('automate.creation.unsavedChanges.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={handleConfirmBack}>
{t('automate.creation.unsavedChanges.confirm', 'Go Back')}
</Button>
</Group>
</Stack>
</Modal>
</div>
);
}

View File

@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */
title?: string;
/** MUI Icon component for the badge */
badgeIcon?: React.ComponentType<any>;
/** Array of tool operation names in the workflow */
operations: string[];
/** Click handler */
onClick: () => void;
/** Whether to keep the icon at normal color (for special cases like "Add New") */
keepIconColor?: boolean;
/** Show menu for saved/suggested automations */
showMenu?: boolean;
/** Edit handler */
onEdit?: () => void;
/** Delete handler */
onDelete?: () => void;
}
export default function AutomationEntry({
title,
badgeIcon: BadgeIcon,
operations,
onClick,
keepIconColor = false,
showMenu = false,
onEdit,
onDelete
}: AutomationEntryProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
const renderContent = () => {
if (title) {
// Custom automation with title
return (
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
{BadgeIcon && (
<BadgeIcon
style={{
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
}}
/>
)}
<Text size="xs" style={{ flex: 1, textAlign: 'left', color: 'var(--mantine-color-text)' }}>
{title}
</Text>
</Group>
);
} else {
// Suggested automation showing tool chain
return (
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
{BadgeIcon && (
<BadgeIcon
style={{
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
}}
/>
)}
<Group gap="xs" justify="flex-start" style={{ flex: 1 }}>
{operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t(`${op}.title`, op)}
</Text>
{index < operations.length - 1 && (
<Text size="xs" c="dimmed" style={{ color: 'var(--mantine-color-text)' }}>
</Text>
)}
</React.Fragment>
))}
</Group>
</Group>
);
}
};
return (
<Box
style={{
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
borderRadius: 'var(--mantine-radius-md)',
transition: 'background-color 0.15s ease',
padding: '0.75rem 1rem',
cursor: 'pointer'
}}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Group gap="md" align="center" justify="space-between" style={{ width: '100%' }}>
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start' }}>
{renderContent()}
</div>
{showMenu && (
<Menu
position="bottom-end"
withinPortal
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => e.stopPropagation()}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: shouldShowHovered ? 'auto' : 'none'
}}
>
<MoreVertIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onEdit && (
<Menu.Item
leftSection={<EditIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
{t('edit', 'Edit')}
</Menu.Item>
)}
{onDelete && (
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
{t('delete', 'Delete')}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Box>
);
}

View File

@ -0,0 +1,223 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import CheckIcon from "@mui/icons-material/Check";
import { useFileSelection } from "../../../contexts/FileContext";
import { useFlatToolRegistry } from "../../../data/useTranslatedToolRegistry";
import { AutomationConfig, ExecutionStep } from "../../../types/automation";
import { AUTOMATION_CONSTANTS, EXECUTION_STATUS } from "../../../constants/automation";
import { useResourceCleanup } from "../../../utils/resourceManager";
interface AutomationRunProps {
automation: AutomationConfig;
onComplete: () => void;
automateOperation?: any; // TODO: Type this properly when available
}
export default function AutomationRun({ automation, onComplete, automateOperation }: AutomationRunProps) {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const toolRegistry = useFlatToolRegistry();
const cleanup = useResourceCleanup();
// Progress tracking state
const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
// Use the operation hook's loading state
const isExecuting = automateOperation?.isLoading || false;
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
React.useEffect(() => {
if (automation?.operations) {
const steps = automation.operations.map((op: any, index: number) => {
const tool = toolRegistry[op.operation];
return {
id: `${op.operation}-${index}`,
operation: op.operation,
name: tool?.name || op.operation,
status: EXECUTION_STATUS.PENDING
};
});
setExecutionSteps(steps);
setCurrentStepIndex(-1);
}
}, [automation, toolRegistry]);
// Cleanup when component unmounts
React.useEffect(() => {
return () => {
// Reset progress state when component unmounts
setExecutionSteps([]);
setCurrentStepIndex(-1);
// Clean up any blob URLs
cleanup();
};
}, [cleanup]);
const executeAutomation = async () => {
if (!selectedFiles || selectedFiles.length === 0) {
return;
}
if (!automateOperation) {
console.error('No automateOperation provided');
return;
}
// Reset progress tracking
setCurrentStepIndex(0);
setExecutionSteps(prev => prev.map(step => ({ ...step, status: EXECUTION_STATUS.PENDING, error: undefined })));
try {
// Use the automateOperation.executeOperation to handle file consumption properly
await automateOperation.executeOperation(
{
automationConfig: automation,
onStepStart: (stepIndex: number, operationName: string) => {
setCurrentStepIndex(stepIndex);
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
));
},
onStepComplete: (stepIndex: number, resultFiles: File[]) => {
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
));
},
onStepError: (stepIndex: number, error: string) => {
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.ERROR, error } : step
));
}
},
selectedFiles
);
// Mark all as completed and reset current step
setCurrentStepIndex(-1);
console.log(`✅ Automation completed successfully`);
} catch (error: any) {
console.error("Automation execution failed:", error);
setCurrentStepIndex(-1);
}
};
const getProgress = () => {
if (executionSteps.length === 0) return 0;
const completedSteps = executionSteps.filter(step => step.status === EXECUTION_STATUS.COMPLETED).length;
return (completedSteps / executionSteps.length) * 100;
};
const getStepIcon = (step: ExecutionStep) => {
switch (step.status) {
case EXECUTION_STATUS.COMPLETED:
return <CheckIcon style={{ fontSize: 16, color: 'green' }} />;
case EXECUTION_STATUS.ERROR:
return <span style={{ fontSize: 16, color: 'red' }}></span>;
case EXECUTION_STATUS.RUNNING:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderTop: '2px solid #007bff',
borderRadius: '50%',
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
}} />;
default:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderRadius: '50%'
}} />;
}
};
return (
<div>
<Stack gap="md">
{/* Automation Info */}
<Card padding="md" withBorder>
<Text size="sm" fw={500} mb="xs">
{automation?.name || t("automate.sequence.unnamed", "Unnamed Automation")}
</Text>
<Text size="xs" c="dimmed">
{t("automate.sequence.steps", "{{count}} steps", { count: executionSteps.length })}
</Text>
</Card>
{/* Progress Bar */}
{isExecuting && (
<div>
<Text size="sm" mb="xs">
Progress: {currentStepIndex + 1}/{executionSteps.length}
</Text>
<Progress value={getProgress()} size="lg" />
</div>
)}
{/* Execution Steps */}
<Stack gap="xs">
{executionSteps.map((step, index) => (
<Group key={step.id} gap="sm" align="center">
<Text size="xs" c="dimmed" style={{ minWidth: "1rem", textAlign: "center" }}>
{index + 1}
</Text>
{getStepIcon(step)}
<div style={{ flex: 1 }}>
<Text
size="sm"
style={{
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
}}
>
{step.name}
</Text>
{step.error && (
<Text size="xs" c="red" mt="xs">
{step.error}
</Text>
)}
</div>
</Group>
))}
</Stack>
{/* Action Buttons */}
<Group justify="space-between" mt="xl">
<Button
leftSection={<PlayArrowIcon />}
onClick={executeAutomation}
disabled={isExecuting || !selectedFiles || selectedFiles.length === 0}
loading={isExecuting}
>
{isExecuting
? t("automate.sequence.running", "Running Automation...")
: t("automate.sequence.run", "Run Automation")}
</Button>
{hasResults && (
<Button variant="light" onClick={onComplete}>
{t("automate.sequence.finish", "Finish")}
</Button>
)}
</Group>
</Stack>
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
</style>
</div>
);
}

View File

@ -0,0 +1,76 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Title, Stack, Divider } from "@mantine/core";
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
import SettingsIcon from "@mui/icons-material/Settings";
import AutomationEntry from "./AutomationEntry";
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
import { AutomationConfig } from "../../../types/automation";
interface AutomationSelectionProps {
savedAutomations: AutomationConfig[];
onCreateNew: () => void;
onRun: (automation: AutomationConfig) => void;
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
}
export default function AutomationSelection({
savedAutomations,
onCreateNew,
onRun,
onEdit,
onDelete
}: AutomationSelectionProps) {
const { t } = useTranslation();
const suggestedAutomations = useSuggestedAutomations();
return (
<div>
<Title order={3} size="h4" fw={600} mb="md" style={{color: 'var(--mantine-color-dimmed)'}}>
{t("automate.selection.saved.title", "Saved")}
</Title>
<Stack gap="xs">
<AutomationEntry
title={t("automate.selection.createNew.title", "Create New Automation")}
badgeIcon={AddCircleOutline}
operations={[]}
onClick={onCreateNew}
keepIconColor={true}
/>
{/* Saved Automations */}
{savedAutomations.map((automation) => (
<AutomationEntry
key={automation.id}
title={automation.name}
badgeIcon={SettingsIcon}
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
onClick={() => onRun(automation)}
showMenu={true}
onEdit={() => onEdit(automation)}
onDelete={() => onDelete(automation)}
/>
))}
<Divider pb='sm' />
{/* Suggested Automations */}
<div>
<Title order={3} size="h4" fw={600} mb="md"style={ {color: 'var(--mantine-color-dimmed)'}}>
{t("automate.selection.suggested.title", "Suggested")}
</Title>
<Stack gap="xs">
{suggestedAutomations.map((automation) => (
<AutomationEntry
key={automation.id}
badgeIcon={automation.icon}
operations={automation.operations.map(op => op.operation)}
onClick={() => onRun(automation)}
/>
))}
</Stack>
</div>
</Stack>
</div>
);
}

View File

@ -0,0 +1,138 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Title,
Button,
Group,
Stack,
Text,
Alert
} from '@mantine/core';
import SettingsIcon from '@mui/icons-material/Settings';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import WarningIcon from '@mui/icons-material/Warning';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import { getAvailableToExtensions } from '../../../utils/convertUtils';
interface ToolConfigurationModalProps {
opened: boolean;
tool: {
id: string;
operation: string;
name: string;
parameters?: any;
};
onSave: (parameters: any) => void;
onCancel: () => void;
toolRegistry: ToolRegistry;
}
export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, toolRegistry }: ToolConfigurationModalProps) {
const { t } = useTranslation();
const [parameters, setParameters] = useState<any>({});
const [isValid, setIsValid] = useState(true);
// Get tool info from registry
const toolInfo = toolRegistry[tool.operation];
const SettingsComponent = toolInfo?.settingsComponent;
// Initialize parameters from tool (which should contain defaults from registry)
useEffect(() => {
if (tool.parameters) {
setParameters(tool.parameters);
} else {
// Fallback to empty parameters if none provided
setParameters({});
}
}, [tool.parameters, tool.operation]);
// Render the settings component
const renderToolSettings = () => {
if (!SettingsComponent) {
return (
<Alert icon={<WarningIcon />} color="orange">
<Text size="sm">
{t('automate.config.noSettings', 'This tool does not have configurable settings.')}
</Text>
</Alert>
);
}
// Special handling for ConvertSettings which needs additional props
if (tool.operation === 'convert') {
return (
<SettingsComponent
parameters={parameters}
onParameterChange={(key: string, value: any) => {
setParameters((prev: any) => ({ ...prev, [key]: value }));
}}
getAvailableToExtensions={getAvailableToExtensions}
selectedFiles={[]}
disabled={false}
/>
);
}
return (
<SettingsComponent
parameters={parameters}
onParameterChange={(key: string, value: any) => {
setParameters((prev: any) => ({ ...prev, [key]: value }));
}}
disabled={false}
/>
);
};
const handleSave = () => {
if (isValid) {
onSave(parameters);
}
};
return (
<Modal
opened={opened}
onClose={onCancel}
title={
<Group gap="xs">
<SettingsIcon />
<Title order={3}>
{t('automate.config.title', 'Configure {{toolName}}', { toolName: tool.name })}
</Title>
</Group>
}
size="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('automate.config.description', 'Configure the settings for this tool. These settings will be applied when the automation runs.')}
</Text>
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{renderToolSettings()}
</div>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
leftSection={<CloseIcon />}
onClick={onCancel}
>
{t('automate.config.cancel', 'Cancel')}
</Button>
<Button
leftSection={<CheckIcon />}
onClick={handleSave}
disabled={!isValid}
>
{t('automate.config.save', 'Save Configuration')}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,149 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, Stack, Group, ActionIcon } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import SettingsIcon from '@mui/icons-material/Settings';
import CloseIcon from '@mui/icons-material/Close';
import AddCircleOutline from '@mui/icons-material/AddCircleOutline';
import { AutomationTool } from '../../../types/automation';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import ToolSelector from './ToolSelector';
import AutomationEntry from './AutomationEntry';
interface ToolListProps {
tools: AutomationTool[];
toolRegistry: Record<string, ToolRegistryEntry>;
onToolUpdate: (index: number, updates: Partial<AutomationTool>) => void;
onToolRemove: (index: number) => void;
onToolConfigure: (index: number) => void;
onToolAdd: () => void;
getToolName: (operation: string) => string;
getToolDefaultParameters: (operation: string) => Record<string, any>;
}
export default function ToolList({
tools,
toolRegistry,
onToolUpdate,
onToolRemove,
onToolConfigure,
onToolAdd,
getToolName,
getToolDefaultParameters
}: ToolListProps) {
const { t } = useTranslation();
const handleToolSelect = (index: number, newOperation: string) => {
const defaultParams = getToolDefaultParameters(newOperation);
onToolUpdate(index, {
operation: newOperation,
name: getToolName(newOperation),
configured: false,
parameters: defaultParams
});
};
return (
<div>
<Text size="sm" fw={500} mb="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length})
</Text>
<Stack gap="0">
{tools.map((tool, index) => (
<React.Fragment key={tool.id}>
<div
style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
position: 'relative',
padding: 'var(--mantine-spacing-xs)'
}}
>
{/* Delete X in top right */}
<ActionIcon
variant="subtle"
size="xs"
onClick={() => onToolRemove(index)}
title={t('automate.creation.tools.remove', 'Remove tool')}
style={{
position: 'absolute',
top: '4px',
right: '4px',
zIndex: 1,
color: 'var(--mantine-color-gray-6)'
}}
>
<CloseIcon style={{ fontSize: 12 }} />
</ActionIcon>
<div style={{ paddingRight: '1.25rem' }}>
{/* Tool Selection Dropdown with inline settings cog */}
<Group gap="xs" align="center" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}>
<ToolSelector
key={`tool-selector-${tool.id}`}
onSelect={(newOperation) => handleToolSelect(index, newOperation)}
excludeTools={['automate']}
toolRegistry={toolRegistry}
selectedValue={tool.operation}
placeholder={tool.name}
/>
</div>
{/* Settings cog - only show if tool is selected, aligned right */}
{tool.operation && (
<ActionIcon
variant="subtle"
size="sm"
onClick={() => onToolConfigure(index)}
title={t('automate.creation.tools.configure', 'Configure tool')}
style={{ color: 'var(--mantine-color-gray-6)' }}
>
<SettingsIcon style={{ fontSize: 16 }} />
</ActionIcon>
)}
</Group>
{/* Configuration status underneath */}
{tool.operation && !tool.configured && (
<Text pl="md" size="xs" c="dimmed" mt="xs">
{t('automate.creation.tools.notConfigured', "! Not Configured")}
</Text>
)}
</div>
</div>
{index < tools.length - 1 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Text size="xs" c="dimmed"></Text>
</div>
)}
</React.Fragment>
))}
{/* Arrow before Add Tool Button */}
{tools.length > 0 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Text size="xs" c="dimmed"></Text>
</div>
)}
{/* Add Tool Button */}
<div style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
overflow: 'hidden'
}}>
<AutomationEntry
title={t('automate.creation.tools.addTool', 'Add Tool')}
badgeIcon={AddCircleOutline}
operations={[]}
onClick={onToolAdd}
keepIconColor={true}
/>
</div>
</Stack>
</div>
);
}

View File

@ -0,0 +1,182 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
interface ToolSelectorProps {
onSelect: (toolKey: string) => void;
excludeTools?: string[];
toolRegistry: Record<string, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
selectedValue?: string; // For showing current selection when editing existing tool
placeholder?: string; // Custom placeholder text
}
export default function ToolSelector({
onSelect,
excludeTools = [],
toolRegistry,
selectedValue,
placeholder
}: ToolSelectorProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// Filter out excluded tools (like 'automate' itself)
const baseFilteredTools = useMemo(() => {
return Object.entries(toolRegistry).filter(([key]) => !excludeTools.includes(key));
}, [toolRegistry, excludeTools]);
// Apply search filter
const filteredTools = useMemo(() => {
if (!searchTerm.trim()) {
return baseFilteredTools;
}
const lowercaseSearch = searchTerm.toLowerCase();
return baseFilteredTools.filter(([key, tool]) => {
return (
tool.name.toLowerCase().includes(lowercaseSearch) ||
tool.description?.toLowerCase().includes(lowercaseSearch) ||
key.toLowerCase().includes(lowercaseSearch)
);
});
}, [baseFilteredTools, searchTerm]);
// Create filtered tool registry for ToolSearch
const filteredToolRegistry = useMemo(() => {
const registry: Record<string, ToolRegistryEntry> = {};
baseFilteredTools.forEach(([key, tool]) => {
registry[key] = tool;
});
return registry;
}, [baseFilteredTools]);
// Use the same tool sections logic as the main ToolPicker
const { sections, searchGroups } = useToolSections(filteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
const displayGroups = useMemo(() => {
if (isSearching) {
return searchGroups || [];
}
if (!sections || sections.length === 0) {
return [];
}
// Find the "all" section which contains all tools without duplicates
const allSection = sections.find(s => (s as any).key === 'all');
return allSection?.subcategories || [];
}, [isSearching, searchGroups, sections]);
const handleToolSelect = useCallback((toolKey: string) => {
onSelect(toolKey);
setOpened(false);
setSearchTerm(''); // Clear search to show the selected tool display
}, [onSelect]);
const renderedTools = useMemo(() =>
displayGroups.map((subcategory) =>
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching)
), [displayGroups, handleToolSelect, isSearching, t]
);
const handleSearchFocus = () => {
setOpened(true);
};
const handleSearchChange = (value: string) => {
setSearchTerm(value);
if (!opened) {
setOpened(true);
}
};
// Get display value for selected tool
const getDisplayValue = () => {
if (selectedValue && toolRegistry[selectedValue]) {
return toolRegistry[selectedValue].name;
}
return placeholder || t('automate.creation.tools.add', 'Add a tool...');
};
return (
<div style={{ position: 'relative', width: '100%' }}>
<Menu
opened={opened}
onChange={(isOpen) => {
setOpened(isOpen);
// Clear search term when menu closes to show proper display
if (!isOpen) {
setSearchTerm('');
}
}}
closeOnClickOutside={true}
closeOnEscape={true}
position="bottom-start"
offset={4}
withinPortal={false}
trapFocus={false}
shadow="sm"
transitionProps={{ duration: 0 }}
>
<Menu.Target>
<div style={{ width: '100%' }}>
{selectedValue && toolRegistry[selectedValue] && !opened ? (
// Show selected tool in AutomationEntry style when tool is selected and not searching
<div onClick={handleSearchFocus} style={{ cursor: 'pointer' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--mantine-spacing-sm)',
padding: '0 0.5rem',
borderRadius: 'var(--mantine-radius-sm)',
}}>
<div style={{ color: 'var(--mantine-color-text)', fontSize: '1.2rem' }}>
{toolRegistry[selectedValue].icon}
</div>
<Text size="sm" style={{ flex: 1, color: 'var(--mantine-color-text)' }}>
{toolRegistry[selectedValue].name}
</Text>
</div>
</div>
) : (
// Show search input when no tool selected or actively searching
<ToolSearch
value={searchTerm}
onChange={handleSearchChange}
toolRegistry={filteredToolRegistry}
mode="filter"
placeholder={getDisplayValue()}
hideIcon={true}
onFocus={handleSearchFocus}
/>
)}
</div>
</Menu.Target>
<Menu.Dropdown p={0} style={{ minWidth: '16rem' }}>
<ScrollArea h={350}>
<Stack gap="sm" p="sm">
{displayGroups.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md">
{isSearching
? t('tools.noSearchResults', 'No tools found')
: t('tools.noTools', 'No tools available')
}
</Text>
) : (
renderedTools
)}
</Stack>
</ScrollArea>
</Menu.Dropdown>
</Menu>
</div>
);
}

View File

@ -1,6 +1,9 @@
import React from "react";
import { Text } from "@mantine/core";
import { Text, Anchor } from "@mantine/core";
import { useTranslation } from "react-i18next";
import FolderIcon from '@mui/icons-material/Folder';
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext";
export interface FileStatusIndicatorProps {
selectedFiles?: File[];
@ -12,13 +15,39 @@ const FileStatusIndicator = ({
placeholder,
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const defaultPlaceholder = placeholder || t("files.placeholder", "Select a PDF file in the main view to get started");
// Only show content when no files are selected
const { openFilesModal } = useFilesModalContext();
const { files: workbenchFiles } = useAllFiles();
// Check if there are no files in the workbench
if (workbenchFiles.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("files.noFiles", "No files uploaded. ")}{" "}
<Anchor
size="sm"
onClick={openFilesModal}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<FolderIcon style={{ fontSize: '14px' }} />
{t("files.addFiles", "Add files")}
</Anchor>
</Text>
);
}
// Show selection status when there are files in workbench
if (selectedFiles.length === 0) {
return (
<Text size="sm" c="dimmed">
{defaultPlaceholder}
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
<Anchor
size="sm"
onClick={openFilesModal}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<FolderIcon style={{ fontSize: '14px' }} />
{t("files.addFiles", "Add files")}
</Anchor>
</Text>
);
}

View File

@ -2,6 +2,7 @@ import React from 'react';
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
export interface SuggestedToolsSectionProps {}
export function SuggestedToolsSection(): React.ReactElement {
@ -21,7 +22,7 @@ export function SuggestedToolsSection(): React.ReactElement {
const IconComponent = tool.icon;
return (
<Card
key={tool.name}
key={tool.id}
p="sm"
withBorder
style={{ cursor: 'pointer' }}

View File

@ -1,7 +1,6 @@
import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import LocalIcon from '../../shared/LocalIcon';
import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../../types/tips';
import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep';
@ -25,6 +24,7 @@ export interface ToolStepProps {
_stepNumber?: number; // Internal prop set by ToolStepContainer
_excludeFromCount?: boolean; // Internal prop to exclude from visible count calculation
_noPadding?: boolean; // Internal prop to exclude from default left padding
alwaysShowTooltip?: boolean; // Force tooltip to show even when collapsed
tooltip?: {
content?: React.ReactNode;
tips?: TooltipTip[];
@ -38,9 +38,10 @@ export interface ToolStepProps {
const renderTooltipTitle = (
title: string,
tooltip: ToolStepProps['tooltip'],
isCollapsed: boolean
isCollapsed: boolean,
alwaysShowTooltip: boolean = false
) => {
if (tooltip && !isCollapsed) {
if (tooltip && (!isCollapsed || alwaysShowTooltip)) {
return (
<Tooltip
content={tooltip.content}
@ -52,9 +53,7 @@ const renderTooltipTitle = (
<Text fw={500} size="lg">
{title}
</Text>
<span className="material-symbols-rounded" style={{ fontSize: '1.2rem', color: 'var(--icon-files-color)' }}>
gpp_maybe
</span>
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
</Flex>
</Tooltip>
);
@ -77,6 +76,7 @@ const ToolStep = ({
showNumber,
_stepNumber,
_noPadding,
alwaysShowTooltip = false,
tooltip
}: ToolStepProps) => {
if (!isVisible) return null;
@ -118,18 +118,16 @@ const ToolStep = ({
{stepNumber}
</Text>
)}
{renderTooltipTitle(title, tooltip, isCollapsed)}
{renderTooltipTitle(title, tooltip, isCollapsed, alwaysShowTooltip)}
</Flex>
{isCollapsed ? (
<ChevronRightIcon style={{
fontSize: '1.2rem',
<LocalIcon icon="chevron-right-rounded" width="1.2rem" height="1.2rem" style={{
color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5
}} />
) : (
<ExpandMoreIcon style={{
fontSize: '1.2rem',
<LocalIcon icon="expand-more-rounded" width="1.2rem" height="1.2rem" style={{
color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5
}} />

View File

@ -0,0 +1,52 @@
import React from 'react';
import { Flex, Text, Divider } from '@mantine/core';
import LocalIcon from '../../shared/LocalIcon';
import { Tooltip } from '../../shared/Tooltip';
export interface ToolWorkflowTitleProps {
title: string;
tooltip?: {
content?: React.ReactNode;
tips?: any[];
header?: {
title: string;
logo?: React.ReactNode;
};
};
}
export function ToolWorkflowTitle({ title, tooltip }: ToolWorkflowTitleProps) {
if (tooltip) {
return (
<>
<Flex justify="center" w="100%">
<Tooltip
content={tooltip.content}
tips={tooltip.tips}
header={tooltip.header}
sidebarTooltip={true}
>
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
<Text fw={500} size="xl" p="md">
{title}
</Text>
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
</Flex>
</Tooltip>
</Flex>
<Divider />
</>
);
}
return (
<>
<Flex justify="center" w="100%">
<Text fw={500} size="xl" p="md">
{title}
</Text>
</Flex>
<Divider />
</>
);
}

View File

@ -3,12 +3,14 @@ import { Stack } from '@mantine/core';
import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
export interface FilesStepConfig {
selectedFiles: File[];
isCollapsed?: boolean;
placeholder?: string;
onCollapsedClick?: () => void;
isVisible?: boolean;
}
export interface MiddleStepConfig {
@ -44,7 +46,10 @@ export interface ReviewStepConfig {
testId?: string;
}
export interface TitleConfig extends ToolWorkflowTitleProps {}
export interface ToolFlowConfig {
title?: TitleConfig;
files: FilesStepConfig;
steps: MiddleStepConfig[];
executeButton?: ExecuteButtonConfig;
@ -62,8 +67,10 @@ export function createToolFlow(config: ToolFlowConfig) {
return (
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
{config.title && <ToolWorkflowTitle {...config.title} />}
{/* Files Step */}
{steps.createFilesStep({
{config.files.isVisible !== false && steps.createFilesStep({
selectedFiles: config.files.selectedFiles,
isCollapsed: config.files.isCollapsed,
placeholder: config.files.placeholder,

View File

@ -0,0 +1,34 @@
import React from 'react';
import { Box, Stack } from '@mantine/core';
import ToolButton from '../toolPicker/ToolButton';
import SubcategoryHeader from './SubcategoryHeader';
import { getSubcategoryLabel } from "../../../data/toolsTaxonomy";
import { TFunction } from 'i18next';
import { SubcategoryGroup } from '../../../hooks/useToolSections';
// Helper function to render tool buttons for a subcategory
export const renderToolButtons = (
t: TFunction,
subcategory: SubcategoryGroup,
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true
) => (
<Box key={subcategory.subcategoryId} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<Stack gap="xs">
{subcategory.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
);

View File

@ -14,9 +14,9 @@ interface ToolButtonProps {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
const handleClick = (id: string) => {
if (tool.link) {
// Open external link in new tab
// Open external link in new tab
window.open(tool.link, '_blank', 'noopener,noreferrer');
return;
return;
}
// Normal tool selection
onSelect(id);
@ -47,4 +47,4 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
);
};
export default ToolButton;
export default ToolButton;

View File

@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect, useMemo } from "react";
import { Stack, Button, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from '../../shared/LocalIcon';
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { TextInput } from "../../shared/TextInput";
import './ToolPicker.css';
@ -12,19 +13,26 @@ interface ToolSearchProps {
onToolSelect?: (toolId: string) => void;
mode: 'filter' | 'dropdown';
selectedToolKey?: string | null;
placeholder?: string;
hideIcon?: boolean;
onFocus?: () => void;
}
const ToolSearch = ({
value,
onChange,
toolRegistry,
onToolSelect,
const ToolSearch = ({
value,
onChange,
toolRegistry,
onToolSelect,
mode = 'filter',
selectedToolKey
selectedToolKey,
placeholder,
hideIcon = false,
onFocus
}: ToolSearchProps) => {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const filteredTools = useMemo(() => {
if (!value.trim()) return [];
@ -47,7 +55,12 @@ const ToolSearch = ({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
if (
searchRef.current &&
dropdownRef.current &&
!searchRef.current.contains(event.target as Node) &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
@ -61,9 +74,10 @@ const ToolSearch = ({
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
icon={<span className="material-symbols-rounded">search</span>}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
autoComplete="off"
/>
</div>
);
@ -77,19 +91,19 @@ const ToolSearch = ({
{searchInput}
{dropdownOpen && filteredTools.length > 0 && (
<div
ref={dropdownRef}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '8px',
marginTop: '4px',
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '6px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
maxHeight: '300px',
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
overflowY: 'auto'
}}
>
<Stack gap="xs" style={{ padding: '8px' }}>
@ -97,7 +111,10 @@ const ToolSearch = ({
<Button
key={id}
variant="subtle"
onClick={() => onToolSelect && onToolSelect(id)}
onClick={() => {
onToolSelect && onToolSelect(id);
setDropdownOpen(false);
}}
leftSection={
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
{tool.icon}
@ -126,4 +143,4 @@ const ToolSearch = ({
);
};
export default ToolSearch;
export default ToolSearch;

View File

@ -0,0 +1,42 @@
/**
* Constants for automation functionality
*/
export const AUTOMATION_CONSTANTS = {
// Timeouts
OPERATION_TIMEOUT: 300000, // 5 minutes in milliseconds
// Default values
DEFAULT_TOOL_COUNT: 2,
MIN_TOOL_COUNT: 2,
// File prefixes
FILE_PREFIX: 'automated_',
RESPONSE_ZIP_PREFIX: 'response_',
RESULT_FILE_PREFIX: 'result_',
PROCESSED_FILE_PREFIX: 'processed_',
// Operation types
CONVERT_OPERATION_TYPE: 'convert',
// Storage keys
DB_NAME: 'StirlingPDF_Automations',
DB_VERSION: 1,
STORE_NAME: 'automations',
// UI delays
SPINNER_ANIMATION_DURATION: '1s'
} as const;
export const AUTOMATION_STEPS = {
SELECTION: 'selection',
CREATION: 'creation',
RUN: 'run'
} as const;
export const EXECUTION_STATUS = {
PENDING: 'pending',
RUNNING: 'running',
COMPLETED: 'completed',
ERROR: 'error'
} as const;

View File

@ -1,5 +1,6 @@
import React, { createContext, useContext, useReducer, useCallback } from 'react';
import { useNavigationUrlSync } from '../hooks/useUrlSync';
import { ModeType, isValidMode, getDefaultMode } from '../types/navigation';
/**
* NavigationContext - Complete navigation management system
@ -9,32 +10,13 @@ import { useNavigationUrlSync } from '../hooks/useUrlSync';
* maintain clear separation of concerns.
*/
// Navigation mode types - complete list to match fileContext.ts
export type ModeType =
| 'viewer'
| 'pageEditor'
| 'fileEditor'
| 'merge'
| 'split'
| 'compress'
| 'ocr'
| 'convert'
| 'sanitize'
| 'addPassword'
| 'changePermissions'
| 'addWatermark'
| 'removePassword'
| 'single-large-page'
| 'repair'
| 'unlockPdfForms'
| 'removeCertificateSign';
// Navigation state
interface NavigationState {
currentMode: ModeType;
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
selectedToolKey: string | null; // Add tool selection to navigation state
}
// Navigation actions
@ -42,7 +24,8 @@ type NavigationAction =
| { type: 'SET_MODE'; payload: { mode: ModeType } }
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } }
| { type: 'SET_SELECTED_TOOL'; payload: { toolKey: string | null } };
// Navigation reducer
const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => {
@ -59,6 +42,9 @@ const navigationReducer = (state: NavigationState, action: NavigationAction): Na
case 'SHOW_NAVIGATION_WARNING':
return { ...state, showNavigationWarning: action.payload.show };
case 'SET_SELECTED_TOOL':
return { ...state, selectedToolKey: action.payload.toolKey };
default:
return state;
}
@ -66,10 +52,11 @@ const navigationReducer = (state: NavigationState, action: NavigationAction): Na
// Initial state
const initialState: NavigationState = {
currentMode: 'pageEditor',
currentMode: getDefaultMode(),
hasUnsavedChanges: false,
pendingNavigation: null,
showNavigationWarning: false
showNavigationWarning: false,
selectedToolKey: null
};
// Navigation context actions interface
@ -80,6 +67,9 @@ export interface NavigationContextActions {
requestNavigation: (navigationFn: () => void) => void;
confirmNavigation: () => void;
cancelNavigation: () => void;
selectTool: (toolKey: string) => void;
clearToolSelection: () => void;
handleToolSelect: (toolId: string) => void;
}
// Split context values
@ -88,6 +78,7 @@ export interface NavigationContextStateValue {
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
selectedToolKey: string | null;
}
export interface NavigationContextActionsValue {
@ -145,6 +136,31 @@ export const NavigationProvider: React.FC<{
// Clear navigation without executing
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, []),
selectTool: useCallback((toolKey: string) => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey } });
}, []),
clearToolSelection: useCallback(() => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
}, []),
handleToolSelect: useCallback((toolId: string) => {
// Handle special cases
if (toolId === 'allTools') {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
return;
}
// Special-case: if tool is a dedicated reader tool, enter reader mode
if (toolId === 'read' || toolId === 'view-pdf') {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
return;
}
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: toolId } });
dispatch({ type: 'SET_MODE', payload: { mode: 'fileEditor' as ModeType } });
}, [])
};
@ -152,7 +168,8 @@ export const NavigationProvider: React.FC<{
currentMode: state.currentMode,
hasUnsavedChanges: state.hasUnsavedChanges,
pendingNavigation: state.pendingNavigation,
showNavigationWarning: state.showNavigationWarning
showNavigationWarning: state.showNavigationWarning,
selectedToolKey: state.selectedToolKey
};
const actionsValue: NavigationContextActionsValue = {
@ -212,16 +229,8 @@ export const useNavigationGuard = () => {
};
};
// Utility functions for mode handling
export const isValidMode = (mode: string): mode is ModeType => {
const validModes: ModeType[] = [
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split',
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize'
];
return validModes.includes(mode as ModeType);
};
export const getDefaultMode = (): ModeType => 'pageEditor';
// Re-export utility functions from types for backward compatibility
export { isValidMode, getDefaultMode, type ModeType } from '../types/navigation';
// TODO: This will be expanded for URL-based routing system
// - URL parsing utilities

View File

@ -0,0 +1,64 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
interface RightRailContextValue {
buttons: RightRailButtonConfig[];
actions: Record<string, RightRailAction>;
registerButtons: (buttons: RightRailButtonConfig[]) => void;
unregisterButtons: (ids: string[]) => void;
setAction: (id: string, action: RightRailAction) => void;
clear: () => void;
}
const RightRailContext = createContext<RightRailContextValue | undefined>(undefined);
export function RightRailProvider({ children }: { children: React.ReactNode }) {
const [buttons, setButtons] = useState<RightRailButtonConfig[]>([]);
const [actions, setActions] = useState<Record<string, RightRailAction>>({});
const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => {
setButtons(prev => {
const byId = new Map(prev.map(b => [b.id, b] as const));
newButtons.forEach(nb => {
const existing = byId.get(nb.id) || ({} as RightRailButtonConfig);
byId.set(nb.id, { ...existing, ...nb });
});
const merged = Array.from(byId.values());
merged.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
if (process.env.NODE_ENV === 'development') {
const ids = newButtons.map(b => b.id);
const dupes = ids.filter((id, idx) => ids.indexOf(id) !== idx);
if (dupes.length) console.warn('[RightRail] Duplicate ids in registerButtons:', dupes);
}
return merged;
});
}, []);
const unregisterButtons = useCallback((ids: string[]) => {
setButtons(prev => prev.filter(b => !ids.includes(b.id)));
setActions(prev => Object.fromEntries(Object.entries(prev).filter(([id]) => !ids.includes(id))));
}, []);
const setAction = useCallback((id: string, action: RightRailAction) => {
setActions(prev => ({ ...prev, [id]: action }));
}, []);
const clear = useCallback(() => {
setButtons([]);
setActions({});
}, []);
const value = useMemo<RightRailContextValue>(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]);
return (
<RightRailContext.Provider value={value}>
{children}
</RightRailContext.Provider>
);
}
export function useRightRail() {
const ctx = useContext(RightRailContext);
if (!ctx) throw new Error('useRightRail must be used within RightRailProvider');
return ctx;
}

View File

@ -8,6 +8,7 @@ import { useToolManagement } from '../hooks/useToolManagement';
import { PageEditorFunctions } from '../types/pageEditor';
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
import { useToolWorkflowUrlSync } from '../hooks/useUrlSync';
import { useNavigationActions, useNavigationState } from './NavigationContext';
// State interface
interface ToolWorkflowState {
@ -72,7 +73,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
selectedToolKey: string | null;
selectedTool: ToolRegistryEntry | null;
toolRegistry: any; // From useToolManagement
// UI Actions
setSidebarsVisible: (visible: boolean) => void;
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void;
@ -100,23 +101,23 @@ const ToolWorkflowContext = createContext<ToolWorkflowContextValue | undefined>(
// Provider component
interface ToolWorkflowProviderProps {
children: React.ReactNode;
/** Handler for view changes (passed from parent) */
onViewChange?: (view: string) => void;
/** Enable URL synchronization for tool selection */
enableUrlSync?: boolean;
}
export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = true }: ToolWorkflowProviderProps) {
export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
// Navigation actions and state are available since we're inside NavigationProvider
const { actions } = useNavigationActions();
const navigationState = useNavigationState();
// Tool management hook
const {
selectedToolKey,
selectedTool,
toolRegistry,
selectTool,
clearToolSelection,
getSelectedTool,
} = useToolManagement();
// Get selected tool from navigation context
const selectedTool = getSelectedTool(navigationState.selectedToolKey);
// UI Action creators
const setSidebarsVisible = useCallback((visible: boolean) => {
@ -145,28 +146,30 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: string) => {
// Special-case: if tool is a dedicated reader tool, enter reader mode and do not go to toolContent
if (toolId === 'read' || toolId === 'view-pdf') {
setReaderMode(true);
setLeftPanelView('toolPicker');
clearToolSelection();
setSearchQuery('');
return;
}
selectTool(toolId);
onViewChange?.('fileEditor');
setLeftPanelView('toolContent');
setReaderMode(false);
// Clear search so the tool content becomes visible immediately
actions.handleToolSelect(toolId);
// Clear search query when selecting a tool
setSearchQuery('');
}, [selectTool, onViewChange, setLeftPanelView, setReaderMode, setSearchQuery, clearToolSelection]);
// Handle view switching logic
if (toolId === 'allTools' || toolId === 'read' || toolId === 'view-pdf') {
setLeftPanelView('toolPicker');
if (toolId === 'read' || toolId === 'view-pdf') {
setReaderMode(true);
} else {
setReaderMode(false);
}
} else {
setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools
}
}, [actions, setLeftPanelView, setReaderMode, setSearchQuery]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');
setReaderMode(false);
clearToolSelection();
}, [setLeftPanelView, setReaderMode, clearToolSelection]);
actions.clearToolSelection();
}, [setLeftPanelView, setReaderMode, actions]);
const handleReaderToggle = useCallback(() => {
setReaderMode(true);
@ -186,13 +189,13 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
);
// Enable URL synchronization for tool selection
useToolWorkflowUrlSync(selectedToolKey, selectTool, clearToolSelection, enableUrlSync);
useToolWorkflowUrlSync(navigationState.selectedToolKey, actions.selectTool, actions.clearToolSelection, true);
// Simple context value with basic memoization
// Properly memoized context value
const contextValue = useMemo((): ToolWorkflowContextValue => ({
// State
...state,
selectedToolKey,
selectedToolKey: navigationState.selectedToolKey,
selectedTool,
toolRegistry,
@ -203,8 +206,8 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
setPreviewFile,
setPageEditorFunctions,
setSearchQuery,
selectTool,
clearToolSelection,
selectTool: actions.selectTool,
clearToolSelection: actions.clearToolSelection,
// Workflow Actions
handleToolSelect,
@ -214,7 +217,25 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
// Computed
filteredTools,
isPanelVisible,
}), [state, selectedToolKey, selectedTool, toolRegistry, filteredTools, isPanelVisible]);
}), [
state,
navigationState.selectedToolKey,
selectedTool,
toolRegistry,
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
setPreviewFile,
setPageEditorFunctions,
setSearchQuery,
actions.selectTool,
actions.clearToolSelection,
handleToolSelect,
handleBackToTools,
handleReaderToggle,
filteredTools,
isPanelVisible,
]);
return (
<ToolWorkflowContext.Provider value={contextValue}>
@ -227,7 +248,39 @@ export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = t
export function useToolWorkflow(): ToolWorkflowContextValue {
const context = useContext(ToolWorkflowContext);
if (!context) {
// During development hot reload, temporarily return a safe fallback
if (false && process.env.NODE_ENV === 'development') {
console.warn('ToolWorkflowContext temporarily unavailable during hot reload, using fallback');
// Return minimal safe fallback to prevent crashes
return {
sidebarsVisible: true,
leftPanelView: 'toolPicker',
readerMode: false,
previewFile: null,
pageEditorFunctions: null,
searchQuery: '',
selectedToolKey: null,
selectedTool: null,
toolRegistry: {},
filteredTools: [],
isPanelVisible: true,
setSidebarsVisible: () => {},
setLeftPanelView: () => {},
setReaderMode: () => {},
setPreviewFile: () => {},
setPageEditorFunctions: () => {},
setSearchQuery: () => {},
selectTool: () => {},
clearToolSelection: () => {},
handleToolSelect: () => {},
handleBackToTools: () => {},
handleReaderToggle: () => {}
} as ToolWorkflowContextValue;
}
console.error('ToolWorkflowContext not found. Current stack:', new Error().stack);
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
}
return context;
}
}

View File

@ -1,25 +1,27 @@
import { type TFunction } from 'i18next';
import React from 'react';
import { ToolOperationHook, ToolOperationConfig } from '../hooks/tools/shared/useToolOperation';
import { BaseToolProps } from '../types/tool';
import { BaseParameters } from '../types/parameters';
export enum SubcategoryId {
SIGNING = 'signing',
DOCUMENT_SECURITY = 'documentSecurity',
VERIFICATION = 'verification',
DOCUMENT_REVIEW = 'documentReview',
PAGE_FORMATTING = 'pageFormatting',
EXTRACTION = 'extraction',
REMOVAL = 'removal',
AUTOMATION = 'automation',
GENERAL = 'general',
ADVANCED_FORMATTING = 'advancedFormatting',
DEVELOPER_TOOLS = 'developerTools'
SIGNING = 'signing',
DOCUMENT_SECURITY = 'documentSecurity',
VERIFICATION = 'verification',
DOCUMENT_REVIEW = 'documentReview',
PAGE_FORMATTING = 'pageFormatting',
EXTRACTION = 'extraction',
REMOVAL = 'removal',
AUTOMATION = 'automation',
GENERAL = 'general',
ADVANCED_FORMATTING = 'advancedFormatting',
DEVELOPER_TOOLS = 'developerTools'
}
export enum ToolCategory {
STANDARD_TOOLS = 'Standard Tools',
ADVANCED_TOOLS = 'Advanced Tools',
RECOMMENDED_TOOLS = 'Recommended Tools'
export enum ToolCategoryId {
STANDARD_TOOLS = 'standardTools',
ADVANCED_TOOLS = 'advancedTools',
RECOMMENDED_TOOLS = 'recommendedTools'
}
export type ToolRegistryEntry = {
@ -28,76 +30,80 @@ export type ToolRegistryEntry = {
component: React.ComponentType<BaseToolProps> | null;
view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external';
description: string;
category: ToolCategory;
subcategory: SubcategoryId;
categoryId: ToolCategoryId;
subcategoryId: SubcategoryId;
maxFiles?: number;
supportedFormats?: string[];
endpoints?: string[];
link?: string;
type?: string;
// Operation configuration for automation
operationConfig?: ToolOperationConfig<any>;
// Settings component for automation configuration
settingsComponent?: React.ComponentType<any>;
}
export type ToolRegistry = Record<string, ToolRegistryEntry>;
export type ToolRegistry = Record<string /* FIX ME: Should be ToolId */, ToolRegistryEntry>;
export const SUBCATEGORY_ORDER: SubcategoryId[] = [
SubcategoryId.SIGNING,
SubcategoryId.DOCUMENT_SECURITY,
SubcategoryId.VERIFICATION,
SubcategoryId.DOCUMENT_REVIEW,
SubcategoryId.PAGE_FORMATTING,
SubcategoryId.EXTRACTION,
SubcategoryId.REMOVAL,
SubcategoryId.AUTOMATION,
SubcategoryId.GENERAL,
SubcategoryId.ADVANCED_FORMATTING,
SubcategoryId.DEVELOPER_TOOLS,
SubcategoryId.SIGNING,
SubcategoryId.DOCUMENT_SECURITY,
SubcategoryId.VERIFICATION,
SubcategoryId.DOCUMENT_REVIEW,
SubcategoryId.PAGE_FORMATTING,
SubcategoryId.EXTRACTION,
SubcategoryId.REMOVAL,
SubcategoryId.AUTOMATION,
SubcategoryId.GENERAL,
SubcategoryId.ADVANCED_FORMATTING,
SubcategoryId.DEVELOPER_TOOLS,
];
export const SUBCATEGORY_COLOR_MAP: Record<SubcategoryId, string> = {
[SubcategoryId.SIGNING]: '#FF7892',
[SubcategoryId.DOCUMENT_SECURITY]: '#FF7892',
[SubcategoryId.VERIFICATION]: '#1BB1D4',
[SubcategoryId.DOCUMENT_REVIEW]: '#48BD54',
[SubcategoryId.PAGE_FORMATTING]: '#7882FF',
[SubcategoryId.EXTRACTION]: '#1BB1D4',
[SubcategoryId.REMOVAL]: '#7882FF',
[SubcategoryId.AUTOMATION]: '#69DC95',
[SubcategoryId.GENERAL]: '#69DC95',
[SubcategoryId.ADVANCED_FORMATTING]: '#F55454',
[SubcategoryId.DEVELOPER_TOOLS]: '#F55454',
[SubcategoryId.SIGNING]: '#FF7892',
[SubcategoryId.DOCUMENT_SECURITY]: '#FF7892',
[SubcategoryId.VERIFICATION]: '#1BB1D4',
[SubcategoryId.DOCUMENT_REVIEW]: '#48BD54',
[SubcategoryId.PAGE_FORMATTING]: '#7882FF',
[SubcategoryId.EXTRACTION]: '#1BB1D4',
[SubcategoryId.REMOVAL]: '#7882FF',
[SubcategoryId.AUTOMATION]: '#69DC95',
[SubcategoryId.GENERAL]: '#69DC95',
[SubcategoryId.ADVANCED_FORMATTING]: '#F55454',
[SubcategoryId.DEVELOPER_TOOLS]: '#F55454',
};
export const getSubcategoryColor = (subcategory: SubcategoryId): string => SUBCATEGORY_COLOR_MAP[subcategory] || '#7882FF';
export const getCategoryLabel = (t: TFunction, id: ToolCategoryId): string => t(`toolPicker.categories.${id}`, id);
export const getSubcategoryLabel = (t: TFunction, id: SubcategoryId): string => t(`toolPicker.subcategories.${id}`, id);
export const getSubcategoryColor = (subcategory: SubcategoryId): string => SUBCATEGORY_COLOR_MAP[subcategory] || '#7882FF';
export const getAllEndpoints = (registry: ToolRegistry): string[] => {
const lists: string[][] = [];
Object.values(registry).forEach(entry => {
if (entry.endpoints && entry.endpoints.length > 0) {
lists.push(entry.endpoints);
}
});
return Array.from(new Set(lists.flat()));
const lists: string[][] = [];
Object.values(registry).forEach(entry => {
if (entry.endpoints && entry.endpoints.length > 0) {
lists.push(entry.endpoints);
}
});
return Array.from(new Set(lists.flat()));
};
export const getConversionEndpoints = (extensionToEndpoint: Record<string, Record<string, string>>): string[] => {
const endpoints = new Set<string>();
Object.values(extensionToEndpoint).forEach(toEndpoints => {
Object.values(toEndpoints).forEach(endpoint => {
endpoints.add(endpoint);
});
});
return Array.from(endpoints);
const endpoints = new Set<string>();
Object.values(extensionToEndpoint).forEach(toEndpoints => {
Object.values(toEndpoints).forEach(endpoint => {
endpoints.add(endpoint);
});
});
return Array.from(endpoints);
};
export const getAllApplicationEndpoints = (
registry: ToolRegistry,
extensionToEndpoint?: Record<string, Record<string, string>>
registry: ToolRegistry,
extensionToEndpoint?: Record<string, Record<string, string>>
): string[] => {
const toolEp = getAllEndpoints(registry);
const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : [];
return Array.from(new Set([...toolEp, ...convEp]));
const toolEp = getAllEndpoints(registry);
const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : [];
return Array.from(new Set([...toolEp, ...convEp]));
};

View File

@ -1,4 +1,5 @@
import React from 'react';
import React, { useMemo } from 'react';
import LocalIcon from '../components/shared/LocalIcon';
import { useTranslation } from 'react-i18next';
import SplitPdfPanel from "../tools/Split";
import CompressPdfPanel from "../tools/Compress";
@ -8,12 +9,36 @@ import Sanitize from '../tools/Sanitize';
import AddPassword from '../tools/AddPassword';
import ChangePermissions from '../tools/ChangePermissions';
import RemovePassword from '../tools/RemovePassword';
import { SubcategoryId, ToolCategory, ToolRegistry } from './toolsTaxonomy';
import { SubcategoryId, ToolCategoryId, ToolRegistry } from './toolsTaxonomy';
import AddWatermark from '../tools/AddWatermark';
import Repair from '../tools/Repair';
import SingleLargePage from '../tools/SingleLargePage';
import UnlockPdfForms from '../tools/UnlockPdfForms';
import RemoveCertificateSign from '../tools/RemoveCertificateSign';
import { compressOperationConfig } from '../hooks/tools/compress/useCompressOperation';
import { splitOperationConfig } from '../hooks/tools/split/useSplitOperation';
import { addPasswordOperationConfig } from '../hooks/tools/addPassword/useAddPasswordOperation';
import { removePasswordOperationConfig } from '../hooks/tools/removePassword/useRemovePasswordOperation';
import { sanitizeOperationConfig } from '../hooks/tools/sanitize/useSanitizeOperation';
import { repairOperationConfig } from '../hooks/tools/repair/useRepairOperation';
import { addWatermarkOperationConfig } from '../hooks/tools/addWatermark/useAddWatermarkOperation';
import { unlockPdfFormsOperationConfig } from '../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation';
import { singleLargePageOperationConfig } from '../hooks/tools/singleLargePage/useSingleLargePageOperation';
import { ocrOperationConfig } from '../hooks/tools/ocr/useOCROperation';
import { convertOperationConfig } from '../hooks/tools/convert/useConvertOperation';
import { removeCertificateSignOperationConfig } from '../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation';
import { changePermissionsOperationConfig } from '../hooks/tools/changePermissions/useChangePermissionsOperation';
import CompressSettings from '../components/tools/compress/CompressSettings';
import SplitSettings from '../components/tools/split/SplitSettings';
import AddPasswordSettings from '../components/tools/addPassword/AddPasswordSettings';
import RemovePasswordSettings from '../components/tools/removePassword/RemovePasswordSettings';
import SanitizeSettings from '../components/tools/sanitize/SanitizeSettings';
import RepairSettings from '../components/tools/repair/RepairSettings';
import UnlockPdfFormsSettings from '../components/tools/unlockPdfForms/UnlockPdfFormsSettings';
import AddWatermarkSingleStepSettings from '../components/tools/addWatermark/AddWatermarkSingleStepSettings';
import OCRSettings from '../components/tools/ocr/OCRSettings';
import ConvertSettings from '../components/tools/convert/ConvertSettings';
import ChangePermissionsSettings from '../components/tools/changePermissions/ChangePermissionsSettings';
const showPlaceholderTools = false; // For development purposes. Allows seeing the full list of tools, even if they're unimplemented
@ -21,526 +46,548 @@ const showPlaceholderTools = false; // For development purposes. Allows seeing t
export function useFlatToolRegistry(): ToolRegistry {
const { t } = useTranslation();
const allTools: ToolRegistry = {
return useMemo(() => {
const allTools: ToolRegistry = {
// Signing
"certSign": {
icon: <span className="material-symbols-rounded">workspace_premium</span>,
icon: <LocalIcon icon="workspace-premium-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.certSign.title", "Sign with Certificate"),
component: null,
view: "sign",
description: t("home.certSign.desc", "Signs a PDF with a Certificate/Key (PEM/P12)"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.SIGNING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.SIGNING
},
"sign": {
icon: <span className="material-symbols-rounded">signature</span>,
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.sign.title", "Sign"),
component: null,
view: "sign",
description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.SIGNING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.SIGNING
},
// Document Security
"addPassword": {
icon: <span className="material-symbols-rounded">password</span>,
icon: <LocalIcon icon="password-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.addPassword.title", "Add Password"),
component: AddPassword,
view: "security",
description: t("home.addPassword.desc", "Add password protection and restrictions to PDF files"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["add-password"]
endpoints: ["add-password"],
operationConfig: addPasswordOperationConfig,
settingsComponent: AddPasswordSettings
},
"watermark": {
icon: <span className="material-symbols-rounded">branding_watermark</span>,
icon: <LocalIcon icon="branding-watermark-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.watermark.title", "Add Watermark"),
component: AddWatermark,
view: "format",
maxFiles: -1,
description: t("home.watermark.desc", "Add a custom watermark to your PDF document."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
endpoints: ["add-watermark"]
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
endpoints: ["add-watermark"],
operationConfig: addWatermarkOperationConfig,
settingsComponent: AddWatermarkSingleStepSettings
},
"add-stamp": {
icon: <span className="material-symbols-rounded">approval</span>,
icon: <LocalIcon icon="approval-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.AddStampRequest.title", "Add Stamp to PDF"),
component: null,
view: "format",
description: t("home.AddStampRequest.desc", "Add text or add image stamps at set locations"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
},
"sanitize": {
icon: <span className="material-symbols-rounded">cleaning_services</span>,
icon: <LocalIcon icon="cleaning-services-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.sanitize.title", "Sanitize"),
component: Sanitize,
view: "security",
maxFiles: -1,
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
description: t("home.sanitize.desc", "Remove potentially harmful elements from PDF files"),
endpoints: ["sanitize-pdf"]
endpoints: ["sanitize-pdf"],
operationConfig: sanitizeOperationConfig,
settingsComponent: SanitizeSettings
},
"flatten": {
icon: <span className="material-symbols-rounded">layers_clear</span>,
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.flatten.title", "Flatten"),
component: null,
view: "format",
description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
},
"unlock-pdf-forms": {
icon: <span className="material-symbols-rounded">preview_off</span>,
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.unlockPDFForms.title", "Unlock PDF Forms"),
component: UnlockPdfForms,
view: "security",
description: t("home.unlockPDFForms.desc", "Remove read-only property of form fields in a PDF document."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["unlock-pdf-forms"]
endpoints: ["unlock-pdf-forms"],
operationConfig: unlockPdfFormsOperationConfig,
settingsComponent: UnlockPdfFormsSettings
},
"manage-certificates": {
icon: <span className="material-symbols-rounded">license</span>,
icon: <LocalIcon icon="license-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.manageCertificates.title", "Manage Certificates"),
component: null,
view: "security",
description: t("home.manageCertificates.desc", "Import, export, or delete digital certificate files used for signing PDFs."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
},
"change-permissions": {
icon: <span className="material-symbols-rounded">lock</span>,
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
name: t("home.changePermissions.title", "Change Permissions"),
component: ChangePermissions,
view: "security",
description: t("home.changePermissions.desc", "Change document restrictions and permissions"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["add-password"]
endpoints: ["add-password"],
operationConfig: changePermissionsOperationConfig,
settingsComponent: ChangePermissionsSettings
},
// Verification
"get-all-info-on-pdf": {
icon: <span className="material-symbols-rounded">fact_check</span>,
icon: <LocalIcon icon="fact-check-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
component: null,
view: "extract",
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.VERIFICATION
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION
},
"validate-pdf-signature": {
icon: <span className="material-symbols-rounded">verified</span>,
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.validateSignature.title", "Validate PDF Signature"),
component: null,
view: "security",
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.VERIFICATION
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION
},
// Document Review
"read": {
icon: <span className="material-symbols-rounded">article</span>,
icon: <LocalIcon icon="article-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.read.title", "Read"),
component: null,
view: "view",
description: t("home.read.desc", "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_REVIEW
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW
},
"change-metadata": {
icon: <span className="material-symbols-rounded">assignment</span>,
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.changeMetadata.title", "Change Metadata"),
component: null,
view: "format",
description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_REVIEW
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW
},
// Page Formatting
"cropPdf": {
icon: <span className="material-symbols-rounded">crop</span>,
icon: <LocalIcon icon="crop-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.crop.title", "Crop PDF"),
component: null,
view: "format",
description: t("home.crop.desc", "Crop a PDF to reduce its size (maintains text!)"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"rotate": {
icon: <span className="material-symbols-rounded">rotate_right</span>,
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.rotate.title", "Rotate"),
component: null,
view: "format",
description: t("home.rotate.desc", "Easily rotate your PDFs."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"splitPdf": {
icon: <span className="material-symbols-rounded">content_cut</span>,
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.split.title", "Split"),
component: SplitPdfPanel,
view: "split",
description: t("home.split.desc", "Split PDFs into multiple documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
operationConfig: splitOperationConfig,
settingsComponent: SplitSettings
},
"reorganize-pages": {
icon: <span className="material-symbols-rounded">move_down</span>,
icon: <LocalIcon icon="move-down-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.reorganizePages.title", "Reorganize Pages"),
component: null,
view: "pageEditor",
description: t("home.reorganizePages.desc", "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"adjust-page-size-scale": {
icon: <span className="material-symbols-rounded">crop_free</span>,
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.scalePages.title", "Adjust page size/scale"),
component: null,
view: "format",
description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"addPageNumbers": {
icon: <span className="material-symbols-rounded">123</span>,
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.addPageNumbers.title", "Add Page Numbers"),
component: null,
view: "format",
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"multi-page-layout": {
icon: <span className="material-symbols-rounded">dashboard</span>,
icon: <LocalIcon icon="dashboard-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.pageLayout.title", "Multi-Page Layout"),
component: null,
view: "format",
description: t("home.pageLayout.desc", "Merge multiple pages of a PDF document into a single page"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"single-large-page": {
icon: <span className="material-symbols-rounded">looks_one</span>,
icon: <LocalIcon icon="looks-one-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"),
component: SingleLargePage,
view: "format",
description: t("home.pdfToSinglePage.desc", "Merges all PDF pages into one large single page"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["pdf-to-single-page"]
endpoints: ["pdf-to-single-page"],
operationConfig: singleLargePageOperationConfig
},
"add-attachments": {
icon: <span className="material-symbols-rounded">attachment</span>,
icon: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.attachments.title", "Add Attachments"),
component: null,
view: "format",
description: t("home.attachments.desc", "Add or remove embedded files (attachments) to/from a PDF"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
},
// Extraction
"extractPages": {
icon: <span className="material-symbols-rounded">upload</span>,
icon: <LocalIcon icon="upload-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.extractPages.title", "Extract Pages"),
component: null,
view: "extract",
description: t("home.extractPages.desc", "Extract specific pages from a PDF document"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.EXTRACTION
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EXTRACTION
},
"extract-images": {
icon: <span className="material-symbols-rounded">filter</span>,
icon: <LocalIcon icon="filter-alt" width="1.5rem" height="1.5rem" />,
name: t("home.extractImages.title", "Extract Images"),
component: null,
view: "extract",
description: t("home.extractImages.desc", "Extract images from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.EXTRACTION
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EXTRACTION
},
// Removal
"removePages": {
icon: <span className="material-symbols-rounded">delete</span>,
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removePages.title", "Remove Pages"),
component: null,
view: "remove",
description: t("home.removePages.desc", "Remove specific pages from a PDF document"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL
},
"remove-blank-pages": {
icon: <span className="material-symbols-rounded">scan_delete</span>,
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removeBlanks.title", "Remove Blank Pages"),
component: null,
view: "remove",
description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL
},
"remove-annotations": {
icon: <span className="material-symbols-rounded">thread_unread</span>,
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removeAnnotations.title", "Remove Annotations"),
component: null,
view: "remove",
description: t("home.removeAnnotations.desc", "Remove annotations and comments from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL
},
"remove-image": {
icon: <span className="material-symbols-rounded">remove_selection</span>,
icon: <LocalIcon icon="remove-selection-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removeImagePdf.title", "Remove Image"),
component: null,
view: "format",
description: t("home.removeImagePdf.desc", "Remove images from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL
},
"remove-password": {
icon: <span className="material-symbols-rounded">lock_open_right</span>,
icon: <LocalIcon icon="lock-open-right-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removePassword.title", "Remove Password"),
component: RemovePassword,
view: "security",
description: t("home.removePassword.desc", "Remove password protection from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
endpoints: ["remove-password"],
maxFiles: -1,
operationConfig: removePasswordOperationConfig,
settingsComponent: RemovePasswordSettings
},
"remove-certificate-sign": {
icon: <span className="material-symbols-rounded">remove_moderator</span>,
icon: <LocalIcon icon="remove-moderator-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.removeCertSign.title", "Remove Certificate Sign"),
component: RemoveCertificateSign,
view: "security",
description: t("home.removeCertSign.desc", "Remove digital signature from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-certificate-sign"]
endpoints: ["remove-certificate-sign"],
operationConfig: removeCertificateSignOperationConfig
},
// Automation
"automate": {
icon: <span className="material-symbols-rounded">automation</span>,
icon: <LocalIcon icon="automation-outline" width="1.5rem" height="1.5rem" />,
name: t("home.automate.title", "Automate"),
component: null,
component: React.lazy(() => import('../tools/Automate')),
view: "format",
description: t("home.automate.desc", "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION,
maxFiles: -1,
endpoints: ["handleData"]
},
"auto-rename-pdf-file": {
icon: <span className="material-symbols-rounded">match_word</span>,
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.auto-rename.title", "Auto Rename PDF File"),
component: null,
view: "format",
description: t("home.auto-rename.desc", "Automatically rename PDF files based on their content"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION
},
"auto-split-pages": {
icon: <span className="material-symbols-rounded">split_scene_right</span>,
icon: <LocalIcon icon="split-scene-right-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.autoSplitPDF.title", "Auto Split Pages"),
component: null,
view: "format",
description: t("home.autoSplitPDF.desc", "Automatically split PDF pages based on content detection"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION
},
"auto-split-by-size-count": {
icon: <span className="material-symbols-rounded">content_cut</span>,
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.autoSizeSplitPDF.title", "Auto Split by Size/Count"),
component: null,
view: "format",
description: t("home.autoSizeSplitPDF.desc", "Automatically split PDFs by file size or page count"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION
},
// Advanced Formatting
"adjustContrast": {
icon: <span className="material-symbols-rounded">palette</span>,
icon: <LocalIcon icon="palette" width="1.5rem" height="1.5rem" />,
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
component: null,
view: "format",
description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"repair": {
icon: <span className="material-symbols-rounded">build</span>,
icon: <LocalIcon icon="build-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.repair.title", "Repair"),
component: Repair,
view: "format",
description: t("home.repair.desc", "Repair corrupted or damaged PDF files"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
endpoints: ["repair"]
endpoints: ["repair"],
operationConfig: repairOperationConfig,
settingsComponent: RepairSettings
},
"detect-split-scanned-photos": {
icon: <span className="material-symbols-rounded">scanner</span>,
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.ScannerImageSplit.title", "Detect & Split Scanned Photos"),
component: null,
view: "format",
description: t("home.ScannerImageSplit.desc", "Detect and split scanned photos into separate pages"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"overlay-pdfs": {
icon: <span className="material-symbols-rounded">layers</span>,
icon: <LocalIcon icon="layers-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.overlay-pdfs.title", "Overlay PDFs"),
component: null,
view: "format",
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"replace-and-invert-color": {
icon: <span className="material-symbols-rounded">format_color_fill</span>,
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.replaceColorPdf.title", "Replace & Invert Color"),
component: null,
view: "format",
description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"add-image": {
icon: <span className="material-symbols-rounded">image</span>,
icon: <LocalIcon icon="image-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.addImage.title", "Add Image"),
component: null,
view: "format",
description: t("home.addImage.desc", "Add images to PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"edit-table-of-contents": {
icon: <span className="material-symbols-rounded">bookmark_add</span>,
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
component: null,
view: "format",
description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"scanner-effect": {
icon: <span className="material-symbols-rounded">scanner</span>,
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.fakeScan.title", "Scanner Effect"),
component: null,
view: "format",
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
// Developer Tools
"show-javascript": {
icon: <span className="material-symbols-rounded">javascript</span>,
icon: <LocalIcon icon="javascript-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.showJS.title", "Show JavaScript"),
component: null,
view: "extract",
description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS
},
"dev-api": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: '#2F7BF6' }} />,
name: t("home.devApi.title", "API"),
component: null,
view: "external",
description: t("home.devApi.desc", "Link to API documentation"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html"
},
"dev-folder-scanning": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: '#2F7BF6' }} />,
name: t("home.devFolderScanning.title", "Automated Folder Scanning"),
component: null,
view: "external",
description: t("home.devFolderScanning.desc", "Link to automated folder scanning guide"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/"
},
"dev-sso-guide": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: '#2F7BF6' }} />,
name: t("home.devSsoGuide.title", "SSO Guide"),
component: null,
view: "external",
description: t("home.devSsoGuide.desc", "Link to SSO guide"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
},
"dev-airgapped": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: '#2F7BF6' }} />,
name: t("home.devAirgapped.title", "Air-gapped Setup"),
component: null,
view: "external",
description: t("home.devAirgapped.desc", "Link to air-gapped setup guide"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Pro/#activation"
},
// Recommended Tools
"compare": {
icon: <span className="material-symbols-rounded">compare</span>,
icon: <LocalIcon icon="compare-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.compare.title", "Compare"),
component: null,
view: "format",
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL
},
"compress": {
icon: <span className="material-symbols-rounded">zoom_in_map</span>,
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.compress.title", "Compress"),
component: CompressPdfPanel,
view: "compress",
description: t("home.compress.desc", "Compress PDFs to reduce their file size."),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
maxFiles: -1
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
operationConfig: compressOperationConfig,
settingsComponent: CompressSettings
},
"convert": {
icon: <span className="material-symbols-rounded">sync_alt</span>,
icon: <LocalIcon icon="sync-alt-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.convert.title", "Convert"),
component: ConvertPanel,
view: "convert",
description: t("home.convert.desc", "Convert files to and from PDF format"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: [
"pdf-to-img",
@ -575,58 +622,63 @@ export function useFlatToolRegistry(): ToolRegistry {
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
]
],
operationConfig: convertOperationConfig,
settingsComponent: ConvertSettings
},
"mergePdfs": {
icon: <span className="material-symbols-rounded">library_add</span>,
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.merge.title", "Merge"),
component: null,
view: "merge",
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1
},
"multi-tool": {
icon: <span className="material-symbols-rounded">dashboard_customize</span>,
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.multiTool.title", "Multi-Tool"),
component: null,
view: "pageEditor",
description: t("home.multiTool.desc", "Use multiple tools on a single PDF document"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1
},
"ocr": {
icon: <span className="material-symbols-rounded">quick_reference_all</span>,
icon: <LocalIcon icon="quick-reference-all-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.ocr.title", "OCR"),
component: OCRPanel,
view: "convert",
description: t("home.ocr.desc", "Extract text from scanned PDFs using Optical Character Recognition"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
maxFiles: -1
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
operationConfig: ocrOperationConfig,
settingsComponent: OCRSettings
},
"redact": {
icon: <span className="material-symbols-rounded">visibility_off</span>,
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.redact.title", "Redact"),
component: null,
view: "redact",
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL
},
};
if (showPlaceholderTools) {
return allTools;
} else {
const filteredTools = Object.keys(allTools)
.filter(key => allTools[key].component !== null || allTools[key].link)
.reduce((obj, key) => {
obj[key] = allTools[key];
return obj;
}, {} as ToolRegistry);
return filteredTools;
}
if (showPlaceholderTools) {
return allTools;
} else {
const filteredTools = Object.keys(allTools)
.filter(key => allTools[key].component !== null || allTools[key].link)
.reduce((obj, key) => {
obj[key] = allTools[key];
return obj;
}, {} as ToolRegistry);
return filteredTools;
}
}, [t]); // Only re-compute when translations change
}

View File

@ -4,4 +4,15 @@ declare module "../components/PageEditor";
declare module "../components/Viewer";
declare module "*.js";
declare module '*.module.css';
declare module 'pdfjs-dist';
declare module 'pdfjs-dist';
// Auto-generated icon set JSON import
declare module '../assets/material-symbols-icons.json' {
const value: {
prefix: string;
icons: Record<string, any>;
width?: number;
height?: number;
};
export default value;
}

View File

@ -26,7 +26,7 @@ import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../sha
describe('useAddPasswordOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0];
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<AddPasswordFullParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],

View File

@ -1,30 +1,45 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddPasswordFullParameters } from './useAddPasswordParameters';
import { AddPasswordFullParameters, defaultParameters } from './useAddPasswordParameters';
import { defaultParameters as permissionsDefaults } from '../changePermissions/useChangePermissionsParameters';
import { getFormData } from '../changePermissions/useChangePermissionsOperation';
// Static function that can be used by both the hook and automation executor
export const buildAddPasswordFormData = (parameters: AddPasswordFullParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
formData.append("ownerPassword", parameters.ownerPassword);
formData.append("keyLength", parameters.keyLength.toString());
getFormData(parameters.permissions).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Full default parameters including permissions for automation
const fullDefaultParameters: AddPasswordFullParameters = {
...defaultParameters,
permissions: permissionsDefaults,
};
// Static configuration object
export const addPasswordOperationConfig = {
operationType: 'addPassword',
endpoint: '/api/v1/security/add-password',
buildFormData: buildAddPasswordFormData,
filePrefix: 'encrypted_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters: fullDefaultParameters,
} as const;
export const useAddPasswordOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: AddPasswordFullParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
formData.append("ownerPassword", parameters.ownerPassword);
formData.append("keyLength", parameters.keyLength.toString());
getFormData(parameters.permissions).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
return useToolOperation<AddPasswordFullParameters>({
operationType: 'addPassword',
endpoint: '/api/v1/security/add-password',
buildFormData,
...addPasswordOperationConfig,
filePrefix: t('addPassword.filenamePrefix', 'encrypted') + '_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(t('addPassword.error.failed', 'An error occurred while encrypting the PDF.'))
});
};

View File

@ -1,9 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddWatermarkParameters } from './useAddWatermarkParameters';
import { AddWatermarkParameters, defaultParameters } from './useAddWatermarkParameters';
const buildFormData = (parameters: AddWatermarkParameters, file: File): FormData => {
// Static function that can be used by both the hook and automation executor
export const buildAddWatermarkFormData = (parameters: AddWatermarkParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
@ -32,15 +33,22 @@ const buildFormData = (parameters: AddWatermarkParameters, file: File): FormData
return formData;
};
// Static configuration object
export const addWatermarkOperationConfig = {
operationType: 'watermark',
endpoint: '/api/v1/security/add-watermark',
buildFormData: buildAddWatermarkFormData,
filePrefix: 'watermarked_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useAddWatermarkOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddWatermarkParameters>({
operationType: 'watermark',
endpoint: '/api/v1/security/add-watermark',
buildFormData,
...addWatermarkOperationConfig,
filePrefix: t('watermark.filenamePrefix', 'watermarked') + '_',
multiFileEndpoint: false, // Individual API calls per file
getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.'))
});
};

View File

@ -46,3 +46,4 @@ export const useAddWatermarkParameters = (): AddWatermarkParametersHook => {
},
});
};

View File

@ -0,0 +1,49 @@
import { useToolOperation } from '../shared/useToolOperation';
import { useCallback } from 'react';
import { executeAutomationSequence } from '../../../utils/automationExecutor';
import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry';
import { AutomateParameters } from '../../../types/automation';
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
export function useAutomateOperation() {
const toolRegistry = useFlatToolRegistry();
const customProcessor = useCallback(async (params: AutomateParameters, files: File[]) => {
console.log('🚀 Starting automation execution via customProcessor', { params, files });
if (!params.automationConfig) {
throw new Error('No automation configuration provided');
}
// Execute the automation sequence and return the final results
const finalResults = await executeAutomationSequence(
params.automationConfig!,
files,
toolRegistry,
(stepIndex: number, operationName: string) => {
console.log(`Step ${stepIndex + 1} started: ${operationName}`);
params.onStepStart?.(stepIndex, operationName);
},
(stepIndex: number, resultFiles: File[]) => {
console.log(`Step ${stepIndex + 1} completed with ${resultFiles.length} files`);
params.onStepComplete?.(stepIndex, resultFiles);
},
(stepIndex: number, error: string) => {
console.error(`Step ${stepIndex + 1} failed:`, error);
params.onStepError?.(stepIndex, error);
throw new Error(`Automation step ${stepIndex + 1} failed: ${error}`);
}
);
console.log(`✅ Automation completed, returning ${finalResults.length} files`);
return finalResults;
}, [toolRegistry]);
return useToolOperation<AutomateParameters>({
operationType: 'automate',
endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor
buildFormData: () => new FormData(), // Not used with customProcessor
customProcessor,
filePrefix: AUTOMATION_CONSTANTS.FILE_PREFIX
});
}

View File

@ -0,0 +1,114 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
interface UseAutomationFormProps {
mode: AutomationMode;
existingAutomation?: AutomationConfig;
toolRegistry: Record<string, ToolRegistryEntry>;
}
export function useAutomationForm({ mode, existingAutomation, toolRegistry }: UseAutomationFormProps) {
const { t } = useTranslation();
const [automationName, setAutomationName] = useState('');
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = (operation: string) => {
const tool = toolRegistry?.[operation] as any;
return tool?.name || t(`tools.${operation}.name`, operation);
};
const getToolDefaultParameters = (operation: string): Record<string, any> => {
const config = toolRegistry[operation]?.operationConfig;
if (config?.defaultParameters) {
return { ...config.defaultParameters };
}
return {};
};
// Initialize based on mode and existing automation
useEffect(() => {
if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) {
setAutomationName(existingAutomation.name || '');
const operations = existingAutomation.operations || [];
const tools = operations.map((op, index) => {
const operation = typeof op === 'string' ? op : op.operation;
return {
id: `${operation}-${Date.now()}-${index}`,
operation: operation,
name: getToolName(operation),
configured: mode === AutomationMode.EDIT ? true : false,
parameters: typeof op === 'object' ? op.parameters || {} : {}
};
});
setSelectedTools(tools);
} else if (mode === AutomationMode.CREATE && selectedTools.length === 0) {
// Initialize with default empty tools for new automation
const defaultTools = Array.from({ length: AUTOMATION_CONSTANTS.DEFAULT_TOOL_COUNT }, (_, index) => ({
id: `tool-${index + 1}-${Date.now()}`,
operation: '',
name: t('automate.creation.tools.selectTool', 'Select a tool...'),
configured: false,
parameters: {}
}));
setSelectedTools(defaultTools);
}
}, [mode, existingAutomation, selectedTools.length, t, getToolName]);
const addTool = (operation: string) => {
const newTool: AutomationTool = {
id: `${operation}-${Date.now()}`,
operation,
name: getToolName(operation),
configured: false,
parameters: getToolDefaultParameters(operation)
};
setSelectedTools([...selectedTools, newTool]);
};
const removeTool = (index: number) => {
if (selectedTools.length <= AUTOMATION_CONSTANTS.MIN_TOOL_COUNT) return;
setSelectedTools(selectedTools.filter((_, i) => i !== index));
};
const updateTool = (index: number, updates: Partial<AutomationTool>) => {
const updatedTools = [...selectedTools];
updatedTools[index] = { ...updatedTools[index], ...updates };
setSelectedTools(updatedTools);
};
const hasUnsavedChanges = () => {
return (
automationName.trim() !== '' ||
selectedTools.some(tool => tool.operation !== '' || tool.configured)
);
};
const canSaveAutomation = () => {
return (
automationName.trim() !== '' &&
selectedTools.length > 0 &&
selectedTools.every(tool => tool.configured && tool.operation !== '')
);
};
return {
automationName,
setAutomationName,
selectedTools,
setSelectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters
};
}

View File

@ -0,0 +1,55 @@
import { useState, useEffect, useCallback } from 'react';
import { AutomationConfig } from '../../../services/automationStorage';
export interface SavedAutomation extends AutomationConfig {}
export function useSavedAutomations() {
const [savedAutomations, setSavedAutomations] = useState<SavedAutomation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const loadSavedAutomations = useCallback(async () => {
try {
setLoading(true);
setError(null);
const { automationStorage } = await import('../../../services/automationStorage');
const automations = await automationStorage.getAllAutomations();
setSavedAutomations(automations);
} catch (err) {
console.error('Error loading saved automations:', err);
setError(err as Error);
setSavedAutomations([]);
} finally {
setLoading(false);
}
}, []);
const refreshAutomations = useCallback(() => {
loadSavedAutomations();
}, [loadSavedAutomations]);
const deleteAutomation = useCallback(async (id: string) => {
try {
const { automationStorage } = await import('../../../services/automationStorage');
await automationStorage.deleteAutomation(id);
// Refresh the list after deletion
refreshAutomations();
} catch (err) {
console.error('Error deleting automation:', err);
throw err;
}
}, [refreshAutomations]);
// Load automations on mount
useEffect(() => {
loadSavedAutomations();
}, [loadSavedAutomations]);
return {
savedAutomations,
loading,
error,
refreshAutomations,
deleteAutomation
};
}

View File

@ -0,0 +1,142 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import React from 'react';
import LocalIcon from '../../../components/shared/LocalIcon';
import { SuggestedAutomation } from '../../../types/automation';
// Create icon components
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
const TextFieldsIcon = () => React.createElement(LocalIcon, { icon: 'text-fields', width: '1.5rem', height: '1.5rem' });
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });
export function useSuggestedAutomations(): SuggestedAutomation[] {
const { t } = useTranslation();
const suggestedAutomations = useMemo<SuggestedAutomation[]>(() => {
const now = new Date().toISOString();
return [
{
id: "compress-and-split",
name: t("automation.suggested.compressAndSplit", "Compress & Split"),
description: t("automation.suggested.compressAndSplitDesc", "Compress PDFs and split them by pages"),
operations: [
{
operation: "compress",
parameters: {
compressionLevel: 5,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
}
},
{
operation: "splitPdf",
parameters: {
mode: 'bySizeOrCount',
pages: '1',
hDiv: '2',
vDiv: '2',
merge: false,
splitType: 'pages',
splitValue: '1',
bookmarkLevel: '1',
includeMetadata: false,
allowDuplicates: false,
}
}
],
createdAt: now,
updatedAt: now,
icon: CompressIcon,
},
{
id: "ocr-workflow",
name: t("automation.suggested.ocrWorkflow", "OCR Processing"),
description: t("automation.suggested.ocrWorkflowDesc", "Extract text from PDFs using OCR technology"),
operations: [
{
operation: "ocr",
parameters: {
languages: ['eng'],
ocrType: 'skip-text',
ocrRenderType: 'hocr',
additionalOptions: [],
}
}
],
createdAt: now,
updatedAt: now,
icon: TextFieldsIcon,
},
{
id: "secure-workflow",
name: t("automation.suggested.secureWorkflow", "Security Workflow"),
description: t("automation.suggested.secureWorkflowDesc", "Sanitize PDFs and add password protection"),
operations: [
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
}
},
{
operation: "addPassword",
parameters: {
password: 'password',
ownerPassword: '',
keyLength: 128,
permissions: {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
}
}
}
],
createdAt: now,
updatedAt: now,
icon: SecurityIcon,
},
{
id: "optimization-workflow",
name: t("automation.suggested.optimizationWorkflow", "Optimization Workflow"),
description: t("automation.suggested.optimizationWorkflowDesc", "Repair and compress PDFs for better performance"),
operations: [
{
operation: "repair",
parameters: {}
},
{
operation: "compress",
parameters: {
compressionLevel: 7,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
}
}
],
createdAt: now,
updatedAt: now,
icon: StarIcon,
},
];
}, [t]);
return suggestedAutomations;
}

View File

@ -25,7 +25,7 @@ import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../sha
describe('useChangePermissionsOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<ChangePermissionsParameters> => mockUseToolOperation.mock.calls[0][0];
const getToolConfig = (): ToolOperationConfig<ChangePermissionsParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<ChangePermissionsParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
@ -109,7 +109,7 @@ describe('useChangePermissionsOperation', () => {
{ property: 'multiFileEndpoint' as const, expectedValue: false },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
{ property: 'filePrefix' as const, expectedValue: 'permissions_' },
{ property: 'operationType' as const, expectedValue: 'changePermissions' }
{ property: 'operationType' as const, expectedValue: 'change-permissions' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useChangePermissionsOperation());

View File

@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import type { ChangePermissionsParameters } from './useChangePermissionsParameters';
import { ChangePermissionsParameters, defaultParameters } from './useChangePermissionsParameters';
export const getFormData = ((parameters: ChangePermissionsParameters) =>
Object.entries(parameters).map(([key, value]) =>
@ -9,27 +9,34 @@ export const getFormData = ((parameters: ChangePermissionsParameters) =>
) as string[][]
);
// Static function that can be used by both the hook and automation executor
export const buildChangePermissionsFormData = (parameters: ChangePermissionsParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Static configuration object
export const changePermissionsOperationConfig = {
operationType: 'change-permissions',
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
buildFormData: buildChangePermissionsFormData,
filePrefix: 'permissions_',
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useChangePermissionsOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: ChangePermissionsParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
return useToolOperation({
operationType: 'changePermissions',
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
buildFormData,
filePrefix: 'permissions_',
multiFileEndpoint: false,
...changePermissionsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('changePermissions.error.failed', 'An error occurred while changing PDF permissions.')
)

View File

@ -1,9 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { CompressParameters } from './useCompressParameters';
import { CompressParameters, defaultParameters } from './useCompressParameters';
const buildFormData = (parameters: CompressParameters, file: File): FormData => {
// Static configuration that can be used by both the hook and automation executor
export const buildCompressFormData = (parameters: CompressParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
@ -21,15 +22,21 @@ const buildFormData = (parameters: CompressParameters, file: File): FormData =>
return formData;
};
// Static configuration object
export const compressOperationConfig = {
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: buildCompressFormData,
filePrefix: 'compressed_',
multiFileEndpoint: false, // Individual API calls per file
defaultParameters,
} as const;
export const useCompressOperation = () => {
const { t } = useTranslation();
return useToolOperation<CompressParameters>({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData,
filePrefix: 'compressed_',
multiFileEndpoint: false, // Individual API calls per file
...compressOperationConfig,
getErrorMessage: createStandardErrorHandler(t('compress.error.failed', 'An error occurred while compressing the PDF.'))
});
};

View File

@ -10,7 +10,7 @@ export interface CompressParameters extends BaseParameters {
fileSizeUnit: 'KB' | 'MB';
}
const defaultParameters: CompressParameters = {
export const defaultParameters: CompressParameters = {
compressionLevel: 5,
grayscale: false,
expectedSize: '',

View File

@ -1,13 +1,14 @@
import { useCallback } from 'react';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from './useConvertParameters';
import { ConvertParameters, defaultParameters } from './useConvertParameters';
import { detectFileExtension } from '../../../utils/fileUtils';
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
const shouldProcessFilesSeparately = (
// Static function that can be used by both the hook and automation executor
export const shouldProcessFilesSeparately = (
selectedFiles: File[],
parameters: ConvertParameters
): boolean => {
@ -29,7 +30,8 @@ const shouldProcessFilesSeparately = (
);
};
const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
// Static function that can be used by both the hook and automation executor
export const buildConvertFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
const formData = new FormData();
selectedFiles.forEach(file => {
@ -69,7 +71,8 @@ const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): Fo
return formData;
};
const createFileFromResponse = (
// Static function that can be used by both the hook and automation executor
export const createFileFromResponse = (
responseData: any,
headers: any,
originalFileName: string,
@ -81,6 +84,59 @@ const createFileFromResponse = (
return createFileFromApiResponse(responseData, headers, fallbackFilename);
};
// Static processor that can be used by both the hook and automation executor
export const convertProcessor = async (
parameters: ConvertParameters,
selectedFiles: File[]
): Promise<File[]> => {
const processedFiles: File[] = [];
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
if (!endpoint) {
throw new Error('Unsupported conversion format');
}
// Convert-specific routing logic: decide batch vs individual processing
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
// Individual processing for complex cases (PDF→image, smart detection, etc.)
for (const file of selectedFiles) {
try {
const formData = buildConvertFormData(parameters, [file]);
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
processedFiles.push(convertedFile);
} catch (error) {
console.warn(`Failed to convert file ${file.name}:`, error);
}
}
} else {
// Batch processing for simple cases (image→PDF combine)
const formData = buildConvertFormData(parameters, selectedFiles);
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
const baseFilename = selectedFiles.length === 1
? selectedFiles[0].name
: 'converted_files';
const convertedFile = createFileFromResponse(response.data, response.headers, baseFilename, parameters.toExtension);
processedFiles.push(convertedFile);
}
return processedFiles;
};
// Static configuration object
export const convertOperationConfig = {
operationType: 'convert',
endpoint: '', // Not used with customProcessor but required
buildFormData: buildConvertFormData, // Not used with customProcessor but required
filePrefix: 'converted_',
customProcessor: convertProcessor,
defaultParameters,
} as const;
export const useConvertOperation = () => {
const { t } = useTranslation();
@ -88,52 +144,12 @@ export const useConvertOperation = () => {
parameters: ConvertParameters,
selectedFiles: File[]
): Promise<File[]> => {
const processedFiles: File[] = [];
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
if (!endpoint) {
throw new Error(t('errorNotSupported', 'Unsupported conversion format'));
}
// Convert-specific routing logic: decide batch vs individual processing
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
// Individual processing for complex cases (PDF→image, smart detection, etc.)
for (const file of selectedFiles) {
try {
const formData = buildFormData(parameters, [file]);
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
processedFiles.push(convertedFile);
} catch (error) {
console.warn(`Failed to convert file ${file.name}:`, error);
}
}
} else {
// Batch processing for simple cases (image→PDF combine)
const formData = buildFormData(parameters, selectedFiles);
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
const baseFilename = selectedFiles.length === 1
? selectedFiles[0].name
: 'converted_files';
const convertedFile = createFileFromResponse(response.data, response.headers, baseFilename, parameters.toExtension);
processedFiles.push(convertedFile);
}
return processedFiles;
}, [t]);
return convertProcessor(parameters, selectedFiles);
}, []);
return useToolOperation<ConvertParameters>({
operationType: 'convert',
endpoint: '', // Not used with customProcessor but required
buildFormData, // Not used with customProcessor but required
filePrefix: 'converted_',
customProcessor: customConvertProcessor, // Convert handles its own routing
...convertOperationConfig,
customProcessor: customConvertProcessor, // Use instance-specific processor for translation support
getErrorMessage: (error) => {
if (error.response?.data && typeof error.response.data === 'string') {
return error.response.data;

View File

@ -8,7 +8,7 @@ import {
type OutputOption,
type FitOption
} from '../../../constants/convertConstants';
import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat, getAvailableToExtensions as getAvailableToExtensionsUtil } from '../../../utils/convertUtils';
import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils';
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
@ -47,7 +47,7 @@ export interface ConvertParametersHook extends BaseParametersHook<ConvertParamet
analyzeFileTypes: (files: Array<{name: string}>) => void;
}
const defaultParameters: ConvertParameters = {
export const defaultParameters: ConvertParameters = {
fromExtension: '',
toExtension: '',
imageOptions: {
@ -155,30 +155,7 @@ export const useConvertParameters = (): ConvertParametersHook => {
return getEndpointUrl(fromExtension, toExtension);
};
const getAvailableToExtensions = (fromExtension: string) => {
if (!fromExtension) return [];
// Handle dynamic format identifiers (file-<extension>)
if (fromExtension.startsWith('file-')) {
// Dynamic format - use 'any' conversion options (file-to-pdf)
const supportedExtensions = CONVERSION_MATRIX['any'] || [];
return TO_FORMAT_OPTIONS.filter(option =>
supportedExtensions.includes(option.value)
);
}
let supportedExtensions = CONVERSION_MATRIX[fromExtension] || [];
// If no explicit conversion exists, but file-to-pdf might be available,
// fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf)
if (supportedExtensions.length === 0 && fromExtension !== 'any') {
supportedExtensions = CONVERSION_MATRIX['any'] || [];
}
return TO_FORMAT_OPTIONS.filter(option =>
supportedExtensions.includes(option.value)
);
};
const getAvailableToExtensions = getAvailableToExtensionsUtil;
const analyzeFileTypes = useCallback((files: Array<{name: string}>) => {

View File

@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { OCRParameters } from './useOCRParameters';
import { OCRParameters, defaultParameters } from './useOCRParameters';
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { useToolResources } from '../shared/useToolResources';
@ -37,7 +37,8 @@ function stripExt(name: string): string {
return i > 0 ? name.slice(0, i) : name;
}
const buildFormData = (parameters: OCRParameters, file: File): FormData => {
// Static function that can be used by both the hook and automation executor
export const buildOCRFormData = (parameters: OCRParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
parameters.languages.forEach((lang) => formData.append('languages', lang));
@ -51,57 +52,70 @@ const buildFormData = (parameters: OCRParameters, file: File): FormData => {
return formData;
};
// Static response handler for OCR - can be used by automation executor
export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extractZipFiles: (blob: Blob) => Promise<File[]>): Promise<File[]> => {
const headBuf = await blob.slice(0, 8).arrayBuffer();
const head = new TextDecoder().decode(new Uint8Array(headBuf));
// ZIP: sidecar or multi-asset output
if (head.startsWith('PK')) {
const base = stripExt(originalFiles[0].name);
try {
const extractedFiles = await extractZipFiles(blob);
if (extractedFiles.length > 0) return extractedFiles;
} catch { /* ignore and try local extractor */ }
try {
const local = await extractZipFile(blob); // local fallback
if (local.length > 0) return local;
} catch { /* fall through */ }
return [new File([blob], `ocr_${base}.zip`, { type: 'application/zip' })];
}
// Not a PDF: surface error details if present
if (!head.startsWith('%PDF')) {
const textBuf = await blob.slice(0, 1024).arrayBuffer();
const text = new TextDecoder().decode(new Uint8Array(textBuf));
if (/error|exception|html/i.test(text)) {
if (text.includes('OCR tools') && text.includes('not installed')) {
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
}
const title =
text.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1] ||
text.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ||
'Unknown error';
throw new Error(`OCR service error: ${title}`);
}
throw new Error(`Response is not a valid PDF. Header: "${head}"`);
}
const base = stripExt(originalFiles[0].name);
return [new File([blob], `ocr_${base}.pdf`, { type: 'application/pdf' })];
};
// Static configuration object (without t function dependencies)
export const ocrOperationConfig = {
operationType: 'ocr',
endpoint: '/api/v1/misc/ocr-pdf',
buildFormData: buildOCRFormData,
filePrefix: 'ocr_',
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useOCROperation = () => {
const { t } = useTranslation();
const { extractZipFiles } = useToolResources();
// OCR-specific parsing: ZIP (sidecar) vs PDF vs HTML error
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
const headBuf = await blob.slice(0, 8).arrayBuffer();
const head = new TextDecoder().decode(new Uint8Array(headBuf));
// ZIP: sidecar or multi-asset output
if (head.startsWith('PK')) {
const base = stripExt(originalFiles[0].name);
try {
const extracted = await extractZipFiles(blob);
if (extracted.length > 0) return extracted;
} catch { /* ignore and try local extractor */ }
try {
const local = await extractZipFile(blob); // local fallback
if (local.length > 0) return local;
} catch { /* fall through */ }
return [new File([blob], `ocr_${base}.zip`, { type: 'application/zip' })];
}
// Not a PDF: surface error details if present
if (!head.startsWith('%PDF')) {
const textBuf = await blob.slice(0, 1024).arrayBuffer();
const text = new TextDecoder().decode(new Uint8Array(textBuf));
if (/error|exception|html/i.test(text)) {
if (text.includes('OCR tools') && text.includes('not installed')) {
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
}
const title =
text.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1] ||
text.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ||
t('ocr.error.unknown', 'Unknown error');
throw new Error(`OCR service error: ${title}`);
}
throw new Error(`Response is not a valid PDF. Header: "${head}"`);
}
const base = stripExt(originalFiles[0].name);
return [new File([blob], `ocr_${base}.pdf`, { type: 'application/pdf' })];
}, [t, extractZipFiles]);
// extractZipFiles from useToolResources already returns File[] directly
const simpleExtractZipFiles = extractZipFiles;
return ocrResponseHandler(blob, originalFiles, simpleExtractZipFiles);
}, [extractZipFiles]);
const ocrConfig: ToolOperationConfig<OCRParameters> = {
operationType: 'ocr',
endpoint: '/api/v1/misc/ocr-pdf',
buildFormData,
filePrefix: 'ocr_',
multiFileEndpoint: false, // Process files individually
responseHandler, // use shared flow
...ocrOperationConfig,
responseHandler,
getErrorMessage: (error) =>
error.message?.includes('OCR tools') && error.message?.includes('not installed')
? 'OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.'

View File

@ -10,7 +10,7 @@ export interface OCRParameters extends BaseParameters {
export type OCRParametersHook = BaseParametersHook<OCRParameters>;
const defaultParameters: OCRParameters = {
export const defaultParameters: OCRParameters = {
languages: [],
ocrType: 'skip-text',
ocrRenderType: 'hocr',

View File

@ -1,23 +1,31 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemoveCertificateSignParameters } from './useRemoveCertificateSignParameters';
import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters';
// Static function that can be used by both the hook and automation executor
export const buildRemoveCertificateSignFormData = (parameters: RemoveCertificateSignParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
// Static configuration object
export const removeCertificateSignOperationConfig = {
operationType: 'remove-certificate-sign',
endpoint: '/api/v1/security/remove-cert-sign',
buildFormData: buildRemoveCertificateSignFormData,
filePrefix: 'unsigned_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useRemoveCertificateSignOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: RemoveCertificateSignParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
return useToolOperation<RemoveCertificateSignParameters>({
operationType: 'removeCertificateSign',
endpoint: '/api/v1/security/remove-cert-sign',
buildFormData,
...removeCertificateSignOperationConfig,
filePrefix: t('removeCertSign.filenamePrefix', 'unsigned') + '_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.'))
});
};

View File

@ -25,7 +25,7 @@ import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../sha
describe('useRemovePasswordOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<RemovePasswordParameters> => mockUseToolOperation.mock.calls[0][0];
const getToolConfig = (): ToolOperationConfig<RemovePasswordParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<RemovePasswordParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],

View File

@ -1,24 +1,32 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemovePasswordParameters } from './useRemovePasswordParameters';
import { RemovePasswordParameters, defaultParameters } from './useRemovePasswordParameters';
// Static function that can be used by both the hook and automation executor
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
return formData;
};
// Static configuration object
export const removePasswordOperationConfig = {
operationType: 'removePassword',
endpoint: '/api/v1/security/remove-password',
buildFormData: buildRemovePasswordFormData,
filePrefix: 'decrypted_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useRemovePasswordOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
return formData;
};
return useToolOperation<RemovePasswordParameters>({
operationType: 'removePassword',
endpoint: '/api/v1/security/remove-password',
buildFormData,
...removePasswordOperationConfig,
filePrefix: t('removePassword.filenamePrefix', 'decrypted') + '_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.'))
});
};

View File

@ -1,23 +1,31 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RepairParameters } from './useRepairParameters';
import { RepairParameters, defaultParameters } from './useRepairParameters';
// Static function that can be used by both the hook and automation executor
export const buildRepairFormData = (parameters: RepairParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
// Static configuration object
export const repairOperationConfig = {
operationType: 'repair',
endpoint: '/api/v1/misc/repair',
buildFormData: buildRepairFormData,
filePrefix: 'repaired_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useRepairOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: RepairParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
return useToolOperation<RepairParameters>({
operationType: 'repair',
endpoint: '/api/v1/misc/repair',
buildFormData,
...repairOperationConfig,
filePrefix: t('repair.filenamePrefix', 'repaired') + '_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.'))
});
};

View File

@ -1,9 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SanitizeParameters } from './useSanitizeParameters';
import { SanitizeParameters, defaultParameters } from './useSanitizeParameters';
const buildFormData = (parameters: SanitizeParameters, file: File): FormData => {
// Static function that can be used by both the hook and automation executor
export const buildSanitizeFormData = (parameters: SanitizeParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
@ -18,15 +19,22 @@ const buildFormData = (parameters: SanitizeParameters, file: File): FormData =>
return formData;
};
// Static configuration object
export const sanitizeOperationConfig = {
operationType: 'sanitize',
endpoint: '/api/v1/security/sanitize-pdf',
buildFormData: buildSanitizeFormData,
filePrefix: 'sanitized_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useSanitizeOperation = () => {
const { t } = useTranslation();
return useToolOperation<SanitizeParameters>({
operationType: 'sanitize',
endpoint: '/api/v1/security/sanitize-pdf',
buildFormData,
...sanitizeOperationConfig,
filePrefix: t('sanitize.filenamePrefix', 'sanitized') + '_',
multiFileEndpoint: false, // Individual API calls per file
getErrorMessage: createStandardErrorHandler(t('sanitize.error.failed', 'An error occurred while sanitising the PDF.'))
});
};

View File

@ -61,6 +61,9 @@ export interface ToolOperationConfig<TParams = void> {
/** Extract user-friendly error messages from API errors */
getErrorMessage?: (error: any) => string;
/** Default parameter values for automation */
defaultParameters?: TParams;
}
/**

View File

@ -1,23 +1,31 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SingleLargePageParameters } from './useSingleLargePageParameters';
import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters';
// Static function that can be used by both the hook and automation executor
export const buildSingleLargePageFormData = (parameters: SingleLargePageParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
// Static configuration object
export const singleLargePageOperationConfig = {
operationType: 'single-large-page',
endpoint: '/api/v1/general/pdf-to-single-page',
buildFormData: buildSingleLargePageFormData,
filePrefix: 'single_page_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useSingleLargePageOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: SingleLargePageParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
return useToolOperation<SingleLargePageParameters>({
operationType: 'singleLargePage',
endpoint: '/api/v1/general/pdf-to-single-page',
buildFormData,
...singleLargePageOperationConfig,
filePrefix: t('pdfToSinglePage.filenamePrefix', 'single_page') + '_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.'))
});
};

View File

@ -1,11 +1,11 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SplitParameters } from './useSplitParameters';
import { SplitParameters, defaultParameters } from './useSplitParameters';
import { SPLIT_MODES } from '../../../constants/splitConstants';
const buildFormData = (parameters: SplitParameters, selectedFiles: File[]): FormData => {
// Static functions that can be used by both the hook and automation executor
export const buildSplitFormData = (parameters: SplitParameters, selectedFiles: File[]): FormData => {
const formData = new FormData();
selectedFiles.forEach(file => {
@ -40,7 +40,7 @@ const buildFormData = (parameters: SplitParameters, selectedFiles: File[]): Form
return formData;
};
const getEndpoint = (parameters: SplitParameters): string => {
export const getSplitEndpoint = (parameters: SplitParameters): string => {
switch (parameters.mode) {
case SPLIT_MODES.BY_PAGES:
return "/api/v1/general/split-pages";
@ -55,15 +55,21 @@ const getEndpoint = (parameters: SplitParameters): string => {
}
};
// Static configuration object
export const splitOperationConfig = {
operationType: 'splitPdf',
endpoint: getSplitEndpoint,
buildFormData: buildSplitFormData,
filePrefix: 'split_',
multiFileEndpoint: true, // Single API call with all files
defaultParameters,
} as const;
export const useSplitOperation = () => {
const { t } = useTranslation();
return useToolOperation<SplitParameters>({
operationType: 'split',
endpoint: (params) => getEndpoint(params),
buildFormData: buildFormData, // Multi-file signature: (params, selectedFiles) => FormData
filePrefix: 'split_',
multiFileEndpoint: true, // Single API call with all files
...splitOperationConfig,
getErrorMessage: createStandardErrorHandler(t('split.error.failed', 'An error occurred while splitting the PDF.'))
});
};

View File

@ -17,7 +17,7 @@ export interface SplitParameters extends BaseParameters {
export type SplitParametersHook = BaseParametersHook<SplitParameters>;
const defaultParameters: SplitParameters = {
export const defaultParameters: SplitParameters = {
mode: '',
pages: '',
hDiv: '2',

View File

@ -1,23 +1,31 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { UnlockPdfFormsParameters } from './useUnlockPdfFormsParameters';
import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters';
// Static function that can be used by both the hook and automation executor
export const buildUnlockPdfFormsFormData = (parameters: UnlockPdfFormsParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
// Static configuration object
export const unlockPdfFormsOperationConfig = {
operationType: 'unlock-pdf-forms',
endpoint: '/api/v1/misc/unlock-pdf-forms',
buildFormData: buildUnlockPdfFormsFormData,
filePrefix: 'unlocked_forms_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useUnlockPdfFormsOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: UnlockPdfFormsParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
return formData;
};
return useToolOperation<UnlockPdfFormsParameters>({
operationType: 'unlockPdfForms',
endpoint: '/api/v1/misc/unlock-pdf-forms',
buildFormData,
...unlockPdfFormsOperationConfig,
filePrefix: t('unlockPDFForms.filenamePrefix', 'unlocked_forms') + '_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(t('unlockPDFForms.error.failed', 'An error occurred while unlocking PDF forms.'))
});
};

View File

@ -0,0 +1,46 @@
import { useEffect, useMemo } from 'react';
import { useRightRail } from '../contexts/RightRailContext';
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
export interface RightRailButtonWithAction extends RightRailButtonConfig {
onClick: RightRailAction;
}
/**
* Registers one or more RightRail buttons and their actions.
* - Automatically registers on mount and unregisters on unmount
* - Updates registration when the input array reference changes
*/
export function useRightRailButtons(buttons: readonly RightRailButtonWithAction[]) {
const { registerButtons, unregisterButtons, setAction } = useRightRail();
// Memoize configs and ids to reduce churn
const configs: RightRailButtonConfig[] = useMemo(
() => buttons.map(({ onClick, ...cfg }) => cfg),
[buttons]
);
const ids: string[] = useMemo(() => buttons.map(b => b.id), [buttons]);
useEffect(() => {
if (!buttons || buttons.length === 0) return;
// DEV warnings for duplicate ids or missing handlers
if (process.env.NODE_ENV === 'development') {
const idSet = new Set<string>();
buttons.forEach(b => {
if (!b.onClick) console.warn('[RightRail] Missing onClick for id:', b.id);
if (idSet.has(b.id)) console.warn('[RightRail] Duplicate id in buttons array:', b.id);
idSet.add(b.id);
});
}
// Register visual button configs (idempotent merge by id)
registerButtons(configs);
// Bind/update actions independent of registration
buttons.forEach(({ id, onClick }) => setAction(id, onClick));
// Cleanup unregisters by ids present in this call
return () => unregisterButtons(ids);
}, [registerButtons, unregisterButtons, setAction, configs, ids, buttons]);
}

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
// Material UI Icons
import CompressIcon from '@mui/icons-material/Compress';
@ -9,7 +9,7 @@ import CropIcon from '@mui/icons-material/Crop';
import TextFieldsIcon from '@mui/icons-material/TextFields';
export interface SuggestedTool {
name: string;
id: string /* FIX ME: Should be ToolId */;
title: string;
icon: React.ComponentType<any>;
navigate: () => void;
@ -17,43 +17,44 @@ export interface SuggestedTool {
const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
{
name: 'compress',
id: 'compress',
title: 'Compress',
icon: CompressIcon
},
{
name: 'convert',
id: 'convert',
title: 'Convert',
icon: SwapHorizIcon
},
{
name: 'sanitize',
id: 'sanitize',
title: 'Sanitize',
icon: CleaningServicesIcon
},
{
name: 'split',
id: 'split',
title: 'Split',
icon: CropIcon
},
{
name: 'ocr',
id: 'ocr',
title: 'OCR',
icon: TextFieldsIcon
}
];
export function useSuggestedTools(): SuggestedTool[] {
const { handleToolSelect, selectedToolKey } = useToolWorkflow();
const { actions } = useNavigationActions();
const { selectedToolKey } = useNavigationState();
return useMemo(() => {
// Filter out the current tool
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.name !== selectedToolKey);
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedToolKey);
// Add navigation function to each tool
return filteredTools.map(tool => ({
...tool,
navigate: () => handleToolSelect(tool.name)
navigate: () => actions.handleToolSelect(tool.id)
}));
}, [selectedToolKey, handleToolSelect]);
}
}, [selectedToolKey, actions]);
}

View File

@ -5,19 +5,16 @@ import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
interface ToolManagementResult {
selectedToolKey: string | null;
selectedTool: ToolRegistryEntry | null;
toolSelectedFileIds: string[];
toolRegistry: Record<string, ToolRegistryEntry>;
selectTool: (toolKey: string) => void;
clearToolSelection: () => void;
setToolSelectedFileIds: (fileIds: string[]) => void;
getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null;
}
export const useToolManagement = (): ToolManagementResult => {
const { t } = useTranslation();
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
// Build endpoints list from registry entries with fallback to legacy mapping
@ -56,35 +53,15 @@ export const useToolManagement = (): ToolManagementResult => {
return availableToolRegistry;
}, [isToolAvailable, t, baseRegistry]);
useEffect(() => {
if (!endpointsLoading && selectedToolKey && !toolRegistry[selectedToolKey]) {
const firstAvailableTool = Object.keys(toolRegistry)[0];
if (firstAvailableTool) {
setSelectedToolKey(firstAvailableTool);
} else {
setSelectedToolKey(null);
}
}
}, [endpointsLoading, selectedToolKey, toolRegistry]);
const selectTool = useCallback((toolKey: string) => {
setSelectedToolKey(toolKey);
}, []);
const clearToolSelection = useCallback(() => {
setSelectedToolKey(null);
}, []);
const selectedTool = selectedToolKey ? toolRegistry[selectedToolKey] : null;
const getSelectedTool = useCallback((toolKey: string | null): ToolRegistryEntry | null => {
return toolKey ? toolRegistry[toolKey] || null : null;
}, [toolRegistry]);
return {
selectedToolKey,
selectedTool,
selectedTool: getSelectedTool(null), // This will be unused, kept for compatibility
toolSelectedFileIds,
toolRegistry,
selectTool,
clearToolSelection,
setToolSelectedFileIds,
getSelectedTool,
};
};

View File

@ -1,65 +1,87 @@
import { useMemo } from 'react';
import { SUBCATEGORY_ORDER, ToolCategory, ToolRegistryEntry } from '../data/toolsTaxonomy';
import { SUBCATEGORY_ORDER, SubcategoryId, ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy';
import { useTranslation } from 'react-i18next';
type SubcategoryIdMap = {
[subcategoryId in SubcategoryId]: Array<{ id: string /* FIX ME: Should be ToolId */; tool: ToolRegistryEntry }>;
}
type GroupedTools = {
[category: string]: {
[subcategory: string]: Array<{ id: string; tool: ToolRegistryEntry }>;
};
[categoryId in ToolCategoryId]: SubcategoryIdMap;
};
export function useToolSections(filteredTools: [string, ToolRegistryEntry][]) {
export interface SubcategoryGroup {
subcategoryId: SubcategoryId;
tools: {
id: string /* FIX ME: Should be ToolId */;
tool: ToolRegistryEntry;
}[];
};
export type ToolSectionKey = 'quick' | 'all';
export interface ToolSection {
key: ToolSectionKey;
title: string;
subcategories: SubcategoryGroup[];
};
export function useToolSections(filteredTools: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry][]) {
const { t } = useTranslation();
const groupedTools = useMemo(() => {
const grouped: GroupedTools = {};
const grouped = {} as GroupedTools;
filteredTools.forEach(([id, tool]) => {
const category = tool.category;
const subcategory = tool.subcategory;
if (!grouped[category]) grouped[category] = {};
if (!grouped[category][subcategory]) grouped[category][subcategory] = [];
grouped[category][subcategory].push({ id, tool });
const categoryId = tool.categoryId;
const subcategoryId = tool.subcategoryId;
if (!grouped[categoryId]) grouped[categoryId] = {} as SubcategoryIdMap;
if (!grouped[categoryId][subcategoryId]) grouped[categoryId][subcategoryId] = [];
grouped[categoryId][subcategoryId].push({ id, tool });
});
return grouped;
}, [filteredTools]);
const sections = useMemo(() => {
const getOrderIndex = (name: string) => {
const idx = SUBCATEGORY_ORDER.indexOf(name as any);
const sections: ToolSection[] = useMemo(() => {
const getOrderIndex = (id: SubcategoryId) => {
const idx = SUBCATEGORY_ORDER.indexOf(id);
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
};
const quick: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const all: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const quick = {} as SubcategoryIdMap;
const all = {} as SubcategoryIdMap;
Object.entries(groupedTools).forEach(([origCat, subs]) => {
const upperCat = origCat.toUpperCase();
Object.entries(groupedTools).forEach(([c, subs]) => {
const categoryId = c as ToolCategoryId;
Object.entries(subs).forEach(([sub, tools]) => {
if (!all[sub]) all[sub] = [];
all[sub].push(...tools);
Object.entries(subs).forEach(([s, tools]) => {
const subcategoryId = s as SubcategoryId;
if (!all[subcategoryId]) all[subcategoryId] = [];
all[subcategoryId].push(...tools);
});
if (upperCat === ToolCategory.RECOMMENDED_TOOLS.toUpperCase()) {
Object.entries(subs).forEach(([sub, tools]) => {
if (!quick[sub]) quick[sub] = [];
quick[sub].push(...tools);
if (categoryId === ToolCategoryId.RECOMMENDED_TOOLS) {
Object.entries(subs).forEach(([s, tools]) => {
const subcategoryId = s as SubcategoryId;
if (!quick[subcategoryId]) quick[subcategoryId] = [];
quick[subcategoryId].push(...tools);
});
}
});
const sortSubs = (obj: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>>) =>
const sortSubs = (obj: SubcategoryIdMap) =>
Object.entries(obj)
.sort(([a], [b]) => {
const ai = getOrderIndex(a);
const bi = getOrderIndex(b);
const aId = a as SubcategoryId;
const bId = b as SubcategoryId;
const ai = getOrderIndex(aId);
const bi = getOrderIndex(bId);
if (ai !== bi) return ai - bi;
return a.localeCompare(b);
return aId.localeCompare(bId);
})
.map(([subcategory, tools]) => ({ subcategory, tools }));
.map(([subcategoryId, tools]) => ({ subcategoryId, tools } as SubcategoryGroup));
const built = [
const built: ToolSection[] = [
{ key: 'quick', title: t('toolPicker.quickAccess', 'QUICK ACCESS'), subcategories: sortSubs(quick) },
{ key: 'all', title: t('toolPicker.allTools', 'ALL TOOLS'), subcategories: sortSubs(all) }
];
@ -67,19 +89,20 @@ export function useToolSections(filteredTools: [string, ToolRegistryEntry][]) {
return built.filter(section => section.subcategories.some(sc => sc.tools.length > 0));
}, [groupedTools]);
const searchGroups = useMemo(() => {
const subMap: Record<string, Array<{ id: string; tool: ToolRegistryEntry }>> = {};
const seen = new Set<string>();
const searchGroups: SubcategoryGroup[] = useMemo(() => {
const subMap = {} as SubcategoryIdMap;
const seen = new Set<string /* FIX ME: Should be ToolId */>();
filteredTools.forEach(([id, tool]) => {
if (seen.has(id)) return;
seen.add(id);
const sub = tool.subcategory;
const toolId = id as string /* FIX ME: Should be ToolId */;
if (seen.has(toolId)) return;
seen.add(toolId);
const sub = tool.subcategoryId;
if (!subMap[sub]) subMap[sub] = [];
subMap[sub].push({ id, tool });
subMap[sub].push({ id: toolId, tool });
});
return Object.entries(subMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([subcategory, tools]) => ({ subcategory, tools }));
.map(([subcategoryId, tools]) => ({ subcategoryId, tools } as SubcategoryGroup));
}, [filteredTools]);
return { sections, searchGroups };

View File

@ -3,7 +3,7 @@
*/
import { useEffect, useCallback } from 'react';
import { ModeType } from '../contexts/NavigationContext';
import { ModeType } from '../types/navigation';
import { parseToolRoute, updateToolRoute, clearToolRoute } from '../utils/urlRouting';
/**

View File

@ -1,9 +1,3 @@
@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',

View File

@ -1,20 +1,19 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFileActions, useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
import { ToolWorkflowProvider, useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { Group } from "@mantine/core";
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
import { useSidebarContext } from "../contexts/SidebarContext";
import { useDocumentMeta } from "../hooks/useDocumentMeta";
import { getBaseUrl } from "../constants/app";
import ToolPanel from "../components/tools/ToolPanel";
import Workbench from "../components/layout/Workbench";
import QuickAccessBar from "../components/shared/QuickAccessBar";
import RightRail from "../components/shared/RightRail";
import FileManager from "../components/FileManager";
function HomePageContent() {
export default function HomePage() {
const { t } = useTranslation();
const {
sidebarRefs,
@ -22,8 +21,6 @@ function HomePageContent() {
const { quickAccessRef } = sidebarRefs;
const { setSelectedFiles } = useFileSelection();
const { selectedTool, selectedToolKey } = useToolWorkflow();
const baseUrl = getBaseUrl();
@ -50,28 +47,8 @@ function HomePageContent() {
ref={quickAccessRef} />
<ToolPanel />
<Workbench />
<RightRail />
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</Group>
);
}
function HomePageWithProviders() {
const { actions } = useNavigationActions();
// Wrapper to convert string to ModeType
const handleViewChange = (view: string) => {
actions.setMode(view as any); // ToolWorkflowContext should validate this
};
return (
<ToolWorkflowProvider onViewChange={handleViewChange}>
<SidebarProvider>
<HomePageContent />
</SidebarProvider>
</ToolWorkflowProvider>
);
}
export default function HomePage() {
return <HomePageWithProviders />;
}
}

View File

@ -0,0 +1,183 @@
/**
* Service for managing automation configurations in IndexedDB
*/
export interface AutomationConfig {
id: string;
name: string;
description?: string;
operations: Array<{
operation: string;
parameters: any;
}>;
createdAt: string;
updatedAt: string;
}
class AutomationStorage {
private dbName = 'StirlingPDF_Automations';
private dbVersion = 1;
private storeName = 'automations';
private db: IDBDatabase | null = null;
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
reject(new Error('Failed to open automation storage database'));
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
store.createIndex('name', 'name', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
};
});
}
async ensureDB(): Promise<IDBDatabase> {
if (!this.db) {
await this.init();
}
if (!this.db) {
throw new Error('Database not initialized');
}
return this.db;
}
async saveAutomation(automation: Omit<AutomationConfig, 'id' | 'createdAt' | 'updatedAt'>): Promise<AutomationConfig> {
const db = await this.ensureDB();
const timestamp = new Date().toISOString();
const automationWithMeta: AutomationConfig = {
id: `automation-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
...automation,
createdAt: timestamp,
updatedAt: timestamp
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.add(automationWithMeta);
request.onsuccess = () => {
resolve(automationWithMeta);
};
request.onerror = () => {
reject(new Error('Failed to save automation'));
};
});
}
async updateAutomation(automation: AutomationConfig): Promise<AutomationConfig> {
const db = await this.ensureDB();
const updatedAutomation: AutomationConfig = {
...automation,
updatedAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(updatedAutomation);
request.onsuccess = () => {
resolve(updatedAutomation);
};
request.onerror = () => {
reject(new Error('Failed to update automation'));
};
});
}
async getAutomation(id: string): Promise<AutomationConfig | null> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error('Failed to get automation'));
};
});
}
async getAllAutomations(): Promise<AutomationConfig[]> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
const automations = request.result || [];
// Sort by creation date, newest first
automations.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
resolve(automations);
};
request.onerror = () => {
reject(new Error('Failed to get automations'));
};
});
}
async deleteAutomation(id: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error('Failed to delete automation'));
};
});
}
async searchAutomations(query: string): Promise<AutomationConfig[]> {
const automations = await this.getAllAutomations();
if (!query.trim()) {
return automations;
}
const lowerQuery = query.toLowerCase();
return automations.filter(automation =>
automation.name.toLowerCase().includes(lowerQuery) ||
(automation.description && automation.description.toLowerCase().includes(lowerQuery)) ||
automation.operations.some(op => op.operation.toLowerCase().includes(lowerQuery))
);
}
}
// Export singleton instance
export const automationStorage = new AutomationStorage();

View File

@ -32,7 +32,7 @@ export class PDFExportService {
const sourceDoc = await PDFLibDocument.load(originalPDFBytes);
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly);
return { blob, filename: exportFilename };
} catch (error) {
console.error('PDF export error:', error);
@ -63,7 +63,7 @@ export class PDFExportService {
const blob = await this.createMultiSourceDocument(sourceFiles, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly);
return { blob, filename: exportFilename };
} catch (error) {
console.error('Multi-file PDF export error:', error);
@ -79,10 +79,10 @@ export class PDFExportService {
pages: PDFPage[]
): Promise<Blob> {
const newDoc = await PDFLibDocument.create();
// Load all source documents once and cache them
const loadedDocs = new Map<string, PDFLibDocument>();
for (const [fileId, file] of sourceFiles) {
try {
const arrayBuffer = await file.arrayBuffer();
@ -97,7 +97,7 @@ export class PDFExportService {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
// Apply rotation if needed
if (page.rotation !== 0) {
blankPage.setRotation(degrees(page.rotation));
@ -146,7 +146,7 @@ export class PDFExportService {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
// Apply rotation if needed
if (page.rotation !== 0) {
blankPage.setRotation(degrees(page.rotation));
@ -183,7 +183,7 @@ export class PDFExportService {
/**
* Generate appropriate filename for export
*/
private generateFilename(originalName: string, selectedOnly: boolean): string {
private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string {
const baseName = originalName.replace(/\.pdf$/i, '');
return `${baseName}.pdf`;
}

View File

@ -106,6 +106,12 @@
--icon-config-bg: #9CA3AF;
--icon-config-color: #FFFFFF;
/* RightRail (light) */
--right-rail-bg: #F5F6F8; /* light background */
--right-rail-foreground: #CDD4E1; /* panel behind custom tool icons */
--right-rail-icon: #4B5563; /* icon color */
--right-rail-icon-disabled: #CECECE;/* disabled icon */
/* Colors for tooltips */
--tooltip-title-bg: #DBEFFF;
--tooltip-title-color: #31528E;
@ -234,6 +240,12 @@
--icon-inactive-bg: #2A2F36;
--icon-inactive-color: #6E7581;
/* RightRail (dark) */
--right-rail-bg: #1F2329; /* dark background */
--right-rail-foreground: #2A2F36; /* panel behind custom tool icons */
--right-rail-icon: #BCBEBF; /* icon color */
--right-rail-icon-disabled: #43464B;/* disabled icon */
/* Dark mode tooltip colors */
--tooltip-title-bg: #4B525A;
--tooltip-title-color: #fff;

View File

@ -409,7 +409,7 @@ describe('Convert Tool Integration Tests', () => {
// Verify integration: utils validation prevents API call, hook shows error
expect(mockedAxios.post).not.toHaveBeenCalled();
expect(result.current.errorMessage).toContain('errorNotSupported');
expect(result.current.errorMessage).toContain('Unsupported conversion format');
expect(result.current.isLoading).toBe(false);
expect(result.current.downloadUrl).toBe(null);
});

View File

@ -9,11 +9,11 @@ import { createToolFlow } from "../components/tools/shared/createToolFlow";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import { useAddPasswordParameters } from "../hooks/tools/addPassword/useAddPasswordParameters";
import { useAddPasswordParameters, defaultParameters } from "../hooks/tools/addPassword/useAddPasswordParameters";
import { useAddPasswordOperation } from "../hooks/tools/addPassword/useAddPasswordOperation";
import { useAddPasswordTips } from "../components/tooltips/useAddPasswordTips";
import { useAddPasswordPermissionsTips } from "../components/tooltips/useAddPasswordPermissionsTips";
import { BaseToolProps } from "../types/tool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@ -114,4 +114,4 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
});
};
export default AddPassword;
export default AddPassword as ToolComponent;

View File

@ -21,7 +21,7 @@ import {
useWatermarkFileTips,
useWatermarkFormattingTips,
} from "../components/tooltips/useWatermarkTips";
import { BaseToolProps } from "../types/tool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@ -208,4 +208,7 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
});
};
export default AddWatermark;
// Static method to get the operation hook for automation
AddWatermark.tool = () => useAddWatermarkOperation;
export default AddWatermark as ToolComponent;

Some files were not shown because too many files have changed in this diff Show More