Merge commit 'cd2b82d614b57b6024a6c2ee1f8602ea7f2634b2' into feature/v2/automate

This commit is contained in:
Connor Yoh 2025-08-20 16:56:07 +01:00
commit 441b0d837e
31 changed files with 1502 additions and 110 deletions

View File

@ -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",
@ -1945,7 +2031,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",

View File

@ -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",

View File

@ -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 (
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
{/* Column 1: File Sources */}
<Grid.Col span="content" p="lg" style={{
minWidth: '13.625rem',
width: '13.625rem',
flexShrink: 0,
<Grid.Col span="content" p="lg" style={{
minWidth: '13.625rem',
width: '13.625rem',
flexShrink: 0,
height: '100%',
}}>
<FileSourceButtons />
</Grid.Col>
{/* Column 2: File List */}
<Grid.Col span="auto" style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
<Grid.Col span="auto" style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
flex: '1 1 0px'
}}>
<div style={{
flex: 1,
display: 'flex',
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--bg-file-list)',
border: '1px solid var(--mantine-color-gray-2)',
@ -45,18 +46,26 @@ const DesktopLayout: React.FC = () => {
overflow: 'hidden'
}}>
{activeSource === 'recent' && (
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<SearchInput />
</div>
<>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<SearchInput />
</div>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<FileActions />
</div>
</>
)}
<div style={{ flex: 1, minHeight: 0 }}>
<FileListArea
scrollAreaHeight={`calc(${modalHeight} )`}
scrollAreaStyle={{
scrollAreaStyle={{
height: activeSource === 'recent' && recentFiles.length > 0 ? modalHeight : '100%',
backgroundColor: 'transparent',
border: 'none',
@ -66,12 +75,12 @@ const DesktopLayout: React.FC = () => {
</div>
</div>
</Grid.Col>
{/* Column 3: File Details */}
<Grid.Col p="xl" span="content" style={{
minWidth: '25rem',
width: '25rem',
flexShrink: 0,
<Grid.Col p="xl" span="content" style={{
minWidth: '25rem',
width: '25rem',
flexShrink: 0,
height: '100%',
maxWidth: '18rem'
}}>
@ -79,11 +88,11 @@ const DesktopLayout: React.FC = () => {
<FileDetails />
</div>
</Grid.Col>
{/* Hidden file input for local file selection */}
<HiddenFileInput />
</Grid>
);
};
export default DesktopLayout;
export default DesktopLayout;

View File

@ -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 (
<div
style={{
padding: "0.75rem 1rem",
backgroundColor: "var(--mantine-color-gray-1)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
minHeight: "3rem",
position: "relative",
}}
>
{/* Left: Select All */}
<div>
<Tooltip
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleSelectAll}
disabled={filteredFiles.length === 0}
radius="sm"
>
<SelectAllIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</div>
{/* Center: Selected count */}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
}}
>
{hasSelection && (
<Text size="sm" c="dimmed" fw={500}>
{t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })}
</Text>
)}
</div>
{/* Right: Delete and Download */}
<Group gap="xs">
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleDeleteSelected}
disabled={!hasSelection}
radius="sm"
>
<DeleteIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("fileManager.downloadSelected", "Download Selected")}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleDownloadSelected}
disabled={!hasSelection}
radius="sm"
>
<DownloadIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</Group>
</div>
);
};
export default FileActions;

View File

