diff --git a/frontend/.gitignore b/frontend/.gitignore index 8b055b7a6..1191bbebf 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -24,4 +24,8 @@ yarn-debug.log* yarn-error.log* playwright-report -test-results \ No newline at end of file +test-results + +# auto-generated files +/src/assets/material-symbols-icons.json +/src/assets/material-symbols-icons.d.ts \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1438432a5..817f7b17e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,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", @@ -29,7 +30,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", @@ -40,6 +40,8 @@ "web-vitals": "^2.1.4" }, "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", @@ -89,6 +91,28 @@ "node": ">=6.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "dev": true, + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/utils": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-9.2.0.tgz", + "integrity": "sha512-Oq1d9BGZakE/FyoEtcNeSwM7MpDO2vUBi11RWBZXf75zPsbUVWmUs03EqkRFrcgbXyKTas0BdZWC1wcuSoqSAw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1192,6 +1216,104 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, + "node_modules/@iconify-json/material-symbols": { + "version": "1.2.33", + "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.33.tgz", + "integrity": "sha512-Bs0X1+/vpJydW63olrGh60zkR8/Y70sI14AIWaP7Z6YQXukzWANH4q3I0sIPklbIn1oL6uwLvh0zQyd6Vh79LQ==", + "dev": true, + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/react": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.0.tgz", + "integrity": "sha512-eqNscABVZS8eCpZLU/L5F5UokMS9mnCf56iS1nM9YYHdH8ZxqZL9zyjSwW60IOQFsXZkilbBiv+1paMXBhSQnw==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.0.1.tgz", + "integrity": "sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw==", + "dev": true, + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@antfu/utils": "^9.2.0", + "@iconify/types": "^2.0.0", + "debug": "^4.4.1", + "globals": "^15.15.0", + "kolorist": "^1.8.0", + "local-pkg": "^1.1.1", + "mlly": "^1.7.4" + } + }, + "node_modules/@iconify/utils/node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true + }, + "node_modules/@iconify/utils/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@iconify/utils/node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dev": true, + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@iconify/utils/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/@iconify/utils/node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -4446,6 +4568,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5553,6 +5681,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true + }, "node_modules/license-checker": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/license-checker/-/license-checker-25.0.1.tgz", @@ -6097,12 +6231,6 @@ "semver": "bin/semver.js" } }, - "node_modules/material-symbols": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.33.0.tgz", - "integrity": "sha512-t9/Gz+14fClRgN7oVOt5CBuwsjFLxSNP9BRDyMrI5el3IZNvoD94IDGJha0YYivyAow24rCS0WOkAv4Dp+YjNg==", - "license": "Apache-2.0" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6653,6 +6781,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", + "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==", + "dev": true + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -7403,6 +7537,22 @@ "node": ">=6" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ] + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -8615,6 +8765,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "dev": true + }, "node_modules/tinyglobby": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index cde323bcc..eaa5f20d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 0b2c0d9b2..907f495e9 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -48,7 +48,11 @@ "filesSelected": "{{count}} files selected", "files": { "title": "Files", - "placeholder": "Select a PDF file in the main view to get started" + "placeholder": "Select a PDF file in the main view to get started", + "upload": "Upload", + "addFiles": "Add files", + "noFiles": "No files uploaded. ", + "selectFromWorkbench": "Select files from the workbench or " }, "noFavourites": "No favourites added", "downloadComplete": "Download Complete", @@ -604,6 +608,10 @@ "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", @@ -1114,7 +1122,88 @@ }, "help": "Please read this documentation on how to use this for other languages and/or use not in docker", "credit": "This service uses qpdf and Tesseract for OCR.", - "submit": "Process PDF with OCR" + "submit": "Process PDF with OCR", + "operation": { + "submit": "Process OCR and Review" + }, + "results": { + "title": "OCR Results" + }, + "languagePicker": { + "additionalLanguages": "Looking for additional languages?", + "viewSetupGuide": "View setup guide →" + }, + "settings": { + "title": "Settings", + "ocrMode": { + "label": "OCR Mode", + "auto": "Auto (skip text layers)", + "force": "Force (re-OCR all, replace text)", + "strict": "Strict (abort if text found)" + }, + "languages": { + "label": "Languages", + "placeholder": "Select languages" + }, + "compatibilityMode": { + "label": "Compatibility Mode" + }, + "advancedOptions": { + "label": "Processing Options", + "sidecar": "Create a text file", + "deskew": "Deskew pages", + "clean": "Clean input file", + "cleanFinal": "Clean final output" + } + }, + "tooltip": { + "header": { + "title": "OCR Settings Overview" + }, + "mode": { + "title": "OCR Mode", + "text": "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight.", + "bullet1": "Auto skips pages that already contain text layers.", + "bullet2": "Force re-OCRs every page and replaces all the text.", + "bullet3": "Strict halts if any selectable text is found." + }, + "languages": { + "title": "Languages", + "text": "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection." + }, + "output": { + "title": "Output", + "text": "Decide how you want the text output formatted:", + "bullet1": "Searchable PDF embeds text behind the original image.", + "bullet2": "HOCR XML returns a structured machine-readable file.", + "bullet3": "Plain-text sidecar creates a separate .txt file with raw content." + }, + "advanced": { + "header": { + "title": "Advanced OCR Processing" + }, + "compatibility": { + "title": "Compatibility Mode", + "text": "Uses OCR 'sandwich PDF' mode: results in larger files, but more reliable with certain languages and older PDF software. By default we use hOCR for smaller, modern PDFs." + }, + "sidecar": { + "title": "Create Text File", + "text": "Generates a separate .txt file alongside the PDF containing all extracted text content for easy access and processing." + }, + "deskew": { + "title": "Deskew Pages", + "text": "Automatically corrects skewed or tilted pages to improve OCR accuracy. Useful for scanned documents that weren't perfectly aligned." + }, + "clean": { + "title": "Clean Input File", + "text": "Preprocesses the input by removing noise, enhancing contrast, and optimising the image for better OCR recognition before processing." + }, + "cleanFinal": { + "title": "Clean Final Output", + "text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size." + } + } + } }, "extractImages": { "tags": "picture,photo,save,archive,zip,capture,grab", @@ -1932,6 +2021,19 @@ "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", @@ -1980,6 +2082,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.", @@ -2266,6 +2369,20 @@ "description": "Configure the settings for this tool. These settings will be applied when the automation runs.", "cancel": "Cancel", "save": "Save Configuration" - } + }, + "copyToSaved": "Copy to Saved" } + }, + "automation": { + "suggested": { + "securePdfIngestion": "Secure PDF Ingestion", + "securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitises documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimises file size.", + "emailPreparation": "Email Preparation", + "emailPreparationDesc": "Optimises PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.", + "secureWorkflow": "Security Workflow", + "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access.", + "processImages": "Process Images", + "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." + } + } } diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 358ccd53a..ab5b66802 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -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.", @@ -2089,5 +2106,20 @@ "results": { "title": "Decrypted PDFs" } + }, + "automation": { + "suggested": { + "securePdfIngestion": "Secure PDF Ingestion", + "securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size.", + "emailPreparation": "Email Preparation", + "emailPreparationDesc": "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.", + "secureWorkflow": "Security Workflow", + "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access.", + "processImages": "Process Images", + "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." + } + }, + "automate": { + "copyToSaved": "Copy to Saved" } } diff --git a/frontend/scripts/generate-icons.js b/frontend/scripts/generate-icons.js new file mode 100644 index 000000000..681b06728 --- /dev/null +++ b/frontend/scripts/generate-icons.js @@ -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: + const localIconMatches = content.match(/]*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: icon-name + const spanMatches = content.match(/]*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: + const iconMatches = content.match(/]*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; + 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!`); \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b498b0677..ef4d663f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import HomePage from "./pages/HomePage"; // Import global styles import "./styles/tailwind.css"; import "./index.css"; +import { RightRailProvider } from "./contexts/RightRailContext"; // Loading component for i18next suspense const LoadingFallback = () => ( @@ -38,7 +39,9 @@ export default function App() { + + diff --git a/frontend/src/assets/3rdPartyLicenses.json b/frontend/src/assets/3rdPartyLicenses.json index 0235380af..2f19f5db6 100644 --- a/frontend/src/assets/3rdPartyLicenses.json +++ b/frontend/src/assets/3rdPartyLicenses.json @@ -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", diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index c93e78670..df1197ab9 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -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 = ({ - - {toolMode && ( - <> - - - - )} - {showBulkActions && !toolMode && ( - <> - - - )} - {activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? ( @@ -573,25 +557,29 @@ const FileEditor = ({ /> {status && ( - setStatus(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} - > - {status} - + + setStatus(null)} + style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10001 }} + > + {status} + + )} {error && ( - setError(null)} - style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }} - > - {error} - + + setError(null)} + style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }} + > + {error} + + )} diff --git a/frontend/src/components/fileManager/FileInfoCard.tsx b/frontend/src/components/fileManager/FileInfoCard.tsx index f8cc84cb8..68c2a491d 100644 --- a/frontend/src/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/components/fileManager/FileInfoCard.tsx @@ -17,7 +17,7 @@ const FileInfoCard: React.FC = ({ return ( - + {t('fileManager.details', 'File Details')} @@ -31,7 +31,7 @@ const FileInfoCard: React.FC = ({ - + {t('fileManager.fileFormat', 'Format')} {currentFile ? ( @@ -43,7 +43,7 @@ const FileInfoCard: React.FC = ({ )} - + {t('fileManager.fileSize', 'Size')} @@ -51,7 +51,7 @@ const FileInfoCard: React.FC = ({ - + {t('fileManager.fileVersion', 'Version')} @@ -64,4 +64,4 @@ const FileInfoCard: React.FC = ({ ); }; -export default FileInfoCard; \ No newline at end of file +export default FileInfoCard; diff --git a/frontend/src/components/fileManager/FileSourceButtons.tsx b/frontend/src/components/fileManager/FileSourceButtons.tsx index a6870a661..d2d28e09e 100644 --- a/frontend/src/components/fileManager/FileSourceButtons.tsx +++ b/frontend/src/components/fileManager/FileSourceButtons.tsx @@ -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 = ({ +const FileSourceButtons: React.FC = ({ horizontal = false }) => { const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext(); @@ -44,11 +44,11 @@ const FileSourceButtons: React.FC = ({ > {horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')} - + - + + + + +
+ +
)} - {/* File content area */} -
- {/* Stacked file effect - multiple shadows to simulate pages */} -
+ + {file.name} + + + {/* e.g., Jan 29, 2025 - PDF file - 3 Pages */} + {dateLabel} + {extUpper ? ` - ${extUpper} file` : ''} + {pageLabel ? ` - ${pageLabel}` : ''} + +
+ + {/* Preview area */} +
+
{file.name} { - // Hide broken image if blob URL was revoked - const img = e.target as HTMLImageElement; + const img = e.currentTarget; img.style.display = 'none'; + img.parentElement?.setAttribute('data-thumb-missing', 'true'); }} style={{ - maxWidth: '100%', - maxHeight: '100%', + maxWidth: '80%', + maxHeight: '80%', objectFit: 'contain', - borderRadius: 2, + borderRadius: 0, + background: '#ffffff', + border: '1px solid var(--border-default)', + display: 'block', + marginLeft: 'auto', + marginRight: 'auto', + alignSelf: 'start' }} />
- {/* Page count badge - only show for PDFs */} - {file.pageCount > 0 && ( - - {file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'} - + {/* Pin indicator (bottom-left) */} + {isPinned && ( + + + )} - {/* Unsupported badge */} - {!isSupported && ( - -{t("fileManager.unsupported", "Unsupported")} - - )} - - {/* File name overlay */} - - {file.name} - - - {/* Hover controls */} -
- - {actualFile && ( - - { - e.stopPropagation(); - if (isFilePinned(actualFile)) { - unpinFile(actualFile); - onSetStatus(`Unpinned ${file.name}`); - } else { - pinFile(actualFile); - onSetStatus(`Pinned ${file.name}`); - } - }} - > - {isFilePinned(actualFile) ? ( - - ) : ( - - )} - - - )} - - - { - e.stopPropagation(); - onDeleteFile(file.id); - onSetStatus(`Closed ${file.name}`); - }} - > - - - -
- - + {/* Drag handle (span wrapper so we can attach a ref reliably) */} + + +
- - {/* File info */} -
- - {file.name} - - - {formatFileSize(file.size)} - -
-
); }; -export default FileThumbnail; \ No newline at end of file +export default React.memo(FileThumbnail); diff --git a/frontend/src/components/pageEditor/PageEditor.module.css b/frontend/src/components/pageEditor/PageEditor.module.css index 8b1c84638..851d81517 100644 --- a/frontend/src/components/pageEditor/PageEditor.module.css +++ b/frontend/src/components/pageEditor/PageEditor.module.css @@ -1,67 +1,265 @@ -/* Page container hover effects - optimized for smooth scrolling */ -.pageContainer { - transition: transform 0.2s ease-in-out; - /* Enable hardware acceleration for smoother scrolling */ - will-change: transform; - transform: translateZ(0); - backface-visibility: hidden; +/* ========================= + NEW styles for card UI + ========================= */ + + .card { + background: var(--file-card-bg); + border-radius: 0.0625rem; + cursor: pointer; + transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease; + max-width: 100%; + max-height: 100%; + overflow: hidden; + margin-left: 0.5rem; + margin-right: 0.5rem; + } + .card:hover { + box-shadow: var(--shadow-md); + } + .card[data-selected="true"] { + box-shadow: var(--shadow-sm); + } + + /* While dragging */ + .card.dragging, + .card:global(.dragging) { + outline: 1px solid var(--border-strong); + box-shadow: var(--shadow-md); + transform: none !important; + } + + /* -------- Header -------- */ + .header { + height: 2.25rem; + border-radius: 0.0625rem 0.0625rem 0 0; + display: grid; + grid-template-columns: 44px 1fr 44px; + align-items: center; + padding: 0 6px; + user-select: none; + background: var(--bg-toolbar); + color: var(--text-primary); + border-bottom: 1px solid var(--border-default); + } + .headerResting { + background: #3B4B6E; /* dark blue for unselected in light mode */ + color: #FFFFFF; + border-bottom: 1px solid var(--border-default); + } + .headerSelected { + background: var(--header-selected-bg); + color: var(--header-selected-fg); + border-bottom: 1px solid var(--header-selected-bg); + } + + /* Selected border color in light mode */ + :global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { + outline-color: var(--card-selected-border); + } + + /* Reserve space for checkbox instead of logo */ + .logoMark { + margin-left: 8px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + } + + .headerIndex { + text-align: center; + font-weight: 500; + font-size: 18px; + letter-spacing: 0.04em; + } + + .kebab { + justify-self: end; + color: inherit; + } + + /* Menu dropdown */ + .menuDropdown { + min-width: 210px; + } + + /* -------- Title / Meta -------- */ + .title { + line-height: 1.2; + color: var(--text-primary); + } + .meta { + margin-top: 2px; + color: var(--text-secondary); + } + + /* -------- Preview area -------- */ + .previewBox { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--file-card-bg); + } + .previewPaper { + width: 100%; + height: calc(100% - 6px); + min-height: 9rem; + justify-content: center; + display: grid; + position: relative; + overflow: hidden; + background: var(--file-card-bg); + } + + /* Thumbnail fallback */ + .previewPaper[data-thumb-missing="true"]::after { + content: "No preview"; + position: absolute; inset: 0; + display: grid; place-items: center; + color: var(--text-secondary); + font-weight: 600; font-size: 12px; + } + + /* Drag handle grip */ + .dragHandle { + position: absolute; + bottom: 6px; + right: 6px; + color: var(--text-secondary); + z-index: 1; + cursor: grab; + display: inline-flex; + } + + /* Reduced motion */ + @media (prefers-reduced-motion: reduce) { + .card, + .menuDropdown { + transition: none !important; + } + } + + /* ========================= + DARK MODE OVERRIDES + ========================= */ + :global([data-mantine-color-scheme="dark"]) .card { + outline-color: #3A4047; /* deselected stroke */ + } + :global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] { + outline-color: #4B525A; /* selected stroke (subtle grey) */ + } + :global([data-mantine-color-scheme="dark"]) .headerResting { + background: #1F2329; /* requested default unselected color */ + color: var(--tool-header-text); /* #D0D6DC */ + border-bottom-color: var(--tool-header-border); /* #3A4047 */ + } + :global([data-mantine-color-scheme="dark"]) .headerSelected { + background: var(--tool-header-border); /* #3A4047 */ + color: var(--tool-header-text); /* #D0D6DC */ + border-bottom-color: var(--tool-header-border); + } + :global([data-mantine-color-scheme="dark"]) .title { + color: #D0D6DC; /* title text */ + } + :global([data-mantine-color-scheme="dark"]) .meta { + color: #6B7280; /* subtitle text */ + } + + /* Light mode selected header stroke override */ + :global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { + outline-color: #3B4B6E; + } + + /* ========================= + (Optional) legacy styles from your + previous component kept here to + avoid breaking other imports. + They are not used by the new card. + ========================= */ + + .pageContainer { + transition: transform 0.2s ease-in-out; + will-change: transform; + transform: translateZ(0); + backface-visibility: hidden; + } + .pageContainer:hover { transform: scale(1.02) translateZ(0); } + .pageContainer:hover .pageNumber { opacity: 1 !important; } + .pageContainer:hover .pageHoverControls { opacity: 1 !important; } + .checkboxContainer { transform: none !important; transition: none !important; } + + .pageMoveAnimation { transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); } + .pageMoving { z-index: 10; transform: scale(1.05); box-shadow: 0 10px 30px rgba(0,0,0,0.3); } + + .multiDragIndicator { + position: fixed; + background: rgba(59, 130, 246, 0.9); + color: white; + padding: 8px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + pointer-events: none; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + transform: translate(-50%, -50%); + backdrop-filter: blur(4px); + } + + @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} } + .pulse { animation: pulse 1s infinite; } + + .actionsOverlay { + position: absolute; + left: 0; + top: 44px; /* just below header */ + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border-default); + z-index: 20; + overflow: hidden; + animation: slideDown 140ms ease-out; + color: var(--text-primary); + } + @keyframes slideDown { from { transform: translateY(-8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } } + + .actionRow { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: transparent; + border: none; + color: var(--text-primary); + cursor: pointer; + text-align: left; + } + .actionRow:hover { background: var(--hover-bg); } + .actionDanger { color: var(--text-brand-accent); } + .actionsDivider { height: 1px; background: var(--border-default); margin: 4px 0; } + +.pinIndicator { + position: absolute; + bottom: 4px; + left: 4px; + z-index: 1; + color: rgba(0, 0, 0, 0.35); /* match drag handle color */ } -.pageContainer:hover { - transform: scale(1.02) translateZ(0); -} - -.pageContainer:hover .pageNumber { - opacity: 1 !important; -} - -.pageContainer:hover .pageHoverControls { - opacity: 1 !important; -} - -/* Checkbox container - prevent transform inheritance */ -.checkboxContainer { - transform: none !important; - transition: none !important; -} - -/* Page movement animations */ -.pageMoveAnimation { - transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); -} - -.pageMoving { - z-index: 10; - transform: scale(1.05); - box-shadow: 0 10px 30px rgba(0,0,0,0.3); -} - -/* Multi-page drag indicator */ -.multiDragIndicator { - position: fixed; - background: rgba(59, 130, 246, 0.9); +.unsupportedPill { + margin-left: 1.75rem; + background: #6B7280; color: white; - padding: 8px 12px; - border-radius: 20px; - font-size: 12px; - font-weight: 600; - pointer-events: none; - z-index: 1000; - box-shadow: 0 4px 12px rgba(0,0,0,0.3); - transform: translate(-50%, -50%); - backdrop-filter: blur(4px); + padding: 4px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + min-width: 80px; + height: 20px; } - -/* Animations */ -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -.pulse { - animation: pulse 1s infinite; -} \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 7ca640b06..543778d9e 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -1,14 +1,13 @@ 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"; import { ModeType } from "../../contexts/NavigationContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; -import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { useUndoRedo } from "../../hooks/useUndoRedo"; import { RotatePagesCommand, @@ -56,7 +55,6 @@ export interface PageEditorProps { const PageEditor = ({ onFunctionsReady, }: PageEditorProps) => { - const { t } = useTranslation(); // Use split contexts to prevent re-renders const { state, selectors } = useFileState(); @@ -241,19 +239,26 @@ const PageEditor = ({ const [exportLoading, setExportLoading] = useState(false); const [showExportModal, setShowExportModal] = useState(false); const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); + const [exportSelectedOnly, setExportSelectedOnly] = useState(false); // Animation state const [movingPage, setMovingPage] = useState(null); - const [pagePositions, setPagePositions] = useState>(new Map()); const [isAnimating, setIsAnimating] = useState(false); - const pageRefs = useRef>(new Map()); - const fileInputRef = useRef<() => void>(null); // Undo/Redo system const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); + // Track whether the user has manually edited the filename to avoid auto-overwrites + const userEditedFilename = useRef(false); + + // Reset user edit flag when the active files change, so defaults can be applied for new docs + useEffect(() => { + userEditedFilename.current = false; + }, [filesSignature]); + // Set initial filename when document changes - use stable signature useEffect(() => { + if (userEditedFilename.current) return; // Do not overwrite user-typed filename if (mergedPdfDocument) { if (activeFileIds.length === 1 && primaryFileId) { const record = selectors.getFileRecord(primaryFileId); @@ -838,14 +843,18 @@ const PageEditor = ({ const handleDelete = useCallback(() => { if (!displayDocument) return; - const pagesToDelete = selectionMode - ? selectedPageNumbers.map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) + const hasSelectedPages = selectedPageNumbers.length > 0; + + const pagesToDelete = (selectionMode || hasSelectedPages) + ? selectedPageNumbers + .map(pageNum => { + const page = displayDocument.pages.find(p => p.pageNumber === pageNum); + return page?.id || ''; + }) + .filter(id => id) : displayDocument.pages.map(p => p.id); - if (selectionMode && selectedPageNumbers.length === 0) return; + if ((selectionMode || hasSelectedPages) && selectedPageNumbers.length === 0) return; const command = new DeletePagesCommand( displayDocument, @@ -857,7 +866,7 @@ const PageEditor = ({ if (selectionMode) { actions.setSelectedPages([]); } - const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; + const pageCount = (selectionMode || hasSelectedPages) ? selectedPageNumbers.length : displayDocument.pages.length; setStatus(`Deleted ${pageCount} pages`); }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]); @@ -885,49 +894,52 @@ const PageEditor = ({ }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); const showExportPreview = useCallback((selectedOnly: boolean = false) => { - if (!mergedPdfDocument) return; + const doc = editedDocument || mergedPdfDocument; + if (!doc) return; // Convert page numbers to page IDs for export service const exportPageIds = selectedOnly ? selectedPageNumbers.map(pageNum => { - const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); + const page = doc.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id) : []; - - const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly); + const preview = pdfExportService.getExportInfo(doc, exportPageIds, selectedOnly); setExportPreview(preview); + setExportSelectedOnly(selectedOnly); setShowExportModal(true); - }, [mergedPdfDocument, selectedPageNumbers]); + }, [editedDocument, mergedPdfDocument, selectedPageNumbers]); const handleExport = useCallback(async (selectedOnly: boolean = false) => { - if (!mergedPdfDocument) return; + const doc = editedDocument || mergedPdfDocument; + if (!doc) return; setExportLoading(true); try { // Convert page numbers to page IDs for export service const exportPageIds = selectedOnly ? selectedPageNumbers.map(pageNum => { - const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); + const page = doc.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id) : []; - const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly); + const errors = pdfExportService.validateExport(doc, exportPageIds, selectedOnly); if (errors.length > 0) { setStatus(errors.join(', ')); return; } - const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore); + const hasSplitMarkers = doc.pages.some(page => page.splitBefore); if (hasSplitMarkers) { - const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, { + const result = await pdfExportService.exportPDF(doc, exportPageIds, { selectedOnly, filename, - splitDocuments: true + splitDocuments: true, + appendSuffix: false }) as { blobs: Blob[]; filenames: string[] }; result.blobs.forEach((blob, index) => { @@ -938,9 +950,10 @@ const PageEditor = ({ setStatus(`Exported ${result.blobs.length} split documents`); } else { - const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, { + const result = await pdfExportService.exportPDF(doc, exportPageIds, { selectedOnly, - filename + filename, + appendSuffix: false }) as { blob: Blob; filename: string }; pdfExportService.downloadFile(result.blob, result.filename); @@ -953,7 +966,7 @@ const PageEditor = ({ } finally { setExportLoading(false); } - }, [mergedPdfDocument, selectedPageNumbers, filename]); + }, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]); const handleUndo = useCallback(() => { if (undo()) { @@ -1240,59 +1253,13 @@ const PageEditor = ({
)} - - setFilename(e.target.value)} placeholder="Enter filename" - style={{ minWidth: 200 }} + style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}} /> - - {selectionMode && ( - <> - - - - )} - - - {/* Apply Changes Button */} - {hasUnsavedChanges && ( - - )} - - - {selectionMode && ( - - )} - { setShowExportModal(false); - const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0); - handleExport(selectedOnly); + handleExport(exportSelectedOnly); }} > Export PDF @@ -1446,14 +1412,16 @@ const PageEditor = ({ {status && ( + setStatus(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} + style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }} > {status} + )} {error && ( diff --git a/frontend/src/components/pageEditor/PageEditorControls.tsx b/frontend/src/components/pageEditor/PageEditorControls.tsx index 43e224e2b..2b0c6ee3c 100644 --- a/frontend/src/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/components/pageEditor/PageEditorControls.tsx @@ -2,16 +2,12 @@ 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"; interface PageEditorControlsProps { // Close/Reset functions @@ -39,17 +35,12 @@ interface PageEditorControlsProps { } const PageEditorControls = ({ - onClosePdf, onUndo, onRedo, canUndo, canRedo, onRotate, - onDelete, onSplit, - onExportSelected, - onExportAll, - exportLoading, selectionMode, selectedPages }: PageEditorControlsProps) => { @@ -57,9 +48,9 @@ const PageEditorControls = ({
- - {/* Close PDF */} - - - - - - -
{/* Undo/Redo */} @@ -133,17 +118,6 @@ const PageEditorControls = ({ - - 0 ? "light" : "default"} - size="lg" - > - - - -
- - {/* Export Controls */} - {selectionMode && selectedPages.length > 0 && ( - - - - - - )} - - - - - - +
); }; diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index f1590978a..7360b4dce 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -205,7 +205,7 @@ const PageThumbnail = React.memo(({ }} draggable={false} > - {selectionMode && ( + {
- )} + }
= ({ onPrevious, onNext }) => { - if (!file) return null; + if (!file) { + return ( + +
+ +
+
+ ); + } const hasMultipleFiles = totalFiles > 1; diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 6c1668a43..14322076e 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -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(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 = () => { /> - {/* Add Files Button */} - + + +
{/* Hidden file input for native file picker */} { +interface LanguageSelectorProps { + position?: React.ComponentProps['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 = () => { @@ -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 = () => { }} > - + }} + > + + + ) : ( + + )} { }} > {option.label} - - {/* Ripple effect */} - {rippleEffect && pendingLanguage === option.value && ( + {!compact && rippleEffect && pendingLanguage === option.value && (
= ({ 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 ; +}; + +export default LocalIcon; \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 80ef86c83..c57e49c40 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -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'; @@ -44,7 +42,7 @@ const QuickAccessBar = forwardRef(({ { id: 'read', name: t("quickAccess.read", "Read"), - icon: , + icon: , size: 'lg', isRound: false, type: 'navigation', @@ -54,28 +52,23 @@ const QuickAccessBar = forwardRef(({ handleReaderToggle(); } }, - { - id: 'sign', - name: t("quickAccess.sign", "Sign"), - icon: - - signature - , - size: 'lg', - isRound: false, - type: 'navigation', - onClick: () => { - setActiveButton('sign'); - handleToolSelect('sign'); - } - }, + // TODO: Add sign + //{ + // id: 'sign', + // name: t("quickAccess.sign", "Sign"), + // icon: , + // size: 'lg', + // isRound: false, + // type: 'navigation', + // onClick: () => { + // setActiveButton('sign'); + // handleToolSelect('sign'); + // } + //}, { id: 'automate', name: t("quickAccess.automate", "Automate"), - icon: - - automation - , + icon: , size: 'lg', isRound: false, type: 'navigation', @@ -87,28 +80,26 @@ const QuickAccessBar = forwardRef(({ { id: 'files', name: t("quickAccess.files", "Files"), - icon: , + icon: , isRound: true, size: 'lg', type: 'modal', onClick: handleFilesButtonClick }, - { - id: 'activity', - name: t("quickAccess.activity", "Activity"), - icon: - - vital_signs - , - isRound: true, - size: 'lg', - type: 'navigation', - onClick: () => setActiveButton('activity') - }, + //TODO: Activity + //{ + // id: 'activity', + // name: t("quickAccess.activity", "Activity"), + // icon: , + // isRound: true, + // size: 'lg', + // type: 'navigation', + // onClick: () => setActiveButton('activity') + //}, { id: 'config', name: t("quickAccess.config", "Config"), - icon: , + icon: , size: 'lg', type: 'modal', onClick: () => { @@ -179,8 +170,8 @@ const QuickAccessBar = forwardRef(({
- {/* Add divider after Automate button (index 2) */} - {index === 2 && ( + {/* Add divider after Automate button (index 1) and Files button (index 2) */} + {index === 1 && ( 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(""); + + // 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(currentView === 'pageEditor'); + const [pageControlsVisible, setPageControlsVisible] = useState(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 ( +
+
+ {topButtons.length > 0 && ( + <> +
+ {topButtons.map(btn => ( + + actions[btn.id]?.()} + disabled={btn.disabled} + > + {btn.icon} + + + ))} +
+ + + )} + + {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */} +
+
+ {/* Select All Button */} + +
+ + + +
+
+ + {/* Deselect All Button */} + +
+ + + +
+
+ + {/* Select by Numbers - page editor only, with animated presence */} + {pageControlsMounted && ( + + +
+ + +
+ + + +
+
+ +
+ +
+
+
+
+
+ + )} + + {/* Delete Selected Pages - page editor only, with animated presence */} + {pageControlsMounted && ( + + +
+
+ { 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'} + > + + +
+
+
+ + )} + + {/* Close (File Editor: Close Selected | Page Editor: Close PDF) */} + +
+ pageEditorFunctions?.closePdf?.() : handleCloseSelected} + disabled={ + currentView === 'viewer' || + (currentView === 'fileEditor' && selectedCount === 0) || + (currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) + } + > + + +
+
+
+ + +
+ + {/* Theme toggle and Language dropdown */} +
+ + + + + + + + + 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) + } position="left" offset={12} arrow> +
+ + + +
+
+
+ +
+
+
+ ); +} + + diff --git a/frontend/src/components/shared/TextInput.tsx b/frontend/src/components/shared/TextInput.tsx index e44e8efb2..a5dd90569 100644 --- a/frontend/src/components/shared/TextInput.tsx +++ b/frontend/src/components/shared/TextInput.tsx @@ -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'; /** @@ -30,6 +31,8 @@ export interface TextInputProps { readOnly?: boolean; /** Accessibility label */ 'aria-label'?: string; + /** Focus event handler */ + onFocus?: () => void; } export const TextInput = forwardRef(({ @@ -45,6 +48,7 @@ export const TextInput = forwardRef(({ disabled = false, readOnly = false, 'aria-label': ariaLabel, + onFocus, ...props }, ref) => { const { colorScheme } = useMantineColorScheme(); @@ -62,7 +66,7 @@ export const TextInput = forwardRef(({ return (
{icon && ( - @@ -80,6 +84,7 @@ export const TextInput = forwardRef(({ disabled={disabled} readOnly={readOnly} aria-label={ariaLabel} + onFocus={onFocus} style={{ backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF', color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382', @@ -96,7 +101,7 @@ export const TextInput = forwardRef(({ style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }} aria-label="Clear input" > - close + )}
diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx index c415eddf5..7940112ca 100644 --- a/frontend/src/components/shared/Tooltip.tsx +++ b/frontend/src/components/shared/Tooltip.tsx @@ -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 = ({ 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 = ({ 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 = ({ }} title="Close tooltip" > - - close - + )} {arrow && getArrowClass() && ( diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index ee5591694..9c41b35e0 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -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: ( +
+ {switchingTo === "viewer" ? ( + + ) : ( + + )} + Viewer +
+ ), + value: "viewer", + }, + { + label: ( +
+ {switchingTo === "pageEditor" ? ( + + ) : ( + + )} + Page Editor +
+ ), + value: "pageEditor", + }, + { + label: ( +
+ {switchingTo === "fileEditor" ? ( + + ) : ( + + )} + File Manager +
+ ), + 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(null); + const { isRainbowMode } = useRainbowThemeContext(); + const [switchingTo, setSwitchingTo] = useState(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: ( - - {switchingTo === option.value ? ( - - ) : ( - - )} - - ) - })), [switchingTo]); - - const getThemeIcon = () => { - if (isRainbowMode) return ; - if (themeMode === "dark") return ; - return ; - }; + }, [setCurrentView]); return (
-
- - -
{!isToolSelected && ( -
+
diff --git a/frontend/src/components/shared/rightRail/RightRail.README.md b/frontend/src/components/shared/rightRail/RightRail.README.md new file mode 100644 index 000000000..7506e927c --- /dev/null +++ b/frontend/src/components/shared/rightRail/RightRail.README.md @@ -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: , + tooltip: 'Execute Action', + onClick: handleAction, + }, + ]); + + return
My Tool
; +} +``` + +### Multiple Buttons + +```tsx +useRightRailButtons([ + { + id: 'primary', + icon: , + tooltip: 'Primary Action', + order: 1, + onClick: handlePrimary, + }, + { + id: 'secondary', + icon: , + tooltip: 'Secondary Action', + order: 2, + onClick: handleSecondary, + }, +]); +``` + +### Conditional Buttons + +```tsx +useRightRailButtons([ + // Always show + { + id: 'process', + icon: , + tooltip: 'Process', + disabled: isProcessing, + onClick: handleProcess, + }, + // Only show when condition met + ...(hasResults ? [{ + id: 'export', + icon: , + 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 diff --git a/frontend/src/components/shared/rightRail/RightRail.css b/frontend/src/components/shared/rightRail/RightRail.css new file mode 100644 index 000000000..8d01052a9 --- /dev/null +++ b/frontend/src/components/shared/rightRail/RightRail.css @@ -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; +} + diff --git a/frontend/src/components/shared/tooltip/Tooltip.module.css b/frontend/src/components/shared/tooltip/Tooltip.module.css index 46902c04b..50c242812 100644 --- a/frontend/src/components/shared/tooltip/Tooltip.module.css +++ b/frontend/src/components/shared/tooltip/Tooltip.module.css @@ -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; } diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx index a846978d6..69a012690 100644 --- a/frontend/src/components/tools/ToolPanel.tsx +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -8,6 +8,7 @@ import ToolRenderer from './ToolRenderer'; import ToolSearch from './toolPicker/ToolSearch'; import { useSidebarContext } from "../../contexts/SidebarContext"; import rainbowStyles from '../../styles/rainbow.module.css'; +import { Stack, ScrollArea } from '@mantine/core'; // No props needed - component uses context @@ -91,15 +92,17 @@ export default function ToolPanel() {
) : ( // Selected Tool Content View -
+
{/* Tool content */} -
- {selectedToolKey && ( - - )} +
+ + {selectedToolKey && ( + + )} +
)} diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index a8dbd7993..9a46c8a3e 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -25,19 +25,39 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa const quickAccessRef = useRef(null); const allToolsRef = useRef(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); @@ -85,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" > @@ -109,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)", @@ -117,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)} > @@ -152,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)`, diff --git a/frontend/src/components/tools/automate/AutomationCreation.tsx b/frontend/src/components/tools/automate/AutomationCreation.tsx index 49b12c396..ee301ae8e 100644 --- a/frontend/src/components/tools/automate/AutomationCreation.tsx +++ b/frontend/src/components/tools/automate/AutomationCreation.tsx @@ -98,7 +98,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o const saveAutomation = async () => { if (!canSaveAutomation()) return; - const automation = { + const automationData = { name: automationName.trim(), description: '', operations: selectedTools.map(tool => ({ @@ -109,7 +109,30 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o try { const { automationStorage } = await import('../../../services/automationStorage'); - const savedAutomation = await automationStorage.saveAutomation(automation); + let savedAutomation; + + if (mode === AutomationMode.EDIT && existingAutomation) { + // For edit mode, check if name has changed + const nameChanged = automationName.trim() !== existingAutomation.name; + + if (nameChanged) { + // Name changed - create new automation + savedAutomation = await automationStorage.saveAutomation(automationData); + } else { + // Name unchanged - update existing automation + const updatedAutomation = { + ...existingAutomation, + ...automationData, + id: existingAutomation.id, + createdAt: existingAutomation.createdAt + }; + savedAutomation = await automationStorage.updateAutomation(updatedAutomation); + } + } else { + // Create mode - always create new automation + savedAutomation = await automationStorage.saveAutomation(automationData); + } + onComplete(savedAutomation); } catch (error) { console.error('Error saving automation:', error); diff --git a/frontend/src/components/tools/automate/AutomationEntry.tsx b/frontend/src/components/tools/automate/AutomationEntry.tsx index 3314831be..8c07fb14b 100644 --- a/frontend/src/components/tools/automate/AutomationEntry.tsx +++ b/frontend/src/components/tools/automate/AutomationEntry.tsx @@ -4,10 +4,14 @@ 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'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { Tooltip } from '../../shared/Tooltip'; interface AutomationEntryProps { /** Optional title for the automation (usually for custom ones) */ title?: string; + /** Optional description for tooltip */ + description?: string; /** MUI Icon component for the badge */ badgeIcon?: React.ComponentType; /** Array of tool operation names in the workflow */ @@ -22,17 +26,21 @@ interface AutomationEntryProps { onEdit?: () => void; /** Delete handler */ onDelete?: () => void; + /** Copy handler (for suggested automations) */ + onCopy?: () => void; } export default function AutomationEntry({ title, + description, badgeIcon: BadgeIcon, operations, onClick, keepIconColor = false, showMenu = false, onEdit, - onDelete + onDelete, + onCopy }: AutomationEntryProps) { const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); @@ -41,6 +49,47 @@ export default function AutomationEntry({ // Keep item in hovered state if menu is open const shouldShowHovered = isHovered || isMenuOpen; + // Create tooltip content with description and tool chain + const createTooltipContent = () => { + if (!description) return null; + + const toolChain = operations.map((op, index) => ( + + + {t(`${op}.title`, op)} + + {index < operations.length - 1 && ( + + → + + )} + + )); + + return ( +
+ + {description} + +
+ {toolChain} +
+
+ ); + }; + const renderContent = () => { if (title) { // Custom automation with title @@ -89,7 +138,7 @@ export default function AutomationEntry({ } }; - return ( + const boxContent = ( + {onCopy && ( + } + onClick={(e) => { + e.stopPropagation(); + onCopy(); + }} + > + {t('automate.copyToSaved', 'Copy to Saved')} + + )} {onEdit && ( } @@ -160,4 +220,18 @@ export default function AutomationEntry({ ); + + // Only show tooltip if description exists, otherwise return plain content + return description ? ( + + {boxContent} + + ) : ( + boxContent + ); } diff --git a/frontend/src/components/tools/automate/AutomationSelection.tsx b/frontend/src/components/tools/automate/AutomationSelection.tsx index f55cf4c5d..197a96b3e 100644 --- a/frontend/src/components/tools/automate/AutomationSelection.tsx +++ b/frontend/src/components/tools/automate/AutomationSelection.tsx @@ -5,7 +5,7 @@ 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"; +import { AutomationConfig, SuggestedAutomation } from "../../../types/automation"; interface AutomationSelectionProps { savedAutomations: AutomationConfig[]; @@ -13,6 +13,7 @@ interface AutomationSelectionProps { onRun: (automation: AutomationConfig) => void; onEdit: (automation: AutomationConfig) => void; onDelete: (automation: AutomationConfig) => void; + onCopyFromSuggested: (automation: SuggestedAutomation) => void; } export default function AutomationSelection({ @@ -20,7 +21,8 @@ export default function AutomationSelection({ onCreateNew, onRun, onEdit, - onDelete + onDelete, + onCopyFromSuggested }: AutomationSelectionProps) { const { t } = useTranslation(); const suggestedAutomations = useSuggestedAutomations(); @@ -63,9 +65,13 @@ export default function AutomationSelection({ {suggestedAutomations.map((automation) => ( op.operation)} onClick={() => onRun(automation)} + showMenu={true} + onCopy={() => onCopyFromSuggested(automation)} /> ))} diff --git a/frontend/src/components/tools/automate/ToolList.tsx b/frontend/src/components/tools/automate/ToolList.tsx index 8b24b5c17..b11140ac5 100644 --- a/frontend/src/components/tools/automate/ToolList.tsx +++ b/frontend/src/components/tools/automate/ToolList.tsx @@ -1,14 +1,13 @@ -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'; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Text, Stack, Group, ActionIcon } from "@mantine/core"; +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[]; @@ -29,35 +28,39 @@ export default function ToolList({ onToolConfigure, onToolAdd, getToolName, - getToolDefaultParameters + 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 + parameters: defaultParams, }); }; return (
- - {t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length}) + + {t("automate.creation.tools.selected", "Selected Tools")} ({tools.length}) {tools.map((tool, index) => (
{/* Delete X in top right */} @@ -65,26 +68,26 @@ export default function ToolList({ variant="subtle" size="xs" onClick={() => onToolRemove(index)} - title={t('automate.creation.tools.remove', 'Remove tool')} + title={t("automate.creation.tools.remove", "Remove tool")} style={{ - position: 'absolute', - top: '4px', - right: '4px', + position: "absolute", + top: "4px", + right: "4px", zIndex: 1, - color: 'var(--mantine-color-gray-6)' + color: "var(--mantine-color-gray-6)", }} > - + -
+
{/* Tool Selection Dropdown with inline settings cog */}
handleToolSelect(index, newOperation)} - excludeTools={['automate']} + excludeTools={["automate"]} toolRegistry={toolRegistry} selectedValue={tool.operation} placeholder={tool.name} @@ -97,26 +100,37 @@ export default function ToolList({ variant="subtle" size="sm" onClick={() => onToolConfigure(index)} - title={t('automate.creation.tools.configure', 'Configure tool')} - style={{ color: 'var(--mantine-color-gray-6)' }} + title={t("automate.creation.tools.configure", "Configure tool")} + style={{ color: "var(--mantine-color-gray-6)" }} > )} - - {/* Configuration status underneath */} - {tool.operation && !tool.configured && ( - - {t('automate.creation.tools.notConfigured', "! Not Configured")} - - )}
- + {/* Configuration status underneath */} + {tool.operation && !tool.configured && ( +
+ + {t("automate.creation.tools.notConfigured", "! Not Configured")} + +
+ )} {index < tools.length - 1 && ( -
- +
+ + ↓ +
)} @@ -124,19 +138,23 @@ export default function ToolList({ {/* Arrow before Add Tool Button */} {tools.length > 0 && ( -
- +
+ + ↓ +
)} {/* Add Tool Button */} -
+
); -} \ No newline at end of file +} diff --git a/frontend/src/components/tools/automate/ToolSelector.tsx b/frontend/src/components/tools/automate/ToolSelector.tsx index 80b68b0a4..4fb87548f 100644 --- a/frontend/src/components/tools/automate/ToolSelector.tsx +++ b/frontend/src/components/tools/automate/ToolSelector.tsx @@ -1,10 +1,11 @@ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Menu, Stack, Text, ScrollArea } from '@mantine/core'; +import { 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'; +import ToolButton from '../toolPicker/ToolButton'; interface ToolSelectorProps { onSelect: (toolKey: string) => void; @@ -24,6 +25,8 @@ export default function ToolSelector({ const { t } = useTranslation(); const [opened, setOpened] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + const [shouldAutoFocus, setShouldAutoFocus] = useState(false); + const containerRef = useRef(null); // Filter out excluded tools (like 'automate' itself) const baseFilteredTools = useMemo(() => { @@ -66,13 +69,21 @@ export default function ToolSelector({ } if (!sections || sections.length === 0) { + // If no sections, create a simple group from filtered tools + if (baseFilteredTools.length > 0) { + return [{ + name: 'All Tools', + subcategoryId: 'all' as any, + tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })) + }]; + } 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]); + }, [isSearching, searchGroups, sections, baseFilteredTools]); const handleToolSelect = useCallback((toolKey: string) => { onSelect(toolKey); @@ -88,8 +99,25 @@ export default function ToolSelector({ const handleSearchFocus = () => { setOpened(true); + setShouldAutoFocus(true); // Request auto-focus for the input }; + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpened(false); + setSearchTerm(''); + } + }; + + if (opened) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [opened]); + + const handleSearchChange = (value: string) => { setSearchTerm(value); if (!opened) { @@ -97,6 +125,14 @@ export default function ToolSelector({ } }; + const handleInputFocus = () => { + if (!opened) { + setOpened(true); + } + // Clear auto-focus flag since input is now focused + setShouldAutoFocus(false); + }; + // Get display value for selected tool const getDisplayValue = () => { if (selectedValue && toolRegistry[selectedValue]) { @@ -106,77 +142,63 @@ export default function ToolSelector({ }; return ( -
- { - 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 }} - > - -
- {selectedValue && toolRegistry[selectedValue] && !opened ? ( - // Show selected tool in AutomationEntry style when tool is selected and not searching -
-
-
- {toolRegistry[selectedValue].icon} -
- - {toolRegistry[selectedValue].name} - -
-
- ) : ( - // Show search input when no tool selected or actively searching - - )} -
-
+
+ {/* Always show the target - either selected tool or search input */} - - - - {displayGroups.length === 0 ? ( - - {isSearching - ? t('tools.noSearchResults', 'No tools found') - : t('tools.noTools', 'No tools available') - } - - ) : ( - renderedTools - )} - - - -
+ {selectedValue && toolRegistry[selectedValue] && !opened ? ( + // Show selected tool in AutomationEntry style when tool is selected and dropdown closed +
+ {}} rounded={true}> +
+ ) : ( + // Show search input when no tool selected OR when dropdown is opened + + )} + + {/* Custom dropdown */} + {opened && ( +
+ + + {displayGroups.length === 0 ? ( + + {isSearching + ? t('tools.noSearchResults', 'No tools found') + : t('tools.noTools', 'No tools available') + } + + ) : ( + renderedTools + )} + + +
+ )}
); } diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index 3ea9b782d..ad5bef454 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -1,6 +1,11 @@ -import React from "react"; -import { Text } from "@mantine/core"; +import React, { useState, useEffect } from "react"; +import { Text, Anchor } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import FolderIcon from '@mui/icons-material/Folder'; +import UploadIcon from '@mui/icons-material/Upload'; +import { useFilesModalContext } from "../../../contexts/FilesModalContext"; +import { useAllFiles } from "../../../contexts/FileContext"; +import { useFileManager } from "../../../hooks/useFileManager"; export interface FileStatusIndicatorProps { selectedFiles?: File[]; @@ -14,15 +19,110 @@ const FileStatusIndicator = ({ minFiles = 1, }: FileStatusIndicatorProps) => { const { t } = useTranslation(); - const defaultPlaceholder = placeholder || t("files.placeholder", "Select a PDF file in the main view to get started"); + const { openFilesModal, onFilesSelect } = useFilesModalContext(); + const { files: workbenchFiles } = useAllFiles(); + const { loadRecentFiles } = useFileManager(); + const [hasRecentFiles, setHasRecentFiles] = useState(null); - // Only show content when no files are selected + // Check if there are recent files + useEffect(() => { + const checkRecentFiles = async () => { + try { + const recentFiles = await loadRecentFiles(); + setHasRecentFiles(recentFiles.length > 0); + } catch (error) { + setHasRecentFiles(false); + } + }; + checkRecentFiles(); + }, [loadRecentFiles]); + + // Handle native file picker + const handleNativeUpload = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.accept = '.pdf,application/pdf'; + input.onchange = (event) => { + const files = Array.from((event.target as HTMLInputElement).files || []); + if (files.length > 0) { + onFilesSelect(files); + } + }; + input.click(); + }; + + // Don't render until we know if there are recent files + if (hasRecentFiles === null) { + return null; + } + + // Check if there are no files in the workbench + if (workbenchFiles.length === 0) { + // If no recent files, show upload button + if (!hasRecentFiles) { + return ( + + + + {t("files.upload", "Upload")} + + + ); + } else { + // If there are recent files, show add files button + return ( + + + + {t("files.addFiles", "Add files")} + + + ); + } + } + + // Show selection status when there are files in workbench if (selectedFiles.length < minFiles) { - return ( - - {defaultPlaceholder} - - ); + // If no recent files, show upload option + if (!hasRecentFiles) { + return ( + + {t("files.selectFromWorkbench", "Select files from the workbench or ") + " "} + + + {t("files.upload", "Upload")} + + + ); + } else { + // If there are recent files, show add files option + return ( + + {t("files.selectFromWorkbench", "Select files from the workbench or ") + " "} + + + {t("files.addFiles", "Add files")} + + + ); + } } return ( diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index 927180038..9d86d2f03 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -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 ( {title} - - gpp_maybe - + ); @@ -77,6 +76,7 @@ const ToolStep = ({ showNumber, _stepNumber, _noPadding, + alwaysShowTooltip = false, tooltip }: ToolStepProps) => { if (!isVisible) return null; @@ -118,18 +118,16 @@ const ToolStep = ({ {stepNumber} )} - {renderTooltipTitle(title, tooltip, isCollapsed)} + {renderTooltipTitle(title, tooltip, isCollapsed, alwaysShowTooltip)} {isCollapsed ? ( - ) : ( - diff --git a/frontend/src/components/tools/shared/ToolWorkflowTitle.tsx b/frontend/src/components/tools/shared/ToolWorkflowTitle.tsx new file mode 100644 index 000000000..6ed949442 --- /dev/null +++ b/frontend/src/components/tools/shared/ToolWorkflowTitle.tsx @@ -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 ( + <> + + + e.stopPropagation()}> + + {title} + + + + + + + + ); + } + + return ( + <> + + + {title} + + + + + ); +} diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index 0c887be68..da5202500 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -3,6 +3,7 @@ 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[]; @@ -46,7 +47,10 @@ export interface ReviewStepConfig { testId?: string; } +export interface TitleConfig extends ToolWorkflowTitleProps {} + export interface ToolFlowConfig { + title?: TitleConfig; files: FilesStepConfig; steps: MiddleStepConfig[]; executeButton?: ExecuteButtonConfig; @@ -62,8 +66,11 @@ export function createToolFlow(config: ToolFlowConfig) { const steps = createToolSteps(); return ( - + + {/* */} + {config.title && } + {/* Files Step */} {config.files.isVisible !== false && steps.createFilesStep({ selectedFiles: config.files.selectedFiles, diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 66bd9489e..185eed5ed 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -9,9 +9,10 @@ interface ToolButtonProps { tool: ToolRegistryEntry; isSelected: boolean; onSelect: (id: string) => void; + rounded?: boolean; } -const ToolButton: React.FC = ({ id, tool, isSelected, onSelect }) => { +const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, rounded = false }) => { const handleClick = (id: string) => { if (tool.link) { // Open external link in new tab @@ -33,7 +34,17 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect fullWidth justify="flex-start" className="tool-button" - styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }} + styles={{ + root: { + borderRadius: rounded ? 'var(--mantine-radius-lg)' : 0, + color: "var(--tools-text-and-icon-color)", + ...(rounded && { + '&:hover': { + borderRadius: 'var(--mantine-radius-lg)', + } + }) + } + }} > void; toolRegistry: Readonly>; onToolSelect?: (toolId: string) => void; - mode: 'filter' | 'dropdown'; + mode: "filter" | "dropdown" | "unstyled"; selectedToolKey?: string | null; placeholder?: string; hideIcon?: boolean; onFocus?: () => void; + autoFocus?: boolean; } const ToolSearch = ({ @@ -22,11 +24,12 @@ const ToolSearch = ({ onChange, toolRegistry, onToolSelect, - mode = 'filter', + mode = "filter", selectedToolKey, placeholder, hideIcon = false, - onFocus + onFocus, + autoFocus = false, }: ToolSearchProps) => { const { t } = useTranslation(); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -37,9 +40,10 @@ const ToolSearch = ({ if (!value.trim()) return []; return Object.entries(toolRegistry) .filter(([id, tool]) => { - if (mode === 'dropdown' && id === selectedToolKey) return false; - return tool.name.toLowerCase().includes(value.toLowerCase()) || - tool.description.toLowerCase().includes(value.toLowerCase()); + if (mode === "dropdown" && id === selectedToolKey) return false; + return ( + tool.name.toLowerCase().includes(value.toLowerCase()) || tool.description.toLowerCase().includes(value.toLowerCase()) + ); }) .slice(0, 6) .map(([id, tool]) => ({ id, tool })); @@ -47,7 +51,7 @@ const ToolSearch = ({ const handleSearchChange = (searchValue: string) => { onChange(searchValue); - if (mode === 'dropdown') { + if (mode === "dropdown") { setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0); } }; @@ -63,49 +67,60 @@ const ToolSearch = ({ setDropdownOpen(false); } }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + // Auto-focus the input when requested + useEffect(() => { + if (autoFocus && searchRef.current) { + setTimeout(() => { + searchRef.current?.focus(); + }, 10); + } + }, [autoFocus]); + const searchInput = ( -
search} + icon={hideIcon ? undefined : } autoComplete="off" - + onFocus={onFocus} /> -
); - if (mode === 'filter') { + if (mode === "filter") { + return
{searchInput}
; + } + + if (mode === "unstyled") { return searchInput; } return ( -
+
{searchInput} {dropdownOpen && filteredTools.length > 0 && (
- + {filteredTools.map(({ id, tool }) => (
- } + leftSection={
{tool.icon}
} fullWidth justify="flex-start" style={{ - borderRadius: '6px', - color: 'var(--tools-text-and-icon-color)', - padding: '8px 12px' + borderRadius: "6px", + color: "var(--tools-text-and-icon-color)", + padding: "8px 12px", }} > -
+
{tool.name}
- + {tool.description}
diff --git a/frontend/src/components/tooltips/useAdvancedOCRTips.ts b/frontend/src/components/tooltips/useAdvancedOCRTips.ts new file mode 100644 index 000000000..e1b4532c1 --- /dev/null +++ b/frontend/src/components/tooltips/useAdvancedOCRTips.ts @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useAdvancedOCRTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("ocr.tooltip.advanced.header.title", "Advanced OCR Processing"), + }, + tips: [ + { + title: t("ocr.tooltip.advanced.compatibility.title", "Compatibility Mode"), + description: t("ocr.tooltip.advanced.compatibility.text", "Uses OCR 'sandwich PDF' mode: results in larger files, but more reliable with certain languages and older PDF software. By default we use hOCR for smaller, modern PDFs.") + }, + { + title: t("ocr.tooltip.advanced.sidecar.title", "Create Text File"), + description: t("ocr.tooltip.advanced.sidecar.text", "Generates a separate .txt file alongside the PDF containing all extracted text content for easy access and processing.") + }, + { + title: t("ocr.tooltip.advanced.deskew.title", "Deskew Pages"), + description: t("ocr.tooltip.advanced.deskew.text", "Automatically corrects skewed or tilted pages to improve OCR accuracy. Useful for scanned documents that weren't perfectly aligned.") + }, + { + title: t("ocr.tooltip.advanced.clean.title", "Clean Input File"), + description: t("ocr.tooltip.advanced.clean.text", "Preprocesses the input by removing noise, enhancing contrast, and optimising the image for better OCR recognition before processing.") + }, + { + title: t("ocr.tooltip.advanced.cleanFinal.title", "Clean Final Output"), + description: t("ocr.tooltip.advanced.cleanFinal.text", "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size.") + } + ] + }; +}; diff --git a/frontend/src/contexts/RightRailContext.tsx b/frontend/src/contexts/RightRailContext.tsx new file mode 100644 index 000000000..be3b7276c --- /dev/null +++ b/frontend/src/contexts/RightRailContext.tsx @@ -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; + registerButtons: (buttons: RightRailButtonConfig[]) => void; + unregisterButtons: (ids: string[]) => void; + setAction: (id: string, action: RightRailAction) => void; + clear: () => void; +} + +const RightRailContext = createContext(undefined); + +export function RightRailProvider({ children }: { children: React.ReactNode }) { + const [buttons, setButtons] = useState([]); + const [actions, setActions] = useState>({}); + + 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(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]); + + return ( + + {children} + + ); +} + +export function useRightRail() { + const ctx = useContext(RightRailContext); + if (!ctx) throw new Error('useRightRail must be used within RightRailProvider'); + return ctx; +} diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index f784dd49a..2bbb3c9f4 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -134,7 +134,10 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const setPreviewFile = useCallback((file: File | null) => { dispatch({ type: 'SET_PREVIEW_FILE', payload: file }); - }, []); + if (file) { + actions.setMode('viewer'); + } + }, [actions]); const setPageEditorFunctions = useCallback((functions: PageEditorFunctions | null) => { dispatch({ type: 'SET_PAGE_EDITOR_FUNCTIONS', payload: functions }); diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 71531c5f1..c55a1770f 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -1,4 +1,5 @@ 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"; @@ -53,7 +54,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Signing "certSign": { - icon: workspace_premium, + icon: , name: t("home.certSign.title", "Sign with Certificate"), component: null, view: "sign", @@ -62,7 +63,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.SIGNING }, "sign": { - icon: signature, + icon: , name: t("home.sign.title", "Sign"), component: null, view: "sign", @@ -75,7 +76,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Document Security "addPassword": { - icon: password, + icon: , name: t("home.addPassword.title", "Add Password"), component: AddPassword, view: "security", @@ -88,7 +89,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: AddPasswordSettings }, "watermark": { - icon: branding_watermark, + icon: , name: t("home.watermark.title", "Add Watermark"), component: AddWatermark, view: "format", @@ -101,7 +102,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: AddWatermarkSingleStepSettings }, "add-stamp": { - icon: approval, + icon: , name: t("home.AddStampRequest.title", "Add Stamp to PDF"), component: null, view: "format", @@ -110,7 +111,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.DOCUMENT_SECURITY }, "sanitize": { - icon: cleaning_services, + icon: , name: t("home.sanitize.title", "Sanitize"), component: Sanitize, view: "security", @@ -123,7 +124,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: SanitizeSettings }, "flatten": { - icon: layers_clear, + icon: , name: t("home.flatten.title", "Flatten"), component: null, view: "format", @@ -132,7 +133,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.DOCUMENT_SECURITY }, "unlock-pdf-forms": { - icon: preview_off, + icon: , name: t("home.unlockPDFForms.title", "Unlock PDF Forms"), component: UnlockPdfForms, view: "security", @@ -145,7 +146,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: UnlockPdfFormsSettings }, "manage-certificates": { - icon: license, + icon: , name: t("home.manageCertificates.title", "Manage Certificates"), component: null, view: "security", @@ -154,7 +155,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.DOCUMENT_SECURITY }, "change-permissions": { - icon: lock, + icon: , name: t("home.changePermissions.title", "Change Permissions"), component: ChangePermissions, view: "security", @@ -169,7 +170,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Verification "get-all-info-on-pdf": { - icon: fact_check, + icon: , name: t("home.getPdfInfo.title", "Get ALL Info on PDF"), component: null, view: "extract", @@ -178,7 +179,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.VERIFICATION }, "validate-pdf-signature": { - icon: verified, + icon: , name: t("home.validateSignature.title", "Validate PDF Signature"), component: null, view: "security", @@ -191,7 +192,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Document Review "read": { - icon: article, + icon: , name: t("home.read.title", "Read"), component: null, view: "view", @@ -200,7 +201,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.DOCUMENT_REVIEW }, "change-metadata": { - icon: assignment, + icon: , name: t("home.changeMetadata.title", "Change Metadata"), component: null, view: "format", @@ -211,7 +212,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Page Formatting "cropPdf": { - icon: crop, + icon: , name: t("home.crop.title", "Crop PDF"), component: null, view: "format", @@ -220,7 +221,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.PAGE_FORMATTING }, "rotate": { - icon: rotate_right, + icon: , name: t("home.rotate.title", "Rotate"), component: null, view: "format", @@ -229,7 +230,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.PAGE_FORMATTING }, "splitPdf": { - icon: content_cut, + icon: , name: t("home.split.title", "Split"), component: SplitPdfPanel, view: "split", @@ -240,7 +241,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: SplitSettings }, "reorganize-pages": { - icon: move_down, + icon: , name: t("home.reorganizePages.title", "Reorganize Pages"), component: null, view: "pageEditor", @@ -249,7 +250,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.PAGE_FORMATTING }, "adjust-page-size-scale": { - icon: crop_free, + icon: , name: t("home.scalePages.title", "Adjust page size/scale"), component: null, view: "format", @@ -258,7 +259,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.PAGE_FORMATTING }, "addPageNumbers": { - icon: 123, + icon: , name: t("home.addPageNumbers.title", "Add Page Numbers"), component: null, view: "format", @@ -267,7 +268,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.PAGE_FORMATTING }, "multi-page-layout": { - icon: dashboard, + icon: , name: t("home.pageLayout.title", "Multi-Page Layout"), component: null, view: "format", @@ -276,7 +277,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.PAGE_FORMATTING }, "single-large-page": { - icon: looks_one, + icon: , name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"), component: SingleLargePage, view: "format", @@ -288,7 +289,7 @@ export function useFlatToolRegistry(): ToolRegistry { operationConfig: singleLargePageOperationConfig }, "add-attachments": { - icon: attachment, + icon: , name: t("home.attachments.title", "Add Attachments"), component: null, view: "format", @@ -301,7 +302,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Extraction "extractPages": { - icon: upload, + icon: , name: t("home.extractPages.title", "Extract Pages"), component: null, view: "extract", @@ -310,7 +311,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.EXTRACTION }, "extract-images": { - icon: filter, + icon: , name: t("home.extractImages.title", "Extract Images"), component: null, view: "extract", @@ -323,7 +324,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Removal "removePages": { - icon: delete, + icon: , name: t("home.removePages.title", "Remove Pages"), component: null, view: "remove", @@ -332,7 +333,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.REMOVAL }, "remove-blank-pages": { - icon: scan_delete, + icon: , name: t("home.removeBlanks.title", "Remove Blank Pages"), component: null, view: "remove", @@ -341,7 +342,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.REMOVAL }, "remove-annotations": { - icon: thread_unread, + icon: , name: t("home.removeAnnotations.title", "Remove Annotations"), component: null, view: "remove", @@ -350,7 +351,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.REMOVAL }, "remove-image": { - icon: remove_selection, + icon: , name: t("home.removeImagePdf.title", "Remove Image"), component: null, view: "format", @@ -359,7 +360,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.REMOVAL }, "remove-password": { - icon: lock_open_right, + icon: , name: t("home.removePassword.title", "Remove Password"), component: RemovePassword, view: "security", @@ -372,7 +373,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: RemovePasswordSettings }, "remove-certificate-sign": { - icon: remove_moderator, + icon: , name: t("home.removeCertSign.title", "Remove Certificate Sign"), component: RemoveCertificateSign, view: "security", @@ -388,7 +389,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Automation "automate": { - icon: automation, + icon: , name: t("home.automate.title", "Automate"), component: React.lazy(() => import('../tools/Automate')), view: "format", @@ -399,7 +400,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["handleData"] }, "auto-rename-pdf-file": { - icon: match_word, + icon: , name: t("home.auto-rename.title", "Auto Rename PDF File"), component: null, view: "format", @@ -408,7 +409,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.AUTOMATION }, "auto-split-pages": { - icon: split_scene_right, + icon: , name: t("home.autoSplitPDF.title", "Auto Split Pages"), component: null, view: "format", @@ -417,7 +418,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.AUTOMATION }, "auto-split-by-size-count": { - icon: content_cut, + icon: , name: t("home.autoSizeSplitPDF.title", "Auto Split by Size/Count"), component: null, view: "format", @@ -430,7 +431,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Advanced Formatting "adjustContrast": { - icon: palette, + icon: , name: t("home.adjustContrast.title", "Adjust Colors/Contrast"), component: null, view: "format", @@ -439,7 +440,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.ADVANCED_FORMATTING }, "repair": { - icon: build, + icon: , name: t("home.repair.title", "Repair"), component: Repair, view: "format", @@ -452,7 +453,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: RepairSettings }, "detect-split-scanned-photos": { - icon: scanner, + icon: , name: t("home.ScannerImageSplit.title", "Detect & Split Scanned Photos"), component: null, view: "format", @@ -461,7 +462,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.ADVANCED_FORMATTING }, "overlay-pdfs": { - icon: layers, + icon: , name: t("home.overlay-pdfs.title", "Overlay PDFs"), component: null, view: "format", @@ -470,7 +471,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.ADVANCED_FORMATTING }, "replace-and-invert-color": { - icon: format_color_fill, + icon: , name: t("home.replaceColorPdf.title", "Replace & Invert Color"), component: null, view: "format", @@ -479,7 +480,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.ADVANCED_FORMATTING }, "add-image": { - icon: image, + icon: , name: t("home.addImage.title", "Add Image"), component: null, view: "format", @@ -488,7 +489,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.ADVANCED_FORMATTING }, "edit-table-of-contents": { - icon: bookmark_add, + icon: , name: t("home.editTableOfContents.title", "Edit Table of Contents"), component: null, view: "format", @@ -497,7 +498,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.ADVANCED_FORMATTING }, "scanner-effect": { - icon: scanner, + icon: , name: t("home.fakeScan.title", "Scanner Effect"), component: null, view: "format", @@ -510,7 +511,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Developer Tools "show-javascript": { - icon: javascript, + icon: , name: t("home.showJS.title", "Show JavaScript"), component: null, view: "extract", @@ -519,7 +520,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.DEVELOPER_TOOLS }, "dev-api": { - icon: open_in_new, + icon: , name: t("home.devApi.title", "API"), component: null, view: "external", @@ -529,7 +530,7 @@ export function useFlatToolRegistry(): ToolRegistry { link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html" }, "dev-folder-scanning": { - icon: open_in_new, + icon: , name: t("home.devFolderScanning.title", "Automated Folder Scanning"), component: null, view: "external", @@ -539,7 +540,7 @@ export function useFlatToolRegistry(): ToolRegistry { link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/" }, "dev-sso-guide": { - icon: open_in_new, + icon: , name: t("home.devSsoGuide.title", "SSO Guide"), component: null, view: "external", @@ -549,7 +550,7 @@ export function useFlatToolRegistry(): ToolRegistry { link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration", }, "dev-airgapped": { - icon: open_in_new, + icon: , name: t("home.devAirgapped.title", "Air-gapped Setup"), component: null, view: "external", @@ -562,7 +563,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Recommended Tools "compare": { - icon: compare, + icon: , name: t("home.compare.title", "Compare"), component: null, view: "format", @@ -571,7 +572,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.GENERAL }, "compress": { - icon: zoom_in_map, + icon: , name: t("home.compress.title", "Compress"), component: CompressPdfPanel, view: "compress", @@ -583,7 +584,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: CompressSettings }, "convert": { - icon: sync_alt, + icon: , name: t("home.convert.title", "Convert"), component: ConvertPanel, view: "convert", @@ -629,7 +630,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: ConvertSettings }, "mergePdfs": { - icon: library_add, + icon: , name: t("home.merge.title", "Merge"), component: Merge, view: "merge", @@ -642,7 +643,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: MergeSettings }, "multi-tool": { - icon: dashboard_customize, + icon: , name: t("home.multiTool.title", "Multi-Tool"), component: null, view: "pageEditor", @@ -652,7 +653,7 @@ export function useFlatToolRegistry(): ToolRegistry { maxFiles: -1 }, "ocr": { - icon: quick_reference_all, + icon: , name: t("home.ocr.title", "OCR"), component: OCRPanel, view: "convert", @@ -664,7 +665,7 @@ export function useFlatToolRegistry(): ToolRegistry { settingsComponent: OCRSettings }, "redact": { - icon: visibility_off, + icon: , name: t("home.redact.title", "Redact"), component: null, view: "redact", diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index eb4b5d6c2..5511059a8 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -4,4 +4,15 @@ declare module "../components/PageEditor"; declare module "../components/Viewer"; declare module "*.js"; declare module '*.module.css'; -declare module 'pdfjs-dist'; \ No newline at end of file +declare module 'pdfjs-dist'; + +// Auto-generated icon set JSON import +declare module '../assets/material-symbols-icons.json' { + const value: { + prefix: string; + icons: Record; + width?: number; + height?: number; + }; + export default value; +} \ No newline at end of file diff --git a/frontend/src/hooks/tools/automate/useAutomationForm.ts b/frontend/src/hooks/tools/automate/useAutomationForm.ts index 11464a329..7bbe14d9b 100644 --- a/frontend/src/hooks/tools/automate/useAutomationForm.ts +++ b/frontend/src/hooks/tools/automate/useAutomationForm.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation'; import { AUTOMATION_CONSTANTS } from '../../../constants/automation'; @@ -16,18 +16,18 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us const [automationName, setAutomationName] = useState(''); const [selectedTools, setSelectedTools] = useState([]); - const getToolName = (operation: string) => { + const getToolName = useCallback((operation: string) => { const tool = toolRegistry?.[operation] as any; return tool?.name || t(`tools.${operation}.name`, operation); - }; + }, [toolRegistry, t]); - const getToolDefaultParameters = (operation: string): Record => { + const getToolDefaultParameters = useCallback((operation: string): Record => { const config = toolRegistry[operation]?.operationConfig; if (config?.defaultParameters) { return { ...config.defaultParameters }; } return {}; - }; + }, [toolRegistry]); // Initialize based on mode and existing automation useEffect(() => { @@ -58,7 +58,7 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us })); setSelectedTools(defaultTools); } - }, [mode, existingAutomation, selectedTools.length, t, getToolName]); + }, [mode, existingAutomation, t, getToolName]); const addTool = (operation: string) => { const newTool: AutomationTool = { diff --git a/frontend/src/hooks/tools/automate/useSavedAutomations.ts b/frontend/src/hooks/tools/automate/useSavedAutomations.ts index c52e4c784..1f210b432 100644 --- a/frontend/src/hooks/tools/automate/useSavedAutomations.ts +++ b/frontend/src/hooks/tools/automate/useSavedAutomations.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { AutomationConfig } from '../../../services/automationStorage'; +import { SuggestedAutomation } from '../../../types/automation'; export interface SavedAutomation extends AutomationConfig {} @@ -40,6 +41,26 @@ export function useSavedAutomations() { } }, [refreshAutomations]); + const copyFromSuggested = useCallback(async (suggestedAutomation: SuggestedAutomation) => { + try { + const { automationStorage } = await import('../../../services/automationStorage'); + + // Convert suggested automation to saved automation format + const savedAutomation = { + name: suggestedAutomation.name, + description: suggestedAutomation.description, + operations: suggestedAutomation.operations + }; + + await automationStorage.saveAutomation(savedAutomation); + // Refresh the list after saving + refreshAutomations(); + } catch (err) { + console.error('Error copying suggested automation:', err); + throw err; + } + }, [refreshAutomations]); + // Load automations on mount useEffect(() => { loadSavedAutomations(); @@ -50,6 +71,7 @@ export function useSavedAutomations() { loading, error, refreshAutomations, - deleteAutomation + deleteAutomation, + copyFromSuggested }; } \ No newline at end of file diff --git a/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts b/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts index bb1ed5916..047f041e4 100644 --- a/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts +++ b/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts @@ -1,8 +1,15 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import StarIcon from '@mui/icons-material/Star'; +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(); @@ -10,37 +17,171 @@ export function useSuggestedAutomations(): SuggestedAutomation[] { const now = new Date().toISOString(); return [ { - id: "compress-and-merge", - name: t("automation.suggested.compressAndMerge", "Compress & Merge"), - description: t("automation.suggested.compressAndMergeDesc", "Compress PDFs and merge them into one file"), + id: "secure-pdf-ingestion", + name: t("automation.suggested.securePdfIngestion", "Secure PDF Ingestion"), + description: t("automation.suggested.securePdfIngestionDesc", "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size."), operations: [ - { operation: "compress", parameters: {} }, - { operation: "merge", parameters: {} } + { + operation: "sanitize", + parameters: { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: true, + removeMetadata: true, + removeLinks: false, + removeFonts: false, + } + }, + { + operation: "ocr", + parameters: { + languages: ['eng'], + ocrType: 'skip-text', + ocrRenderType: 'hocr', + additionalOptions: ['clean', 'cleanFinal'], + } + }, + { + operation: "convert", + parameters: { + fromExtension: 'pdf', + toExtension: 'pdfa', + pdfaOptions: { + outputFormat: 'pdfa-1', + } + } + }, + { + operation: "compress", + parameters: { + compressionLevel: 5, + grayscale: false, + expectedSize: '', + compressionMethod: 'quality', + fileSizeValue: '', + fileSizeUnit: 'MB', + } + } ], createdAt: now, updatedAt: now, - icon: StarIcon, + icon: SecurityIcon, }, { - id: "ocr-and-convert", - name: t("automation.suggested.ocrAndConvert", "OCR & Convert"), - description: t("automation.suggested.ocrAndConvertDesc", "Extract text via OCR and convert to different format"), + id: "email-preparation", + name: t("automation.suggested.emailPreparation", "Email Preparation"), + description: t("automation.suggested.emailPreparationDesc", "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy."), operations: [ - { operation: "ocr", parameters: {} }, - { operation: "convert", parameters: {} } + { + operation: "compress", + parameters: { + compressionLevel: 5, + grayscale: false, + expectedSize: '', + compressionMethod: 'quality', + fileSizeValue: '', + fileSizeUnit: 'MB', + } + }, + { + operation: "splitPdf", + parameters: { + mode: 'bySizeOrCount', + pages: '', + hDiv: '1', + vDiv: '1', + merge: false, + splitType: 'size', + splitValue: '20MB', + bookmarkLevel: '1', + includeMetadata: false, + allowDuplicates: false, + } + }, + { + operation: "sanitize", + parameters: { + removeJavaScript: false, + removeEmbeddedFiles: false, + removeXMPMetadata: true, + removeMetadata: true, + removeLinks: false, + removeFonts: false, + } + } ], createdAt: now, updatedAt: now, - icon: StarIcon, + icon: CompressIcon, }, { id: "secure-workflow", - name: t("automation.suggested.secureWorkflow", "Secure Workflow"), - description: t("automation.suggested.secureWorkflowDesc", "Sanitize, add password, and set permissions"), + name: t("automation.suggested.secureWorkflow", "Security Workflow"), + description: t("automation.suggested.secureWorkflowDesc", "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access."), operations: [ - { operation: "sanitize", parameters: {} }, - { operation: "addPassword", parameters: {} }, - { operation: "changePermissions", parameters: {} } + { + 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: "process-images", + name: t("automation.suggested.processImages", "Process Images"), + description: t("automation.suggested.processImagesDesc", "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."), + operations: [ + { + operation: "convert", + parameters: { + fromExtension: 'image', + toExtension: 'pdf', + imageOptions: { + colorType: 'color', + dpi: 300, + singleOrMultiple: 'multiple', + fitOption: 'maintainAspectRatio', + autoRotate: true, + combineImages: true, + } + } + }, + { + operation: "ocr", + parameters: { + languages: ['eng'], + ocrType: 'skip-text', + ocrRenderType: 'hocr', + additionalOptions: [], + } + } ], createdAt: now, updatedAt: now, diff --git a/frontend/src/hooks/useRainbowTheme.ts b/frontend/src/hooks/useRainbowTheme.ts index b16ed1228..449b07c61 100644 --- a/frontend/src/hooks/useRainbowTheme.ts +++ b/frontend/src/hooks/useRainbowTheme.ts @@ -161,8 +161,8 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb } lastToggleTime.current = currentTime; - // Easter egg: Activate rainbow mode after 6 rapid toggles - if (toggleCount.current >= 6) { + // Easter egg: Activate rainbow mode after 10 rapid toggles + if (toggleCount.current >= 10) { setThemeMode('rainbow'); console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!'); console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!'); diff --git a/frontend/src/hooks/useRightRailButtons.ts b/frontend/src/hooks/useRightRailButtons.ts new file mode 100644 index 000000000..82a4e8cd5 --- /dev/null +++ b/frontend/src/hooks/useRightRailButtons.ts @@ -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(); + 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]); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f7e5e0865..ec2585e8c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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', diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index eeb23e83f..12c1f4d7f 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -9,6 +9,7 @@ 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"; @@ -46,7 +47,8 @@ export default function HomePage() { ref={quickAccessRef} /> + ); -} +} \ No newline at end of file diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index b0662437e..9345133b8 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -5,6 +5,7 @@ export interface ExportOptions { selectedOnly?: boolean; filename?: string; splitDocuments?: boolean; + appendSuffix?: boolean; // when false, do not append _edited/_selected } export class PDFExportService { @@ -16,7 +17,7 @@ export class PDFExportService { selectedPageIds: string[] = [], options: ExportOptions = {} ): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> { - const { selectedOnly = false, filename, splitDocuments = false } = options; + const { selectedOnly = false, filename, splitDocuments = false, appendSuffix = true } = options; try { // Determine which pages to export @@ -36,7 +37,7 @@ export class PDFExportService { return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name); } else { const blob = await this.createSingleDocument(sourceDoc, pagesToExport); - const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly); + const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, appendSuffix); return { blob, filename: exportFilename }; } } catch (error) { @@ -56,7 +57,7 @@ export class PDFExportService { for (const page of pages) { // Get the original page from source document - const sourcePageIndex = page.pageNumber - 1; + const sourcePageIndex = this.getOriginalSourceIndex(page); if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { // Copy the page @@ -113,7 +114,7 @@ export class PDFExportService { const newDoc = await PDFLibDocument.create(); for (const page of segmentPages) { - const sourcePageIndex = page.pageNumber - 1; + const sourcePageIndex = this.getOriginalSourceIndex(page); if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); @@ -146,11 +147,28 @@ export class PDFExportService { return { blobs, filenames }; } + /** + * Derive the original page index from a page's stable id. + * Falls back to the current pageNumber if parsing fails. + */ + private getOriginalSourceIndex(page: PDFPage): number { + const match = page.id.match(/-page-(\d+)$/); + if (match) { + const originalNumber = parseInt(match[1], 10); + if (!Number.isNaN(originalNumber)) { + return originalNumber - 1; // zero-based index for pdf-lib + } + } + // Fallback to the visible page number + return Math.max(0, page.pageNumber - 1); + } + /** * 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, ''); + if (!appendSuffix) return `${baseName}.pdf`; const suffix = selectedOnly ? '_selected' : '_edited'; return `${baseName}${suffix}.pdf`; } diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 634cae91c..6643ca580 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -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; @@ -156,10 +162,22 @@ --landing-drop-inner-paper-bg: #BBDEFB; --landing-drop-inner-paper-border: #90CAF9; + /* selected file header colors */ + --header-selected-bg: #1E88E5; /* light mode selected header matches dark */ + --header-selected-fg: #FFFFFF; + --file-card-bg: #FFFFFF; /* file card background (light/dark paired) */ + /* shadows */ --drop-shadow-color: rgba(0, 0, 0, 0.08); --drop-shadow-color-strong: rgba(0, 0, 0, 0.04); --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04)); + + /* Light mode card hover and selection */ + --header-hover-bg: #3B4B6E; /* same family as selected, a touch muted for hover */ + --card-selected-border: #3FAFFF; /* slightly more blue than dark mode header */ + --checkbox-border: #2F83BF; + --checkbox-checked-bg: #3FAFFF; + --checkbox-tick: #FFFFFF; } [data-mantine-color-scheme="dark"] { @@ -234,6 +252,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; @@ -260,6 +284,12 @@ --landing-drop-inner-paper-bg: #2A3441; --landing-drop-inner-paper-border: #3A4451; + /* selected file header colors for dark */ + --header-selected-bg: #1E88E5; + --header-selected-fg: #FFFFFF; + /* file card background (dark) */ + --file-card-bg: #1F2329; + /* shadows */ --drop-shadow-color: rgba(255, 255, 255, 0.08); --drop-shadow-color-strong: rgba(255, 255, 255, 0.04); diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx index 54538781b..e31d1abe3 100644 --- a/frontend/src/tools/Automate.tsx +++ b/frontend/src/tools/Automate.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useFileContext } from "../contexts/FileContext"; import { useFileSelection } from "../contexts/FileContext"; +import { useNavigation } from "../contexts/NavigationContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createFilesToolStep } from "../components/tools/shared/FilesToolStep"; @@ -13,33 +14,40 @@ import { useAutomateOperation } from "../hooks/tools/automate/useAutomateOperati import { BaseToolProps } from "../types/tool"; import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations"; -import { AutomationConfig, AutomationStepData, AutomationMode } from "../types/automation"; +import { AutomationConfig, AutomationStepData, AutomationMode, AutomationStep } from "../types/automation"; import { AUTOMATION_STEPS } from "../constants/automation"; const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { selectedFiles } = useFileSelection(); + const { setMode } = useNavigation(); - const [currentStep, setCurrentStep] = useState<'selection' | 'creation' | 'run'>(AUTOMATION_STEPS.SELECTION); + const [currentStep, setCurrentStep] = useState(AUTOMATION_STEPS.SELECTION); const [stepData, setStepData] = useState({ step: AUTOMATION_STEPS.SELECTION }); const automateOperation = useAutomateOperation(); const toolRegistry = useFlatToolRegistry(); const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null; - const { savedAutomations, deleteAutomation, refreshAutomations } = useSavedAutomations(); + const { savedAutomations, deleteAutomation, refreshAutomations, copyFromSuggested } = useSavedAutomations(); const handleStepChange = (data: AutomationStepData) => { // If navigating away from run step, reset automation results if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) { automateOperation.resetResults(); } - + + // If navigating to selection step, always clear results + if (data.step === AUTOMATION_STEPS.SELECTION) { + automateOperation.resetResults(); + automateOperation.clearError(); + } + // If navigating to run step with a different automation, reset results - if (data.step === AUTOMATION_STEPS.RUN && data.automation && + if (data.step === AUTOMATION_STEPS.RUN && data.automation && stepData.automation && data.automation.id !== stepData.automation.id) { automateOperation.resetResults(); } - + setStepData(data); setCurrentStep(data.step); }; @@ -47,7 +55,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const handleComplete = () => { // Reset automation results when completing automateOperation.resetResults(); - + // Reset to selection step setCurrentStep(AUTOMATION_STEPS.SELECTION); setStepData({ step: AUTOMATION_STEPS.SELECTION }); @@ -56,7 +64,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const renderCurrentStep = () => { switch (currentStep) { - case 'selection': + case AUTOMATION_STEPS.SELECTION: return ( { onError?.(`Failed to delete automation: ${automation.name}`); } }} + onCopyFromSuggested={async (suggestedAutomation) => { + try { + await copyFromSuggested(suggestedAutomation); + } catch (error) { + console.error('Failed to copy suggested automation:', error); + onError?.(`Failed to copy automation: ${suggestedAutomation.name}`); + } + }} /> ); - case 'creation': + case AUTOMATION_STEPS.CREATION: if (!stepData.mode) { console.error('Creation mode is undefined'); return null; @@ -92,7 +108,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { /> ); - case 'run': + case AUTOMATION_STEPS.RUN: if (!stepData.automation) { console.error('Automation config is undefined'); return null; @@ -127,7 +143,12 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { createStep(t('automate.selection.title', 'Automation Selection'), { isVisible: true, isCollapsed: currentStep !== AUTOMATION_STEPS.SELECTION, - onCollapsedClick: () => setCurrentStep(AUTOMATION_STEPS.SELECTION) + onCollapsedClick: () => { + // Clear results when clicking back to selection + automateOperation.resetResults(); + setCurrentStep(AUTOMATION_STEPS.SELECTION); + setStepData({ step: AUTOMATION_STEPS.SELECTION }); + } }, currentStep === AUTOMATION_STEPS.SELECTION ? renderCurrentStep() : null), createStep(stepData.mode === AutomationMode.EDIT @@ -158,9 +179,13 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }, steps: automationSteps, review: { - isVisible: hasResults, + isVisible: hasResults && currentStep === AUTOMATION_STEPS.RUN, operation: automateOperation, - title: t('automate.reviewTitle', 'Automation Results') + title: t('automate.reviewTitle', 'Automation Results'), + onFileClick: (file: File) => { + onPreviewFile?.(file); + setMode('viewer'); + } } }); }; diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index 52db3b0de..e2b56770e 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -13,15 +13,16 @@ import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters"; import { useOCROperation } from "../hooks/tools/ocr/useOCROperation"; import { BaseToolProps, ToolComponent } from "../types/tool"; import { useOCRTips } from "../components/tooltips/useOCRTips"; +import { useAdvancedOCRTips } from "../components/tooltips/useAdvancedOCRTips"; const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { actions } = useNavigationActions(); const { selectedFiles } = useFileSelection(); const ocrParams = useOCRParameters(); const ocrOperation = useOCROperation(); const ocrTips = useOCRTips(); + const advancedOCRTips = useAdvancedOCRTips(); // Step expansion state management const [expandedStep, setExpandedStep] = useState<"files" | "settings" | "advanced" | null>("files"); @@ -82,7 +83,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }, steps: [ { - title: "Settings", + title: t("ocr.settings.title", "Settings"), isCollapsed: !hasFiles || settingsCollapsed, onCollapsedClick: hasResults ? handleSettingsReset @@ -108,6 +109,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { if (!hasFiles) return; // Only allow if files are selected setExpandedStep(expandedStep === "advanced" ? null : "advanced"); }, + tooltip: advancedOCRTips, content: ( ; } +export type AutomationStep = typeof import('../constants/automation').AUTOMATION_STEPS[keyof typeof import('../constants/automation').AUTOMATION_STEPS]; + export interface AutomationStepData { - step: 'selection' | 'creation' | 'run'; + step: AutomationStep; mode?: AutomationMode; automation?: AutomationConfig; } diff --git a/frontend/src/types/navigation.ts b/frontend/src/types/navigation.ts index 61aa24cc3..70d108c9a 100644 --- a/frontend/src/types/navigation.ts +++ b/frontend/src/types/navigation.ts @@ -33,7 +33,7 @@ export const isValidMode = (mode: string): mode is ModeType => { return validModes.includes(mode as ModeType); }; -export const getDefaultMode = (): ModeType => 'pageEditor'; +export const getDefaultMode = (): ModeType => 'fileEditor'; // Route parsing result export interface ToolRoute { diff --git a/frontend/src/types/rightRail.ts b/frontend/src/types/rightRail.ts new file mode 100644 index 000000000..1897a7170 --- /dev/null +++ b/frontend/src/types/rightRail.ts @@ -0,0 +1,26 @@ +import React from 'react'; + +export type RightRailSection = 'top' | 'middle' | 'bottom'; + +export interface RightRailButtonConfig { + /** Unique id for the button, also used to bind action callbacks */ + id: string; + /** Icon element to render */ + icon: React.ReactNode; + /** Tooltip content (can be localized node) */ + tooltip: React.ReactNode; + /** Optional ARIA label for a11y (separate from visual tooltip) */ + ariaLabel?: string; + /** Optional i18n key carried by config */ + templateKey?: string; + /** Visual grouping lane */ + section?: RightRailSection; + /** Sorting within a section (lower first); ties broken by id */ + order?: number; + /** Initial disabled state */ + disabled?: boolean; + /** Initial visibility */ + visible?: boolean; +} + +export type RightRailAction = () => void; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 215a9378b..6886183a1 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -42,7 +42,7 @@ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ + "resolveJsonModule": true, /* Enable importing .json files. */ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */