diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 9b8533024..117f8fbf0 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1011,7 +1011,49 @@ "submit": "Change" }, "removePages": { - "tags": "Remove pages,delete pages" + "tags": "Remove pages,delete pages", + "title": "Remove Pages", + "pageNumbers": "Pages to Remove", + "pageNumbersPlaceholder": "e.g. 1,3,5-7", + "pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", + "filenamePrefix": "pages_removed", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "settings": { + "title": "Page Selection" + }, + "error": { + "failed": "An error occurred whilst removing pages." + }, + "results": { + "title": "Page Removal Results" + }, + "submit": "Remove Pages" + }, + "pageSelection": { + "tooltip": { + "header": { + "title": "Page Selection Guide" + }, + "basic": { + "title": "Basic Usage", + "text": "Select specific pages from your PDF document using simple syntax.", + "bullet1": "Individual pages: 1,3,5", + "bullet2": "Page ranges: 3-6 or 10-15", + "bullet3": "All pages: all" + }, + "advanced": { + "title": "Advanced Features" + }, + "tips": { + "title": "Tips", + "text": "Keep these guidelines in mind:", + "bullet1": "Page numbers start from 1 (not 0)", + "bullet2": "Spaces are automatically removed", + "bullet3": "Invalid expressions are ignored" + } + } }, "compressPdfs": { "tags": "squish,small,tiny" @@ -1020,7 +1062,18 @@ "tags": "remove,delete,form,field,readonly", "title": "Remove Read-Only from Form Fields", "header": "Unlock PDF Forms", - "submit": "Remove" + "submit": "Unlock Forms", + "description": "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.", + "filenamePrefix": "unlocked_forms", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred whilst unlocking PDF forms." + }, + "results": { + "title": "Unlocked Forms Results" + } }, "changeMetadata": { "tags": "Title,author,date,creation,time,publisher,producer,stats", @@ -1188,7 +1241,18 @@ "tags": "fix,restore,correction,recover", "title": "Repair", "header": "Repair PDFs", - "submit": "Repair" + "submit": "Repair", + "description": "This tool will attempt to repair corrupted or damaged PDF files. No additional settings are required.", + "filenamePrefix": "repaired", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred whilst repairing the PDF." + }, + "results": { + "title": "Repair Results" + } }, "removeBlanks": { "tags": "cleanup,streamline,non-content,organize", @@ -1257,7 +1321,18 @@ "title": "Remove Certificate Signature", "header": "Remove the digital certificate from the PDF", "selectPDF": "Select a PDF file:", - "submit": "Remove Signature" + "submit": "Remove Signature", + "description": "This tool will remove digital certificate signatures from your PDF document.", + "filenamePrefix": "unsigned", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred whilst removing certificate signatures." + }, + "results": { + "title": "Certificate Removal Results" + } }, "pageLayout": { "tags": "merge,composite,single-view,organize", @@ -1585,7 +1660,18 @@ "pdfToSinglePage": { "title": "PDF To Single Page", "header": "PDF To Single Page", - "submit": "Convert To Single Page" + "submit": "Convert To Single Page", + "description": "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.", + "filenamePrefix": "single_page", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred whilst converting to single page." + }, + "results": { + "title": "Single Page Results" + } }, "pageExtracter": { "title": "Extract Pages", @@ -1947,7 +2033,14 @@ "fileSize": "Size", "fileVersion": "Version", "totalSelected": "Total Selected", - "dropFilesHere": "Drop files here" + "dropFilesHere": "Drop files here", + "selectAll": "Select All", + "deselectAll": "Deselect All", + "deleteSelected": "Delete Selected", + "downloadSelected": "Download Selected", + "selectedCount": "{{count}} selected", + "download": "Download", + "delete": "Delete" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index af7188944..358ccd53a 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -738,7 +738,72 @@ "submit": "Change" }, "removePages": { - "tags": "Remove pages,delete pages" + "tags": "Remove pages,delete pages", + "title": "Remove Pages", + "pageNumbers": "Pages to Remove", + "pageNumbersPlaceholder": "e.g. 1,3,5-7", + "pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", + "filenamePrefix": "pages_removed", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "settings": { + "title": "Page Selection" + }, + "error": { + "failed": "An error occurred while removing pages." + }, + "results": { + "title": "Page Removal Results" + }, + "submit": "Remove Pages" + }, + "pageSelection": { + "tooltip": { + "header": { + "title": "Page Selection Guide" + }, + "basic": { + "title": "Basic Usage", + "text": "Select specific pages from your PDF document using simple syntax.", + "bullet1": "Individual pages: 1,3,5", + "bullet2": "Page ranges: 3-6 or 10-15", + "bullet3": "All pages: all" + }, + "advanced": { + "title": "Advanced Features", + "expandText": "▶ Show advanced options", + "collapseText": "▼ Hide advanced options", + "mathematical": { + "title": "Mathematical Functions", + "text": "Use mathematical expressions to select page patterns:", + "bullet1": "2n - all even pages (2, 4, 6, 8...)", + "bullet2": "2n+1 - all odd pages (1, 3, 5, 7...)", + "bullet3": "3n - every 3rd page (3, 6, 9, 12...)", + "bullet4": "4n-1 - pages 3, 7, 11, 15..." + }, + "ranges": { + "title": "Open-ended Ranges", + "text": "Select from a starting point to the end:", + "bullet1": "5- selects pages 5 to end of document", + "bullet2": "10- selects pages 10 to end" + }, + "combinations": { + "title": "Complex Combinations", + "text": "Combine different selection methods:", + "bullet1": "1,3-5,8,2n - pages 1, 3-5, 8, and all even pages", + "bullet2": "10-,2n+1 - pages 10 to end plus all odd pages", + "bullet3": "1-5,15-,3n - pages 1-5, 15 to end, and every 3rd page" + } + }, + "tips": { + "title": "Tips", + "text": "Keep these guidelines in mind:", + "bullet1": "Page numbers start from 1 (not 0)", + "bullet2": "Spaces are automatically removed", + "bullet3": "Invalid expressions are ignored" + } + } }, "compressPdfs": { "tags": "squish,small,tiny" @@ -747,7 +812,18 @@ "tags": "remove,delete,form,field,readonly", "title": "Remove Read-Only from Form Fields", "header": "Unlock PDF Forms", - "submit": "Remove" + "submit": "Unlock Forms", + "description": "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.", + "filenamePrefix": "unlocked_forms", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred while unlocking PDF forms." + }, + "results": { + "title": "Unlocked Forms Results" + } }, "changeMetadata": { "tags": "Title,author,date,creation,time,publisher,producer,stats", @@ -915,7 +991,18 @@ "tags": "fix,restore,correction,recover", "title": "Repair", "header": "Repair PDFs", - "submit": "Repair" + "submit": "Repair", + "description": "This tool will attempt to repair corrupted or damaged PDF files. No additional settings are required.", + "filenamePrefix": "repaired", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred while repairing the PDF." + }, + "results": { + "title": "Repair Results" + } }, "removeBlanks": { "tags": "cleanup,streamline,non-content,organize", @@ -984,7 +1071,18 @@ "title": "Remove Certificate Signature", "header": "Remove the digital certificate from the PDF", "selectPDF": "Select a PDF file:", - "submit": "Remove Signature" + "submit": "Remove Signature", + "description": "This tool will remove digital certificate signatures from your PDF document.", + "filenamePrefix": "unsigned", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred while removing certificate signatures." + }, + "results": { + "title": "Certificate Removal Results" + } }, "pageLayout": { "tags": "merge,composite,single-view,organize", @@ -1312,7 +1410,18 @@ "pdfToSinglePage": { "title": "PDF To Single Page", "header": "PDF To Single Page", - "submit": "Convert To Single Page" + "submit": "Convert To Single Page", + "description": "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.", + "filenamePrefix": "single_page", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred while converting to single page." + }, + "results": { + "title": "Single Page Results" + } }, "pageExtracter": { "title": "Extract Pages", diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index 4ea5c7a2a..f21a8ae78 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -160,7 +160,6 @@ const FileManager: React.FC = ({ selectedTool }) => { isOpen={isFilesModalOpen} onFileRemove={handleRemoveFileByIndex} modalHeight={modalHeight} - storeFile={storeFileWithId} refreshRecentFiles={refreshRecentFiles} > {isMobile ? : } diff --git a/frontend/src/components/fileManager/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx index be701ff20..8d1e32ffc 100644 --- a/frontend/src/components/fileManager/DesktopLayout.tsx +++ b/frontend/src/components/fileManager/DesktopLayout.tsx @@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons'; import FileDetails from './FileDetails'; import SearchInput from './SearchInput'; import FileListArea from './FileListArea'; +import FileActions from './FileActions'; import HiddenFileInput from './HiddenFileInput'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; @@ -17,27 +18,27 @@ const DesktopLayout: React.FC = () => { return ( {/* Column 1: File Sources */} - - + {/* Column 2: File List */} - -
{ overflow: 'hidden' }}> {activeSource === 'recent' && ( -
- -
+ <> +
+ +
+
+ +
+ )} - +
0 ? modalHeight : '100%', backgroundColor: 'transparent', border: 'none', @@ -66,12 +75,12 @@ const DesktopLayout: React.FC = () => {
- + {/* Column 3: File Details */} - @@ -79,11 +88,11 @@ const DesktopLayout: React.FC = () => { - + {/* Hidden file input for local file selection */}
); }; -export default DesktopLayout; \ No newline at end of file +export default DesktopLayout; diff --git a/frontend/src/components/fileManager/FileActions.tsx b/frontend/src/components/fileManager/FileActions.tsx new file mode 100644 index 000000000..7bc8d27bc --- /dev/null +++ b/frontend/src/components/fileManager/FileActions.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Group, Text, ActionIcon, Tooltip } from "@mantine/core"; +import SelectAllIcon from "@mui/icons-material/SelectAll"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DownloadIcon from "@mui/icons-material/Download"; +import { useTranslation } from "react-i18next"; +import { useFileManagerContext } from "../../contexts/FileManagerContext"; + +const FileActions: React.FC = () => { + const { t } = useTranslation(); + const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } = + useFileManagerContext(); + + const handleSelectAll = () => { + onSelectAll(); + }; + + const handleDeleteSelected = () => { + if (selectedFileIds.length > 0) { + onDeleteSelected(); + } + }; + + const handleDownloadSelected = () => { + if (selectedFileIds.length > 0) { + onDownloadSelected(); + } + }; + + // Only show actions if there are files + if (recentFiles.length === 0) { + return null; + } + + const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; + const hasSelection = selectedFileIds.length > 0; + + return ( +
+ {/* Left: Select All */} +
+ + + + + +
+ + {/* Center: Selected count */} +
+ {hasSelection && ( + + {t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })} + + )} +
+ + {/* Right: Delete and Download */} + + + + + + + + + + + + + +
+ ); +}; + +export default FileActions; diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx index 8e1975137..bb376765b 100644 --- a/frontend/src/components/fileManager/FileListArea.tsx +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -19,22 +19,23 @@ const FileListArea: React.FC = ({ activeSource, recentFiles, filteredFiles, - selectedFileIds, + selectedFilesSet, onFileSelect, onFileRemove, onFileDoubleClick, + onDownloadSingle, isFileSupported, } = useFileManagerContext(); const { t } = useTranslation(); if (activeSource === 'recent') { return ( - @@ -51,12 +52,13 @@ const FileListArea: React.FC = ({ ) : ( filteredFiles.map((file, index) => ( onFileSelect(file)} + onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)} onRemove={() => onFileRemove(index)} + onDownload={() => onDownloadSingle(file)} onDoubleClick={() => onFileDoubleClick(file)} /> )) @@ -77,4 +79,4 @@ const FileListArea: React.FC = ({ ); }; -export default FileListArea; \ No newline at end of file +export default FileListArea; diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index 09e508c50..b04f9bc41 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -1,8 +1,9 @@ import React, { useState } from 'react'; -import { Group, Box, Text, ActionIcon, Checkbox, Divider, Badge } from '@mantine/core'; +import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import DeleteIcon from '@mui/icons-material/Delete'; -import PushPinIcon from '@mui/icons-material/PushPin'; -import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; +import DownloadIcon from '@mui/icons-material/Download'; +import { useTranslation } from 'react-i18next'; import { getFileSize, getFileDate } from '../../utils/fileUtils'; import { FileMetadata } from '../../types/file'; @@ -10,39 +11,44 @@ interface FileListItemProps { file: FileMetadata; isSelected: boolean; isSupported: boolean; - isPinned?: boolean; - onSelect: () => void; + onSelect: (shiftKey?: boolean) => void; onRemove: () => void; - onPin?: () => void; - onUnpin?: () => void; + onDownload?: () => void; onDoubleClick?: () => void; isLast?: boolean; } -const FileListItem: React.FC = ({ - file, - isSelected, +const FileListItem: React.FC = ({ + file, + isSelected, isSupported, - isPinned = false, - onSelect, + onSelect, onRemove, - onPin, - onUnpin, + onDownload, onDoubleClick }) => { const [isHovered, setIsHovered] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { t } = useTranslation(); + + // Keep item in hovered state if menu is open + const shouldShowHovered = isHovered || isMenuOpen; return ( <> - onSelect(e.shiftKey)} onDoubleClick={onDoubleClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -62,7 +68,7 @@ const FileListItem: React.FC = ({ }} /> - + {file.name} @@ -74,51 +80,54 @@ const FileListItem: React.FC = ({ {getFileSize(file)} • {getFileDate(file)} - - {/* Pin button - always visible for pinned files, fades in/out on hover for unpinned */} - {(onPin || onUnpin) && ( - { - e.stopPropagation(); - if (isPinned) { - onUnpin?.(); - } else { - onPin?.(); - } - }} - style={{ - opacity: isPinned ? 1 : (isHovered ? 1 : 0), - transform: isPinned ? 'scale(1)' : (isHovered ? 'scale(1)' : 'scale(0.8)'), - transition: 'opacity 0.3s ease, transform 0.3s ease', - pointerEvents: isPinned ? 'auto' : (isHovered ? 'auto' : 'none') - }} - > - {isPinned ? ( - - ) : ( - - )} - - )} - - {/* Delete button - fades in/out on hover */} - { e.stopPropagation(); onRemove(); }} - style={{ - opacity: isHovered ? 1 : 0, - transform: isHovered ? 'scale(1)' : 'scale(0.8)', - transition: 'opacity 0.3s ease, transform 0.3s ease', - pointerEvents: isHovered ? 'auto' : 'none' - }} + + {/* Three dots menu - fades in/out on hover */} + setIsMenuOpen(true)} + onClose={() => setIsMenuOpen(false)} > - - + + e.stopPropagation()} + style={{ + opacity: shouldShowHovered ? 1 : 0, + transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)', + transition: 'opacity 0.3s ease, transform 0.3s ease', + pointerEvents: shouldShowHovered ? 'auto' : 'none' + }} + > + + + + + + {onDownload && ( + } + onClick={(e) => { + e.stopPropagation(); + onDownload(); + }} + > + {t('fileManager.download', 'Download')} + + )} + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t('fileManager.delete', 'Delete')} + + + { } @@ -126,4 +135,4 @@ const FileListItem: React.FC = ({ ); }; -export default FileListItem; \ No newline at end of file +export default FileListItem; diff --git a/frontend/src/components/fileManager/MobileLayout.tsx b/frontend/src/components/fileManager/MobileLayout.tsx index 30d1ad6b9..5201aafb4 100644 --- a/frontend/src/components/fileManager/MobileLayout.tsx +++ b/frontend/src/components/fileManager/MobileLayout.tsx @@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons'; import FileDetails from './FileDetails'; import SearchInput from './SearchInput'; import FileListArea from './FileListArea'; +import FileActions from './FileActions'; import HiddenFileInput from './HiddenFileInput'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; @@ -22,10 +23,11 @@ const MobileLayout: React.FC = () => { // Estimate heights of fixed components const fileSourceHeight = '3rem'; // FileSourceButtons height const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height + const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom) const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height - const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps + const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps - return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`; + return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`; }; return ( @@ -51,12 +53,20 @@ const MobileLayout: React.FC = () => { minHeight: 0 }}> {activeSource === 'recent' && ( - - - + <> + + + + + + + )} diff --git a/frontend/src/components/shared/FilePickerModal.tsx b/frontend/src/components/shared/FilePickerModal.tsx index 1be0a4ec8..8aa054f25 100644 --- a/frontend/src/components/shared/FilePickerModal.tsx +++ b/frontend/src/components/shared/FilePickerModal.tsx @@ -48,7 +48,7 @@ const FilePickerModal = ({ }; const selectAll = () => { - setSelectedFileIds(storedFiles.map(f => f.id || f.name)); + setSelectedFileIds(storedFiles.map(f => f.id).filter(Boolean)); }; const selectNone = () => { @@ -57,7 +57,7 @@ const FilePickerModal = ({ const handleConfirm = async () => { const selectedFiles = storedFiles.filter(f => - selectedFileIds.includes(f.id || f.name) + selectedFileIds.includes(f.id) ); // Convert stored files to File objects @@ -154,7 +154,7 @@ const FilePickerModal = ({ {storedFiles.map((file) => { - const fileId = file.id || file.name; + const fileId = file.id; const isSelected = selectedFileIds.includes(fileId); return ( diff --git a/frontend/src/components/tools/removeCertificateSign/RemoveCertificateSignSettings.tsx b/frontend/src/components/tools/removeCertificateSign/RemoveCertificateSignSettings.tsx new file mode 100644 index 000000000..f34e3f2e6 --- /dev/null +++ b/frontend/src/components/tools/removeCertificateSign/RemoveCertificateSignSettings.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { RemoveCertificateSignParameters } from '../../../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters'; + +interface RemoveCertificateSignSettingsProps { + parameters: RemoveCertificateSignParameters; + onParameterChange: (parameter: K, value: RemoveCertificateSignParameters[K]) => void; + disabled?: boolean; +} + +const RemoveCertificateSignSettings: React.FC = ({ + parameters, + onParameterChange, // Unused - kept for interface consistency and future extensibility + disabled = false +}) => { + const { t } = useTranslation(); + + return ( +
+

+ {t('removeCertSign.description', 'This tool will remove digital certificate signatures from your PDF document.')} +

+
+ ); +}; + +export default RemoveCertificateSignSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/repair/RepairSettings.tsx b/frontend/src/components/tools/repair/RepairSettings.tsx new file mode 100644 index 000000000..15078defb --- /dev/null +++ b/frontend/src/components/tools/repair/RepairSettings.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { RepairParameters } from '../../../hooks/tools/repair/useRepairParameters'; + +interface RepairSettingsProps { + parameters: RepairParameters; + onParameterChange: (parameter: K, value: RepairParameters[K]) => void; + disabled?: boolean; +} + +const RepairSettings: React.FC = ({ + parameters, + onParameterChange, + disabled = false +}) => { + const { t } = useTranslation(); + + return ( +
+

+ {t('repair.description', 'This tool will attempt to repair corrupted or damaged PDF files. No additional settings are required.')} +

+
+ ); +}; + +export default RepairSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index e6f0b2e2f..22a25e627 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -1,5 +1,6 @@ -import React from 'react'; -import { Text } from '@mantine/core'; +import React from "react"; +import { Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; export interface FileStatusIndicatorProps { selectedFiles?: File[]; @@ -8,20 +9,25 @@ export interface FileStatusIndicatorProps { const FileStatusIndicator = ({ selectedFiles = [], - placeholder = "Select a PDF file in the main view to get started" + placeholder, }: FileStatusIndicatorProps) => { + const { t } = useTranslation(); + const defaultPlaceholder = placeholder || t("files.placeholder", "Select a PDF file in the main view to get started"); // Only show content when no files are selected if (selectedFiles.length === 0) { return ( - {placeholder} + {defaultPlaceholder} ); } - // Return nothing when files are selected - return null; -} + return ( + + ✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })} + + ); +}; -export default FileStatusIndicator; \ No newline at end of file +export default FileStatusIndicator; diff --git a/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx b/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx new file mode 100644 index 000000000..87dfef926 --- /dev/null +++ b/frontend/src/components/tools/singleLargePage/SingleLargePageSettings.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SingleLargePageParameters } from '../../../hooks/tools/singleLargePage/useSingleLargePageParameters'; + +interface SingleLargePageSettingsProps { + parameters: SingleLargePageParameters; + onParameterChange: (parameter: K, value: SingleLargePageParameters[K]) => void; + disabled?: boolean; +} + +const SingleLargePageSettings: React.FC = ({ + parameters, + onParameterChange, + disabled = false +}) => { + const { t } = useTranslation(); + + return ( +
+

+ {t('pdfToSinglePage.description', 'This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.')} +

+
+ ); +}; + +export default SingleLargePageSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/unlockPdfForms/UnlockPdfFormsSettings.tsx b/frontend/src/components/tools/unlockPdfForms/UnlockPdfFormsSettings.tsx new file mode 100644 index 000000000..cc8697d7a --- /dev/null +++ b/frontend/src/components/tools/unlockPdfForms/UnlockPdfFormsSettings.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { UnlockPdfFormsParameters } from '../../../hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters'; + +interface UnlockPdfFormsSettingsProps { + parameters: UnlockPdfFormsParameters; + onParameterChange: (parameter: K, value: UnlockPdfFormsParameters[K]) => void; + disabled?: boolean; +} + +const UnlockPdfFormsSettings: React.FC = ({ + parameters, + onParameterChange, // Unused - kept for interface consistency and future extensibility + disabled = false +}) => { + const { t } = useTranslation(); + + return ( +
+

+ {t('unlockPDFForms.description', 'This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.')} +

+
+ ); +}; + +export default UnlockPdfFormsSettings; \ No newline at end of file diff --git a/frontend/src/components/tooltips/usePageSelectionTips.tsx b/frontend/src/components/tooltips/usePageSelectionTips.tsx new file mode 100644 index 000000000..d51034992 --- /dev/null +++ b/frontend/src/components/tooltips/usePageSelectionTips.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +/** + * Reusable tooltip for page selection functionality. + * Can be used by any tool that uses the GeneralUtils.parsePageList syntax. + */ +export const usePageSelectionTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("pageSelection.tooltip.header.title", "Page Selection Guide") + }, + tips: [ + { + description: t("pageSelection.tooltip.description", "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.") + }, + { + title: t("pageSelection.tooltip.individual.title", "Individual Pages"), + description: t("pageSelection.tooltip.individual.description", "Enter numbers separated by commas."), + bullets: [ + t("pageSelection.tooltip.individual.bullet1", "1,3,5 → selects pages 1, 3, 5"), + t("pageSelection.tooltip.individual.bullet2", "2,7,12 → selects pages 2, 7, 12") + ] + }, + { + title: t("pageSelection.tooltip.ranges.title", "Page Ranges"), + description: t("pageSelection.tooltip.ranges.description", "Use - for consecutive pages."), + bullets: [ + t("pageSelection.tooltip.ranges.bullet1", "3-6 → selects pages 3–6"), + t("pageSelection.tooltip.ranges.bullet2", "10-15 → selects pages 10–15"), + t("pageSelection.tooltip.ranges.bullet3", "5- → selects pages 5 to end") + ] + }, + { + title: t("pageSelection.tooltip.mathematical.title", "Mathematical Functions"), + description: t("pageSelection.tooltip.mathematical.description", "Use n in formulas for patterns."), + bullets: [ + t("pageSelection.tooltip.mathematical.bullet2", "2n-1 → all odd pages (1, 3, 5…)"), + t("pageSelection.tooltip.mathematical.bullet1", "2n → all even pages (2, 4, 6…)"), + t("pageSelection.tooltip.mathematical.bullet3", "3n → every 3rd page (3, 6, 9…)"), + t("pageSelection.tooltip.mathematical.bullet4", "4n-1 → pages 3, 7, 11, 15…") + ] + }, + { + title: t("pageSelection.tooltip.special.title", "Special Keywords"), + bullets: [ + t("pageSelection.tooltip.special.bullet1", "all → selects all pages"), + ] + }, + { + title: t("pageSelection.tooltip.complex.title", "Complex Combinations"), + description: t("pageSelection.tooltip.complex.description", "Mix different types."), + bullets: [ + t("pageSelection.tooltip.complex.bullet1", "1,3-5,8,2n → pages 1, 3–5, 8, plus evens"), + t("pageSelection.tooltip.complex.bullet2", "10-,2n-1 → from page 10 to end + odd pages") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index 4ad397542..28b3d52df 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { FileMetadata } from '../types/file'; -import { StoredFile } from '../services/fileStorage'; +import { StoredFile, fileStorage } from '../services/fileStorage'; +import { downloadFiles } from '../utils/downloadUtils'; // Type for the context value - now contains everything directly interface FileManagerContextValue { @@ -11,16 +12,21 @@ interface FileManagerContextValue { selectedFiles: FileMetadata[]; filteredFiles: FileMetadata[]; fileInputRef: React.RefObject; + selectedFilesSet: Set; // Handlers onSourceChange: (source: 'recent' | 'local' | 'drive') => void; onLocalFileClick: () => void; - onFileSelect: (file: FileMetadata) => void; + onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void; onFileRemove: (index: number) => void; onFileDoubleClick: (file: FileMetadata) => void; onOpenFiles: () => void; onSearchChange: (value: string) => void; onFileInputChange: (event: React.ChangeEvent) => void; + onSelectAll: () => void; + onDeleteSelected: () => void; + onDownloadSelected: () => void; + onDownloadSingle: (file: FileMetadata) => void; // External props recentFiles: FileMetadata[]; @@ -42,7 +48,6 @@ interface FileManagerProviderProps { isOpen: boolean; onFileRemove: (index: number) => void; modalHeight: string; - storeFile: (file: File, fileId: string) => Promise; refreshRecentFiles: () => Promise; } @@ -56,28 +61,34 @@ export const FileManagerProvider: React.FC = ({ isOpen, onFileRemove, modalHeight, - storeFile, refreshRecentFiles, }) => { const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent'); const [selectedFileIds, setSelectedFileIds] = useState([]); const [searchTerm, setSearchTerm] = useState(''); + const [lastClickedIndex, setLastClickedIndex] = useState(null); const fileInputRef = useRef(null); // Track blob URLs for cleanup const createdBlobUrls = useRef>(new Set()); // Computed values (with null safety) - const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name)); - const filteredFiles = (recentFiles || []).filter(file => - file.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); + const selectedFilesSet = new Set(selectedFileIds); + + const selectedFiles = selectedFileIds.length === 0 ? [] : + (recentFiles || []).filter(file => selectedFilesSet.has(file.id)); + + const filteredFiles = !searchTerm ? recentFiles || [] : + (recentFiles || []).filter(file => + file.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => { setActiveSource(source); if (source !== 'recent') { setSelectedFileIds([]); setSearchTerm(''); + setLastClickedIndex(null); } }, []); @@ -85,19 +96,46 @@ export const FileManagerProvider: React.FC = ({ fileInputRef.current?.click(); }, []); - const handleFileSelect = useCallback((file: FileMetadata) => { - setSelectedFileIds(prev => { - if (file.id) { - if (prev.includes(file.id)) { - return prev.filter(id => id !== file.id); - } else { - return [...prev, file.id]; + const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => { + const fileId = file.id; + if (!fileId) return; + + if (shiftKey && lastClickedIndex !== null) { + // Range selection with shift-click + const startIndex = Math.min(lastClickedIndex, currentIndex); + const endIndex = Math.max(lastClickedIndex, currentIndex); + + setSelectedFileIds(prev => { + const selectedSet = new Set(prev); + + // Add all files in the range to selection + for (let i = startIndex; i <= endIndex; i++) { + const rangeFileId = filteredFiles[i]?.id; + if (rangeFileId) { + selectedSet.add(rangeFileId); + } } - } else { - return prev; - } - }); - }, []); + + return Array.from(selectedSet); + }); + } else { + // Normal click behavior - optimized with Set for O(1) lookup + setSelectedFileIds(prev => { + const selectedSet = new Set(prev); + + if (selectedSet.has(fileId)) { + selectedSet.delete(fileId); + } else { + selectedSet.add(fileId); + } + + return Array.from(selectedSet); + }); + + // Update last clicked index for future range selections + setLastClickedIndex(currentIndex); + } + }, [filteredFiles, lastClickedIndex]); const handleFileRemove = useCallback((index: number) => { const fileToRemove = filteredFiles[index]; @@ -140,6 +178,71 @@ export const FileManagerProvider: React.FC = ({ event.target.value = ''; }, [onNewFilesSelect, refreshRecentFiles, onClose]); + const handleSelectAll = useCallback(() => { + const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; + if (allFilesSelected) { + // Deselect all + setSelectedFileIds([]); + setLastClickedIndex(null); + } else { + // Select all filtered files + setSelectedFileIds(filteredFiles.map(file => file.id).filter(Boolean)); + setLastClickedIndex(null); + } + }, [filteredFiles, selectedFileIds]); + + const handleDeleteSelected = useCallback(async () => { + if (selectedFileIds.length === 0) return; + + try { + // Get files to delete based on current filtered view + const filesToDelete = filteredFiles.filter(file => + selectedFileIds.includes(file.id) + ); + + // Delete files from storage + for (const file of filesToDelete) { + await fileStorage.deleteFile(file.id); + } + + // Clear selection + setSelectedFileIds([]); + + // Refresh the file list + await refreshRecentFiles(); + } catch (error) { + console.error('Failed to delete selected files:', error); + } + }, [selectedFileIds, filteredFiles, refreshRecentFiles]); + + + const handleDownloadSelected = useCallback(async () => { + if (selectedFileIds.length === 0) return; + + try { + // Get selected files + const selectedFilesToDownload = filteredFiles.filter(file => + selectedFileIds.includes(file.id) + ); + + // Use generic download utility + await downloadFiles(selectedFilesToDownload, { + zipFilename: `selected-files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip` + }); + } catch (error) { + console.error('Failed to download selected files:', error); + } + }, [selectedFileIds, filteredFiles]); + + const handleDownloadSingle = useCallback(async (file: FileMetadata) => { + try { + await downloadFiles([file]); + } catch (error) { + console.error('Failed to download file:', error); + } + }, []); + + // Cleanup blob URLs when component unmounts useEffect(() => { return () => { @@ -157,6 +260,7 @@ export const FileManagerProvider: React.FC = ({ setActiveSource('recent'); setSelectedFileIds([]); setSearchTerm(''); + setLastClickedIndex(null); } }, [isOpen]); @@ -168,6 +272,7 @@ export const FileManagerProvider: React.FC = ({ selectedFiles, filteredFiles, fileInputRef, + selectedFilesSet, // Handlers onSourceChange: handleSourceChange, @@ -178,6 +283,10 @@ export const FileManagerProvider: React.FC = ({ onOpenFiles: handleOpenFiles, onSearchChange: handleSearchChange, onFileInputChange: handleFileInputChange, + onSelectAll: handleSelectAll, + onDeleteSelected: handleDeleteSelected, + onDownloadSelected: handleDownloadSelected, + onDownloadSingle: handleDownloadSingle, // External props recentFiles, @@ -198,6 +307,9 @@ export const FileManagerProvider: React.FC = ({ handleOpenFiles, handleSearchChange, handleFileInputChange, + handleSelectAll, + handleDeleteSelected, + handleDownloadSelected, recentFiles, isFileSupported, modalHeight, diff --git a/frontend/src/contexts/NavigationContext.tsx b/frontend/src/contexts/NavigationContext.tsx index 8bffdfe9e..c85eef158 100644 --- a/frontend/src/contexts/NavigationContext.tsx +++ b/frontend/src/contexts/NavigationContext.tsx @@ -9,21 +9,25 @@ import { useNavigationUrlSync } from '../hooks/useUrlSync'; * maintain clear separation of concerns. */ -// Navigation mode types -export type ModeType = - | 'viewer' - | 'pageEditor' - | 'fileEditor' - | 'merge' - | 'split' - | 'compress' - | 'ocr' - | 'convert' - | 'addPassword' +// Navigation mode types - complete list to match fileContext.ts +export type ModeType = + | 'viewer' + | 'pageEditor' + | 'fileEditor' + | 'merge' + | 'split' + | 'compress' + | 'ocr' + | 'convert' + | 'sanitize' + | 'addPassword' + | 'changePermissions' | 'addWatermark' | 'removePassword' - | 'changePermissions' - | 'sanitize'; + | 'single-large-page' + | 'repair' + | 'unlockPdfForms' + | 'removeCertificateSign'; // Navigation state interface NavigationState { diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 5f6460217..359cf6d35 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -10,6 +10,12 @@ import ChangePermissions from '../tools/ChangePermissions'; import RemovePassword from '../tools/RemovePassword'; import { SubcategoryId, ToolCategory, ToolRegistry } from './toolsTaxonomy'; import AddWatermark from '../tools/AddWatermark'; +import Repair from '../tools/Repair'; +import SingleLargePage from '../tools/SingleLargePage'; +import UnlockPdfForms from '../tools/UnlockPdfForms'; +import RemoveCertificateSign from '../tools/RemoveCertificateSign'; + + // Hook to get the translated tool registry export function useFlatToolRegistry(): ToolRegistry { @@ -94,11 +100,13 @@ export function useFlatToolRegistry(): ToolRegistry { "unlock-pdf-forms": { icon: preview_off, name: t("home.unlockPDFForms.title", "Unlock PDF Forms"), - component: null, + component: UnlockPdfForms, view: "security", description: t("home.unlockPDFForms.desc", "Remove read-only property of form fields in a PDF document."), category: ToolCategory.STANDARD_TOOLS, - subcategory: SubcategoryId.DOCUMENT_SECURITY + subcategory: SubcategoryId.DOCUMENT_SECURITY, + maxFiles: -1, + endpoints: ["unlock-pdf-forms"] }, "manage-certificates": { icon: license, @@ -230,11 +238,13 @@ export function useFlatToolRegistry(): ToolRegistry { "single-large-page": { icon: looks_one, name: t("home.PdfToSinglePage.title", "PDF to Single Large Page"), - component: null, + component: SingleLargePage, view: "format", description: t("home.PdfToSinglePage.desc", "Merges all PDF pages into one large single page"), category: ToolCategory.STANDARD_TOOLS, - subcategory: SubcategoryId.PAGE_FORMATTING + subcategory: SubcategoryId.PAGE_FORMATTING, + maxFiles: -1, + endpoints: ["pdf-to-single-page"] }, "add-attachments": { icon: attachment, @@ -322,11 +332,13 @@ export function useFlatToolRegistry(): ToolRegistry { "remove-certificate-sign": { icon: remove_moderator, name: t("home.removeCertSign.title", "Remove Certificate Signatures"), - component: null, + component: RemoveCertificateSign, view: "security", description: t("home.removeCertSign.desc", "Remove digital signatures from PDF documents"), category: ToolCategory.STANDARD_TOOLS, - subcategory: SubcategoryId.REMOVAL + subcategory: SubcategoryId.REMOVAL, + maxFiles: -1, + endpoints: ["remove-certificate-sign"] }, @@ -384,11 +396,13 @@ export function useFlatToolRegistry(): ToolRegistry { "repair": { icon: build, name: t("home.repair.title", "Repair"), - component: null, + component: Repair, view: "format", description: t("home.repair.desc", "Repair corrupted or damaged PDF files"), category: ToolCategory.ADVANCED_TOOLS, - subcategory: SubcategoryId.ADVANCED_FORMATTING + subcategory: SubcategoryId.ADVANCED_FORMATTING, + maxFiles: -1, + endpoints: ["repair"] }, "detect-split-scanned-photos": { icon: scanner, diff --git a/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts b/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts new file mode 100644 index 000000000..5987944ec --- /dev/null +++ b/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { RemoveCertificateSignParameters } from './useRemoveCertificateSignParameters'; + +export const useRemoveCertificateSignOperation = () => { + const { t } = useTranslation(); + + const buildFormData = (parameters: RemoveCertificateSignParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + return formData; + }; + + return useToolOperation({ + operationType: 'removeCertificateSign', + endpoint: '/api/v1/security/remove-cert-sign', + buildFormData, + filePrefix: t('removeCertSign.filenamePrefix', 'unsigned') + '_', + multiFileEndpoint: false, + getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts b/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts new file mode 100644 index 000000000..59903ccfc --- /dev/null +++ b/frontend/src/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters.ts @@ -0,0 +1,19 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface RemoveCertificateSignParameters extends BaseParameters { + // Extends BaseParameters - ready for future parameter additions if needed +} + +export const defaultParameters: RemoveCertificateSignParameters = { + // No parameters needed +}; + +export type RemoveCertificateSignParametersHook = BaseParametersHook; + +export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'remove-certificate-sign', + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/repair/useRepairOperation.ts b/frontend/src/hooks/tools/repair/useRepairOperation.ts new file mode 100644 index 000000000..b547bbd8f --- /dev/null +++ b/frontend/src/hooks/tools/repair/useRepairOperation.ts @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { RepairParameters } from './useRepairParameters'; + +export const useRepairOperation = () => { + const { t } = useTranslation(); + + const buildFormData = (parameters: RepairParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + return formData; + }; + + return useToolOperation({ + operationType: 'repair', + endpoint: '/api/v1/misc/repair', + buildFormData, + filePrefix: t('repair.filenamePrefix', 'repaired') + '_', + multiFileEndpoint: false, + getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/repair/useRepairParameters.ts b/frontend/src/hooks/tools/repair/useRepairParameters.ts new file mode 100644 index 000000000..5c924de93 --- /dev/null +++ b/frontend/src/hooks/tools/repair/useRepairParameters.ts @@ -0,0 +1,20 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface RepairParameters extends BaseParameters { + // Extends BaseParameters - ready for future parameter additions if needed +} + +export const defaultParameters: RepairParameters = { + // No parameters needed +}; + +export type RepairParametersHook = BaseParametersHook; + +export const useRepairParameters = (): RepairParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'repair', + // validateFn: optional custom validation if needed in future + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/shared/useBaseParameters.ts b/frontend/src/hooks/tools/shared/useBaseParameters.ts new file mode 100644 index 000000000..af244e6f9 --- /dev/null +++ b/frontend/src/hooks/tools/shared/useBaseParameters.ts @@ -0,0 +1,46 @@ +import { useState, useCallback } from 'react'; + +export interface BaseParametersHook { + parameters: T; + updateParameter: (parameter: K, value: T[K]) => void; + resetParameters: () => void; + validateParameters: () => boolean; + getEndpointName: () => string; +} + +export interface BaseParametersConfig { + defaultParameters: T; + endpointName: string; + validateFn?: (params: T) => boolean; +} + +export function useBaseParameters(config: BaseParametersConfig): BaseParametersHook { + const [parameters, setParameters] = useState(config.defaultParameters); + + const updateParameter = useCallback((parameter: K, value: T[K]) => { + setParameters(prev => ({ + ...prev, + [parameter]: value, + })); + }, []); + + const resetParameters = useCallback(() => { + setParameters(config.defaultParameters); + }, [config.defaultParameters]); + + const validateParameters = useCallback(() => { + return config.validateFn ? config.validateFn(parameters) : true; + }, [parameters, config.validateFn]); + + const getEndpointName = useCallback(() => { + return config.endpointName; + }, [config.endpointName]); + + return { + parameters, + updateParameter, + resetParameters, + validateParameters, + getEndpointName, + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts new file mode 100644 index 000000000..e73944864 --- /dev/null +++ b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageOperation.ts @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { SingleLargePageParameters } from './useSingleLargePageParameters'; + +export const useSingleLargePageOperation = () => { + const { t } = useTranslation(); + + const buildFormData = (parameters: SingleLargePageParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + return formData; + }; + + return useToolOperation({ + operationType: 'singleLargePage', + endpoint: '/api/v1/general/pdf-to-single-page', + buildFormData, + filePrefix: t('pdfToSinglePage.filenamePrefix', 'single_page') + '_', + multiFileEndpoint: false, + getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/singleLargePage/useSingleLargePageParameters.ts b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageParameters.ts new file mode 100644 index 000000000..df401b1a4 --- /dev/null +++ b/frontend/src/hooks/tools/singleLargePage/useSingleLargePageParameters.ts @@ -0,0 +1,19 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface SingleLargePageParameters extends BaseParameters { + // Extends BaseParameters - ready for future parameter additions if needed +} + +export const defaultParameters: SingleLargePageParameters = { + // No parameters needed +}; + +export type SingleLargePageParametersHook = BaseParametersHook; + +export const useSingleLargePageParameters = (): SingleLargePageParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'pdf-to-single-page', + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts b/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts new file mode 100644 index 000000000..3b648762b --- /dev/null +++ b/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { UnlockPdfFormsParameters } from './useUnlockPdfFormsParameters'; + +export const useUnlockPdfFormsOperation = () => { + const { t } = useTranslation(); + + const buildFormData = (parameters: UnlockPdfFormsParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + return formData; + }; + + return useToolOperation({ + operationType: 'unlockPdfForms', + endpoint: '/api/v1/misc/unlock-pdf-forms', + buildFormData, + filePrefix: t('unlockPDFForms.filenamePrefix', 'unlocked_forms') + '_', + multiFileEndpoint: false, + getErrorMessage: createStandardErrorHandler(t('unlockPDFForms.error.failed', 'An error occurred while unlocking PDF forms.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters.ts b/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters.ts new file mode 100644 index 000000000..ad2536643 --- /dev/null +++ b/frontend/src/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters.ts @@ -0,0 +1,19 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface UnlockPdfFormsParameters extends BaseParameters { + // Extends BaseParameters - ready for future parameter additions if needed +} + +export const defaultParameters: UnlockPdfFormsParameters = { + // No parameters needed +}; + +export type UnlockPdfFormsParametersHook = BaseParametersHook; + +export const useUnlockPdfFormsParameters = (): UnlockPdfFormsParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'unlock-pdf-forms', + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index 516e34460..83576bb4b 100644 --- a/frontend/src/hooks/useFileManager.ts +++ b/frontend/src/hooks/useFileManager.ts @@ -61,14 +61,11 @@ export const useFileManager = () => { return []; } - // Load both regular files and drafts - const [storedFileMetadata, draftMetadata] = await Promise.all([ - indexedDB.loadAllMetadata(), - indexedDB.loadAllDraftMetadata() - ]); + // Load regular files metadata only + const storedFileMetadata = await indexedDB.loadAllMetadata(); - // Combine and sort by last modified - const allFiles = [...storedFileMetadata, ...draftMetadata]; + // For now, only regular files - drafts will be handled separately in the future + const allFiles = storedFileMetadata; const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); return sortedFiles; diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index 8f31200b6..53421920f 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -191,6 +191,19 @@ export const mantineTheme = createTheme({ }, }, }, + Tooltip: { + styles: { + tooltip: { + backgroundColor: 'var( --tooltip-title-bg)', + color: 'var( --tooltip-title-color)', + border: '1px solid var(--tooltip-borderp)', + fontSize: '0.75rem', + fontWeight: '500', + boxShadow: 'var(--shadow-md)', + borderRadius: 'var(--radius-sm)', + }, + }, + }, Checkbox: { styles: { diff --git a/frontend/src/tools/AddPassword.tsx b/frontend/src/tools/AddPassword.tsx index 7d27a1071..003cb1677 100644 --- a/frontend/src/tools/AddPassword.tsx +++ b/frontend/src/tools/AddPassword.tsx @@ -71,7 +71,7 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { return createToolFlow({ files: { selectedFiles, - isCollapsed: hasFiles || hasResults, + isCollapsed: hasResults, }, steps: [ { diff --git a/frontend/src/tools/AddWatermark.tsx b/frontend/src/tools/AddWatermark.tsx index b878ef13a..6dba7ae6c 100644 --- a/frontend/src/tools/AddWatermark.tsx +++ b/frontend/src/tools/AddWatermark.tsx @@ -192,7 +192,7 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => return createToolFlow({ files: { selectedFiles, - isCollapsed: hasFiles || hasResults, + isCollapsed: hasResults, }, steps: getSteps(), executeButton: { diff --git a/frontend/src/tools/ChangePermissions.tsx b/frontend/src/tools/ChangePermissions.tsx index 82dc74c5f..f6b762f4d 100644 --- a/frontend/src/tools/ChangePermissions.tsx +++ b/frontend/src/tools/ChangePermissions.tsx @@ -63,7 +63,7 @@ const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps return createToolFlow({ files: { selectedFiles, - isCollapsed: hasFiles || hasResults, + isCollapsed: hasResults, }, steps: [ { diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index 8ad3dee3b..53a4938dd 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -61,7 +61,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { return createToolFlow({ files: { selectedFiles, - isCollapsed: hasFiles && !hasResults, + isCollapsed: hasResults, }, steps: [ { diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx index afdfcb51b..2a8827cde 100644 --- a/frontend/src/tools/Convert.tsx +++ b/frontend/src/tools/Convert.tsx @@ -37,7 +37,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const hasFiles = selectedFiles.length > 0; const hasResults = convertOperation.downloadUrl !== null; - const filesCollapsed = hasFiles; const settingsCollapsed = hasResults; useEffect(() => { @@ -99,7 +98,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { return createToolFlow({ files: { selectedFiles, - isCollapsed: filesCollapsed, + isCollapsed: hasResults, placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"), }, steps: [ diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index 8dea1ffe7..958b5a31e 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -79,7 +79,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { return createToolFlow({ files: { selectedFiles, - isCollapsed: hasFiles || hasResults, + isCollapsed: hasResults, }, steps: [ { diff --git a/frontend/src/tools/RemoveCertificateSign.tsx b/frontend/src/tools/RemoveCertificateSign.tsx new file mode 100644 index 000000000..47011d4c8 --- /dev/null +++ b/frontend/src/tools/RemoveCertificateSign.tsx @@ -0,0 +1,81 @@ +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useEndpointEnabled } from "../hooks/useEndpointConfig"; +import { useFileContext } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; +import { useToolFileSelection } from "../contexts/file/fileHooks"; + +import { createToolFlow } from "../components/tools/shared/createToolFlow"; + +import { useRemoveCertificateSignParameters } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters"; +import { useRemoveCertificateSignOperation } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation"; +import { BaseToolProps } from "../types/tool"; + +const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useToolFileSelection(); + + const removeCertificateSignParams = useRemoveCertificateSignParameters(); + const removeCertificateSignOperation = useRemoveCertificateSignOperation(); + + // Endpoint validation + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(removeCertificateSignParams.getEndpointName()); + + useEffect(() => { + removeCertificateSignOperation.resetResults(); + onPreviewFile?.(null); + }, [removeCertificateSignParams.parameters]); + + const handleRemoveSignature = async () => { + try { + await removeCertificateSignOperation.executeOperation(removeCertificateSignParams.parameters, selectedFiles); + if (removeCertificateSignOperation.files && onComplete) { + onComplete(removeCertificateSignOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : t("removeCertSign.error.failed", "Remove certificate signature operation failed")); + } + } + }; + + const handleThumbnailClick = (file: File) => { + onPreviewFile?.(file); + sessionStorage.setItem("previousMode", "removeCertificateSign"); + actions.setMode("viewer"); + }; + + const handleSettingsReset = () => { + removeCertificateSignOperation.resetResults(); + onPreviewFile?.(null); + actions.setMode("removeCertificateSign"); + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = removeCertificateSignOperation.files.length > 0 || removeCertificateSignOperation.downloadUrl !== null; + + return createToolFlow({ + files: { + selectedFiles, + isCollapsed: hasFiles || hasResults, + placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"), + }, + steps: [], + executeButton: { + text: t("removeCertSign.submit", "Remove Signature"), + isVisible: !hasResults, + loadingText: t("loading"), + onClick: handleRemoveSignature, + disabled: !removeCertificateSignParams.validateParameters() || !hasFiles || !endpointEnabled, + }, + review: { + isVisible: hasResults, + operation: removeCertificateSignOperation, + title: t("removeCertSign.results.title", "Certificate Removal Results"), + onFileClick: handleThumbnailClick, + }, + }); +}; + +export default RemoveCertificateSign; \ No newline at end of file diff --git a/frontend/src/tools/RemovePassword.tsx b/frontend/src/tools/RemovePassword.tsx index d84ea9a0f..37f30d19a 100644 --- a/frontend/src/tools/RemovePassword.tsx +++ b/frontend/src/tools/RemovePassword.tsx @@ -65,7 +65,7 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = return createToolFlow({ files: { selectedFiles, - isCollapsed: hasFiles || hasResults, + isCollapsed: hasResults, }, steps: [ { diff --git a/frontend/src/tools/Repair.tsx b/frontend/src/tools/Repair.tsx new file mode 100644 index 000000000..66f92a641 --- /dev/null +++ b/frontend/src/tools/Repair.tsx @@ -0,0 +1,81 @@ +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useEndpointEnabled } from "../hooks/useEndpointConfig"; +import { useFileContext } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; +import { useToolFileSelection } from "../contexts/file/fileHooks"; + +import { createToolFlow } from "../components/tools/shared/createToolFlow"; + +import { useRepairParameters } from "../hooks/tools/repair/useRepairParameters"; +import { useRepairOperation } from "../hooks/tools/repair/useRepairOperation"; +import { BaseToolProps } from "../types/tool"; + +const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useToolFileSelection(); + + const repairParams = useRepairParameters(); + const repairOperation = useRepairOperation(); + + // Endpoint validation + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(repairParams.getEndpointName()); + + useEffect(() => { + repairOperation.resetResults(); + onPreviewFile?.(null); + }, [repairParams.parameters]); + + const handleRepair = async () => { + try { + await repairOperation.executeOperation(repairParams.parameters, selectedFiles); + if (repairOperation.files && onComplete) { + onComplete(repairOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : t("repair.error.failed", "Repair operation failed")); + } + } + }; + + const handleThumbnailClick = (file: File) => { + onPreviewFile?.(file); + sessionStorage.setItem("previousMode", "repair"); + actions.setMode("viewer"); + }; + + const handleSettingsReset = () => { + repairOperation.resetResults(); + onPreviewFile?.(null); + actions.setMode("repair"); + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = repairOperation.files.length > 0 || repairOperation.downloadUrl !== null; + + return createToolFlow({ + files: { + selectedFiles, + isCollapsed: hasResults, + placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"), + }, + steps: [], + executeButton: { + text: t("repair.submit", "Repair PDF"), + isVisible: !hasResults, + loadingText: t("loading"), + onClick: handleRepair, + disabled: !repairParams.validateParameters() || !hasFiles || !endpointEnabled, + }, + review: { + isVisible: hasResults, + operation: repairOperation, + title: t("repair.results.title", "Repair Results"), + onFileClick: handleThumbnailClick, + }, + }); +}; + +export default Repair; diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index b6d2e6441..4138e0f43 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -54,13 +54,12 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const hasFiles = selectedFiles.length > 0; const hasResults = sanitizeOperation.files.length > 0; - const filesCollapsed = hasFiles || hasResults; const settingsCollapsed = !hasFiles || hasResults; return createToolFlow({ files: { selectedFiles, - isCollapsed: filesCollapsed, + isCollapsed: hasResults, placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"), }, steps: [ diff --git a/frontend/src/tools/SingleLargePage.tsx b/frontend/src/tools/SingleLargePage.tsx new file mode 100644 index 000000000..00554deeb --- /dev/null +++ b/frontend/src/tools/SingleLargePage.tsx @@ -0,0 +1,81 @@ +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useEndpointEnabled } from "../hooks/useEndpointConfig"; +import { useFileContext } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; +import { useToolFileSelection } from "../contexts/file/fileHooks"; + +import { createToolFlow } from "../components/tools/shared/createToolFlow"; + +import { useSingleLargePageParameters } from "../hooks/tools/singleLargePage/useSingleLargePageParameters"; +import { useSingleLargePageOperation } from "../hooks/tools/singleLargePage/useSingleLargePageOperation"; +import { BaseToolProps } from "../types/tool"; + +const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useToolFileSelection(); + + const singleLargePageParams = useSingleLargePageParameters(); + const singleLargePageOperation = useSingleLargePageOperation(); + + // Endpoint validation + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(singleLargePageParams.getEndpointName()); + + useEffect(() => { + singleLargePageOperation.resetResults(); + onPreviewFile?.(null); + }, [singleLargePageParams.parameters]); + + const handleConvert = async () => { + try { + await singleLargePageOperation.executeOperation(singleLargePageParams.parameters, selectedFiles); + if (singleLargePageOperation.files && onComplete) { + onComplete(singleLargePageOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : t("pdfToSinglePage.error.failed", "Single large page operation failed")); + } + } + }; + + const handleThumbnailClick = (file: File) => { + onPreviewFile?.(file); + sessionStorage.setItem("previousMode", "single-large-page"); + actions.setMode("viewer"); + }; + + const handleSettingsReset = () => { + singleLargePageOperation.resetResults(); + onPreviewFile?.(null); + actions.setMode("single-large-page"); + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = singleLargePageOperation.files.length > 0 || singleLargePageOperation.downloadUrl !== null; + + return createToolFlow({ + files: { + selectedFiles, + isCollapsed: hasFiles || hasResults, + placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"), + }, + steps: [], + executeButton: { + text: t("pdfToSinglePage.submit", "Convert To Single Page"), + isVisible: !hasResults, + loadingText: t("loading"), + onClick: handleConvert, + disabled: !singleLargePageParams.validateParameters() || !hasFiles || !endpointEnabled, + }, + review: { + isVisible: hasResults, + operation: singleLargePageOperation, + title: t("pdfToSinglePage.results.title", "Single Page Results"), + onFileClick: handleThumbnailClick, + }, + }); +}; + +export default SingleLargePage; \ No newline at end of file diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index d33620167..6bccda35c 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -52,13 +52,12 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const hasFiles = selectedFiles.length > 0; const hasResults = splitOperation.downloadUrl !== null; - const filesCollapsed = hasFiles || hasResults; const settingsCollapsed = !hasFiles || hasResults; return createToolFlow({ files: { selectedFiles, - isCollapsed: filesCollapsed, + isCollapsed: hasResults, placeholder: "Select a PDF file in the main view to get started", }, steps: [ diff --git a/frontend/src/tools/UnlockPdfForms.tsx b/frontend/src/tools/UnlockPdfForms.tsx new file mode 100644 index 000000000..c807eb1bb --- /dev/null +++ b/frontend/src/tools/UnlockPdfForms.tsx @@ -0,0 +1,81 @@ +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useEndpointEnabled } from "../hooks/useEndpointConfig"; +import { useFileContext } from "../contexts/FileContext"; +import { useNavigationActions } from "../contexts/NavigationContext"; +import { useToolFileSelection } from "../contexts/file/fileHooks"; + +import { createToolFlow } from "../components/tools/shared/createToolFlow"; + +import { useUnlockPdfFormsParameters } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters"; +import { useUnlockPdfFormsOperation } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation"; +import { BaseToolProps } from "../types/tool"; + +const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { actions } = useNavigationActions(); + const { selectedFiles } = useToolFileSelection(); + + const unlockPdfFormsParams = useUnlockPdfFormsParameters(); + const unlockPdfFormsOperation = useUnlockPdfFormsOperation(); + + // Endpoint validation + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(unlockPdfFormsParams.getEndpointName()); + + useEffect(() => { + unlockPdfFormsOperation.resetResults(); + onPreviewFile?.(null); + }, [unlockPdfFormsParams.parameters]); + + const handleUnlock = async () => { + try { + await unlockPdfFormsOperation.executeOperation(unlockPdfFormsParams.parameters, selectedFiles); + if (unlockPdfFormsOperation.files && onComplete) { + onComplete(unlockPdfFormsOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : t("unlockPDFForms.error.failed", "Unlock PDF forms operation failed")); + } + } + }; + + const handleThumbnailClick = (file: File) => { + onPreviewFile?.(file); + sessionStorage.setItem("previousMode", "unlockPdfForms"); + actions.setMode("viewer"); + }; + + const handleSettingsReset = () => { + unlockPdfFormsOperation.resetResults(); + onPreviewFile?.(null); + actions.setMode("unlockPdfForms"); + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = unlockPdfFormsOperation.files.length > 0 || unlockPdfFormsOperation.downloadUrl !== null; + + return createToolFlow({ + files: { + selectedFiles, + isCollapsed: hasFiles || hasResults, + placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"), + }, + steps: [], + executeButton: { + text: t("unlockPDFForms.submit", "Unlock Forms"), + isVisible: !hasResults, + loadingText: t("loading"), + onClick: handleUnlock, + disabled: !unlockPdfFormsParams.validateParameters() || !hasFiles || !endpointEnabled, + }, + review: { + isVisible: hasResults, + operation: unlockPdfFormsOperation, + title: t("unlockPDFForms.results.title", "Unlocked Forms Results"), + onFileClick: handleThumbnailClick, + }, + }); +}; + +export default UnlockPdfForms; \ No newline at end of file diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 10fa49b67..0425031c5 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -18,8 +18,12 @@ export type ModeType = | 'sanitize' | 'addPassword' | 'changePermissions' - | 'watermark' - | 'removePassword'; + | 'addWatermark' + | 'removePassword' + | 'single-large-page' + | 'repair' + | 'unlockPdfForms' + | 'removeCertificateSign'; // Normalized state types export type FileId = string; diff --git a/frontend/src/types/parameters.ts b/frontend/src/types/parameters.ts new file mode 100644 index 000000000..6f8856a8b --- /dev/null +++ b/frontend/src/types/parameters.ts @@ -0,0 +1,7 @@ +// Base parameter interfaces for reusable patterns + +export interface BaseParameters { + // Base interface that all tool parameters should extend + // Provides a foundation for adding common properties across all tools + // Examples of future additions: userId, sessionId, commonFlags, etc. +} \ No newline at end of file diff --git a/frontend/src/utils/downloadUtils.ts b/frontend/src/utils/downloadUtils.ts new file mode 100644 index 000000000..404e10925 --- /dev/null +++ b/frontend/src/utils/downloadUtils.ts @@ -0,0 +1,152 @@ +import { FileMetadata } from '../types/file'; +import { fileStorage } from '../services/fileStorage'; +import { zipFileService } from '../services/zipFileService'; + +/** + * Downloads a blob as a file using browser download API + * @param blob - The blob to download + * @param filename - The filename for the download + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up the blob URL + URL.revokeObjectURL(url); +} + +/** + * Downloads a single file from IndexedDB storage + * @param file - The file object with storage information + * @throws Error if file cannot be retrieved from storage + */ +export async function downloadFileFromStorage(file: FileMetadata): Promise { + const lookupKey = file.id; + const storedFile = await fileStorage.getFile(lookupKey); + + if (!storedFile) { + throw new Error(`File "${file.name}" not found in storage`); + } + + const blob = new Blob([storedFile.data], { type: storedFile.type }); + downloadBlob(blob, storedFile.name); +} + +/** + * Downloads multiple files as individual downloads + * @param files - Array of files to download + */ +export async function downloadMultipleFiles(files: FileMetadata[]): Promise { + for (const file of files) { + await downloadFileFromStorage(file); + } +} + +/** + * Downloads multiple files as a single ZIP archive + * @param files - Array of files to include in ZIP + * @param zipFilename - Optional custom ZIP filename (defaults to timestamped name) + */ +export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: string): Promise { + if (files.length === 0) { + throw new Error('No files provided for ZIP download'); + } + + // Convert stored files to File objects + const fileObjects: File[] = []; + for (const fileWithUrl of files) { + const lookupKey = fileWithUrl.id; + const storedFile = await fileStorage.getFile(lookupKey); + + if (storedFile) { + const file = new File([storedFile.data], storedFile.name, { + type: storedFile.type, + lastModified: storedFile.lastModified + }); + fileObjects.push(file); + } + } + + if (fileObjects.length === 0) { + throw new Error('No valid files found in storage for ZIP download'); + } + + // Generate default filename if not provided + const finalZipFilename = zipFilename || + `files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`; + + // Create and download ZIP + const { zipFile } = await zipFileService.createZipFromFiles(fileObjects, finalZipFilename); + downloadBlob(zipFile, finalZipFilename); +} + +/** + * Smart download function that handles single or multiple files appropriately + * - Single file: Downloads directly + * - Multiple files: Downloads as ZIP + * @param files - Array of files to download + * @param options - Download options + */ +export async function downloadFiles( + files: FileMetadata[], + options: { + forceZip?: boolean; + zipFilename?: string; + multipleAsIndividual?: boolean; + } = {} +): Promise { + if (files.length === 0) { + throw new Error('No files provided for download'); + } + + if (files.length === 1 && !options.forceZip) { + // Single file download + await downloadFileFromStorage(files[0]); + } else if (options.multipleAsIndividual) { + // Multiple individual downloads + await downloadMultipleFiles(files); + } else { + // ZIP download (default for multiple files) + await downloadFilesAsZip(files, options.zipFilename); + } +} + +/** + * Downloads a File object directly (for files already in memory) + * @param file - The File object to download + * @param filename - Optional custom filename + */ +export function downloadFileObject(file: File, filename?: string): void { + downloadBlob(file, filename || file.name); +} + +/** + * Downloads text content as a file + * @param content - Text content to download + * @param filename - Filename for the download + * @param mimeType - MIME type (defaults to text/plain) + */ +export function downloadTextAsFile( + content: string, + filename: string, + mimeType: string = 'text/plain' +): void { + const blob = new Blob([content], { type: mimeType }); + downloadBlob(blob, filename); +} + +/** + * Downloads JSON data as a file + * @param data - Data to serialize and download + * @param filename - Filename for the download + */ +export function downloadJsonAsFile(data: any, filename: string): void { + const content = JSON.stringify(data, null, 2); + downloadTextAsFile(content, filename, 'application/json'); +} \ No newline at end of file