@ -19,22 +19,23 @@ const FileListArea: React.FC<FileListAreaProps> = ({
activeSource,
recentFiles,
filteredFiles,
selectedFileIds,
selectedFilesSet,
onFileSelect,
onFileRemove,
onFileDoubleClick,
onDownloadSingle,
isFileSupported,
} = useFileManagerContext();
const { t } = useTranslation();
if (activeSource === 'recent') {
return (
<ScrollArea
<ScrollArea
h={scrollAreaHeight}
style={{
style={{
...scrollAreaStyle
}}
type="always"
type="always"
scrollbarSize={8}
>
<Stack gap={0}>
@ -53,10 +54,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
<FileListItem
key={file.id || file.name}
file={file}
isSelected={selectedFileIds.includes(file.id || file.name)}
isSelected={selectedFilesSet.has(file.id || file.name)}
isSupported={isFileSupported(file.name)}
onSelect={() => 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<FileListAreaProps> = ({
);
};
export default FileListArea;
export default FileListArea;

View File

@ -1,6 +1,9 @@
import React, { useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
@ -8,33 +11,44 @@ interface FileListItemProps {
file: FileWithUrl;
isSelected: boolean;
isSupported: boolean;
onSelect: () => void;
onSelect: (shiftKey?: boolean) => void;
onRemove: () => void;
onDownload?: () => void;
onDoubleClick?: () => void;
isLast?: boolean;
}
const FileListItem: React.FC<FileListItemProps> = ({
file,
isSelected,
isSupported,
onSelect,
onRemove,
const FileListItem: React.FC<FileListItemProps> = ({
file,
isSelected,
isSupported,
onSelect,
onRemove,
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 (
<>
<Box
p="sm"
style={{
<Box
p="sm"
style={{
cursor: 'pointer',
backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (isHovered ? 'var(--mantine-color-gray-0)' : 'var(--bg-file-list)'),
backgroundColor: isSelected ? 'var(--mantine-color-gray-1)' : (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
opacity: isSupported ? 1 : 0.5,
transition: 'background-color 0.15s ease'
transition: 'background-color 0.15s ease',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none'
}}
onClick={onSelect}
onClick={(e) => onSelect(e.shiftKey)}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@ -54,26 +68,59 @@ const FileListItem: React.FC<FileListItemProps> = ({
}}
/>
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>{file.name}</Text>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text>
</Box>
{/* Delete button - fades in/out on hover */}
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => { 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 */}
<Menu
position="bottom-end"
withinPortal
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
<Menu.Target>
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => e.stopPropagation()}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: shouldShowHovered ? 'auto' : 'none'
}}
>
<MoreVertIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onDownload && (
<Menu.Item
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
>
{t('fileManager.download', 'Download')}
</Menu.Item>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t('fileManager.delete', 'Delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Box>
{ <Divider color="var(--mantine-color-gray-3)" />}
@ -81,4 +128,4 @@ const FileListItem: React.FC<FileListItemProps> = ({
);
};
export default FileListItem;
export default FileListItem;

View File

@ -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' && (
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<SearchInput />
</Box>
<>
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<SearchInput />
</Box>
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<FileActions />
</Box>
</>
)}
<Box style={{ flex: 1, minHeight: 0 }}>

View File

@ -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: <K extends keyof RemoveCertificateSignParameters>(parameter: K, value: RemoveCertificateSignParameters[K]) => void;
disabled?: boolean;
}
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = ({
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const { t } = useTranslation();
return (
<div className="remove-certificate-sign-settings">
<p className="text-muted">
{t('removeCertSign.description', 'This tool will remove digital certificate signatures from your PDF document.')}
</p>
</div>
);
};
export default RemoveCertificateSignSettings;

View File

@ -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: <K extends keyof RepairParameters>(parameter: K, value: RepairParameters[K]) => void;
disabled?: boolean;
}
const RepairSettings: React.FC<RepairSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
<div className="repair-settings">
<p className="text-muted">
{t('repair.description', 'This tool will attempt to repair corrupted or damaged PDF files. No additional settings are required.')}
</p>
</div>
);
};
export default RepairSettings;

View File

@ -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: <K extends keyof SingleLargePageParameters>(parameter: K, value: SingleLargePageParameters[K]) => void;
disabled?: boolean;
}
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
<div className="single-large-page-settings">
<p className="text-muted">
{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.')}
</p>
</div>
);
};
export default SingleLargePageSettings;

View File

@ -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: <K extends keyof UnlockPdfFormsParameters>(parameter: K, value: UnlockPdfFormsParameters[K]) => void;
disabled?: boolean;
}
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const { t } = useTranslation();
return (
<div className="unlock-pdf-forms-settings">
<p className="text-muted">
{t('unlockPDFForms.description', 'This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.')}
</p>
</div>
);
};
export default UnlockPdfFormsSettings;

View File

@ -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", "<strong>1,3,5</strong> → selects pages 1, 3, 5"),
t("pageSelection.tooltip.individual.bullet2", "<strong>2,7,12</strong> → 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", "<strong>3-6</strong> → selects pages 36"),
t("pageSelection.tooltip.ranges.bullet2", "<strong>10-15</strong> → selects pages 1015"),
t("pageSelection.tooltip.ranges.bullet3", "<strong>5-</strong> → 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", "<strong>2n-1</strong> → all odd pages (1, 3, 5…)"),
t("pageSelection.tooltip.mathematical.bullet1", "<strong>2n</strong> → all even pages (2, 4, 6…)"),
t("pageSelection.tooltip.mathematical.bullet3", "<strong>3n</strong> → every 3rd page (3, 6, 9…)"),
t("pageSelection.tooltip.mathematical.bullet4", "<strong>4n-1</strong> → pages 3, 7, 11, 15…")
]
},
{
title: t("pageSelection.tooltip.special.title", "Special Keywords"),
bullets: [
t("pageSelection.tooltip.special.bullet1", "<strong>all</strong> → 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", "<strong>1,3-5,8,2n</strong> → pages 1, 35, 8, plus evens"),
t("pageSelection.tooltip.complex.bullet2", "<strong>10-,2n-1</strong> → from page 10 to end + odd pages")
]
}
]
};
};

View File

@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
import { FileWithUrl } 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: FileWithUrl[];
filteredFiles: FileWithUrl[];
fileInputRef: React.RefObject<HTMLInputElement | null>;
selectedFilesSet: Set<string>;
// Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onLocalFileClick: () => void;
onFileSelect: (file: FileWithUrl) => void;
onFileSelect: (file: FileWithUrl, index: number, shiftKey?: boolean) => void;
onFileRemove: (index: number) => void;
onFileDoubleClick: (file: FileWithUrl) => void;
onOpenFiles: () => void;
onSearchChange: (value: string) => void;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectAll: () => void;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
onDownloadSingle: (file: FileWithUrl) => void;
// External props
recentFiles: FileWithUrl[];
@ -60,22 +66,29 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(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 || file.name));
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);
}
}, []);
@ -83,19 +96,46 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileInputRef.current?.click();
}, []);
const handleFileSelect = useCallback((file: FileWithUrl) => {
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: FileWithUrl, currentIndex: number, shiftKey?: boolean) => {
const fileId = file.id || file.name;
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 || filteredFiles[i]?.name;
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];
@ -152,6 +192,72 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
event.target.value = '';
}, [storeFile, onFilesSelected, 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 || file.name));
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 || file.name)
);
// Delete files from storage
for (const file of filesToDelete) {
const lookupKey = file.id || file.name;
await fileStorage.deleteFile(lookupKey);
}
// 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 || file.name)
);
// 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: FileWithUrl) => {
try {
await downloadFiles([file]);
} catch (error) {
console.error('Failed to download file:', error);
}
}, []);
// Cleanup blob URLs when component unmounts
useEffect(() => {
return () => {
@ -169,6 +275,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setActiveSource('recent');
setSelectedFileIds([]);
setSearchTerm('');
setLastClickedIndex(null);
}
}, [isOpen]);
@ -180,6 +287,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedFiles,
filteredFiles,
fileInputRef,
selectedFilesSet,
// Handlers
onSourceChange: handleSourceChange,
@ -190,6 +298,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onOpenFiles: handleOpenFiles,
onSearchChange: handleSearchChange,
onFileInputChange: handleFileInputChange,
onSelectAll: handleSelectAll,
onDeleteSelected: handleDeleteSelected,
onDownloadSelected: handleDownloadSelected,
onDownloadSingle: handleDownloadSingle,
// External props
recentFiles,

View File

@ -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: <span className="material-symbols-rounded">preview_off</span>,
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: <span className="material-symbols-rounded">license</span>,
@ -230,11 +238,13 @@ export function useFlatToolRegistry(): ToolRegistry {
"single-large-page": {
icon: <span className="material-symbols-rounded">looks_one</span>,
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: <span className="material-symbols-rounded">attachment</span>,
@ -322,11 +332,13 @@ export function useFlatToolRegistry(): ToolRegistry {
"remove-certificate-sign": {
icon: <span className="material-symbols-rounded">remove_moderator</span>,
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"]
},
@ -386,11 +398,13 @@ export function useFlatToolRegistry(): ToolRegistry {
"repair": {
icon: <span className="material-symbols-rounded">build</span>,
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: <span className="material-symbols-rounded">scanner</span>,

View File

@ -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<RemoveCertificateSignParameters>({
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.'))
});
};

View File

@ -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<RemoveCertificateSignParameters>;
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-certificate-sign',
});
};

View File

@ -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<RepairParameters>({
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.'))
});
};

View File

@ -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<RepairParameters>;
export const useRepairParameters = (): RepairParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'repair',
// validateFn: optional custom validation if needed in future
});
};

View File

@ -0,0 +1,46 @@
import { useState, useCallback } from 'react';
export interface BaseParametersHook<T> {
parameters: T;
updateParameter: <K extends keyof T>(parameter: K, value: T[K]) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
}
export interface BaseParametersConfig<T> {
defaultParameters: T;
endpointName: string;
validateFn?: (params: T) => boolean;
}
export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParametersHook<T> {
const [parameters, setParameters] = useState<T>(config.defaultParameters);
const updateParameter = useCallback(<K extends keyof T>(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,
};
}

View File

@ -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<SingleLargePageParameters>({
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.'))
});
};

View File

@ -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<SingleLargePageParameters>;
export const useSingleLargePageParameters = (): SingleLargePageParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'pdf-to-single-page',
});
};

View File

@ -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<UnlockPdfFormsParameters>({
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.'))
});
};

View File

@ -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<UnlockPdfFormsParameters>;
export const useUnlockPdfFormsParameters = (): UnlockPdfFormsParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'unlock-pdf-forms',
});
};

View File

@ -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: {

View File

@ -0,0 +1,80 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
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 { setCurrentMode } = useFileContext();
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");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
removeCertificateSignOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode("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;

View File

@ -0,0 +1,80 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
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 { setCurrentMode } = useFileContext();
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");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
repairOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode("repair");
};
const hasFiles = selectedFiles.length > 0;
const hasResults = repairOperation.files.length > 0 || repairOperation.downloadUrl !== null;
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasFiles || 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;

View File

@ -0,0 +1,80 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
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 { setCurrentMode } = useFileContext();
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");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
singleLargePageOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode("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;

View File

@ -0,0 +1,80 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
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 { setCurrentMode } = useFileContext();
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");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
unlockPdfFormsOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode("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;

View File

@ -18,7 +18,11 @@ export type ModeType =
| 'addPassword'
| 'changePermissions'
| 'watermark'
| 'removePassword';
| 'removePassword'
| 'single-large-page'
| 'repair'
| 'unlockPdfForms'
| 'removeCertificateSign';
export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';

View File

@ -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.
}

View File

@ -0,0 +1,152 @@
import { FileWithUrl } 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: FileWithUrl): Promise<void> {
const lookupKey = file.id || file.name;
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: FileWithUrl[]): Promise<void> {
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: FileWithUrl[], zipFilename?: string): Promise<void> {
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 || fileWithUrl.name;
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: FileWithUrl[],
options: {
forceZip?: boolean;
zipFilename?: string;
multipleAsIndividual?: boolean;
} = {}
): Promise<void> {
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');
}