mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 06:09:23 +00:00
Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/file-handling-improvements
This commit is contained in:
commit
cd253e0c19
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -160,7 +160,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
isOpen={isFilesModalOpen}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
storeFile={storeFileWithId}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
|
@ -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;
|
||||
|
115
frontend/src/components/fileManager/FileActions.tsx
Normal file
115
frontend/src/components/fileManager/FileActions.tsx
Normal 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;
|
@ -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}>
|
||||
@ -51,12 +52,13 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
) : (
|
||||
filteredFiles.map((file, index) => (
|
||||
<FileListItem
|
||||
key={file.id || file.name}
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={selectedFileIds.includes(file.id || file.name)}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
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;
|
||||
|
@ -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<FileListItemProps> = ({
|
||||
file,
|
||||
isSelected,
|
||||
const FileListItem: React.FC<FileListItemProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<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)}
|
||||
@ -62,7 +68,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
@ -74,51 +80,54 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Pin button - always visible for pinned files, fades in/out on hover for unpinned */}
|
||||
{(onPin || onUnpin) && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c={isPinned ? "blue" : "dimmed"}
|
||||
size="md"
|
||||
onClick={(e) => {
|
||||
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 ? (
|
||||
<PushPinIcon style={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<PushPinOutlinedIcon style={{ fontSize: 18 }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
{/* 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)" />}
|
||||
@ -126,4 +135,4 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileListItem;
|
||||
export default FileListItem;
|
||||
|
@ -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 }}>
|
||||
|
@ -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 = ({
|
||||
<ScrollArea.Autosize mah={400}>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{storedFiles.map((file) => {
|
||||
const fileId = file.id || file.name;
|
||||
const fileId = file.id;
|
||||
const isSelected = selectedFileIds.includes(fileId);
|
||||
|
||||
return (
|
||||
|
@ -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;
|
27
frontend/src/components/tools/repair/RepairSettings.tsx
Normal file
27
frontend/src/components/tools/repair/RepairSettings.tsx
Normal 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;
|
@ -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 (
|
||||
<Text size="sm" c="dimmed">
|
||||
{placeholder}
|
||||
{defaultPlaceholder}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Return nothing when files are selected
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileStatusIndicator;
|
||||
export default FileStatusIndicator;
|
||||
|
@ -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;
|
@ -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;
|
62
frontend/src/components/tooltips/usePageSelectionTips.tsx
Normal file
62
frontend/src/components/tooltips/usePageSelectionTips.tsx
Normal 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 3–6"),
|
||||
t("pageSelection.tooltip.ranges.bullet2", "<strong>10-15</strong> → selects pages 10–15"),
|
||||
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, 3–5, 8, plus evens"),
|
||||
t("pageSelection.tooltip.complex.bullet2", "<strong>10-,2n-1</strong> → from page 10 to end + odd pages")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
@ -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<HTMLInputElement | null>;
|
||||
selectedFilesSet: Set<string>;
|
||||
|
||||
// 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<HTMLInputElement>) => 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<StoredFile>;
|
||||
refreshRecentFiles: () => Promise<void>;
|
||||
}
|
||||
|
||||
@ -56,28 +61,34 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
isOpen,
|
||||
onFileRemove,
|
||||
modalHeight,
|
||||
storeFile,
|
||||
refreshRecentFiles,
|
||||
}) => {
|
||||
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));
|
||||
|
||||
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<FileManagerProviderProps> = ({
|
||||
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<FileManagerProviderProps> = ({
|
||||
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<FileManagerProviderProps> = ({
|
||||
setActiveSource('recent');
|
||||
setSelectedFileIds([]);
|
||||
setSearchTerm('');
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@ -168,6 +272,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
selectedFilesSet,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
@ -178,6 +283,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
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<FileManagerProviderProps> = ({
|
||||
handleOpenFiles,
|
||||
handleSearchChange,
|
||||
handleFileInputChange,
|
||||
handleSelectAll,
|
||||
handleDeleteSelected,
|
||||
handleDownloadSelected,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
|
@ -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 {
|
||||
|
@ -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"]
|
||||
},
|
||||
|
||||
|
||||
@ -384,11 +396,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>,
|
||||
|
@ -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.'))
|
||||
});
|
||||
};
|
@ -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',
|
||||
});
|
||||
};
|
23
frontend/src/hooks/tools/repair/useRepairOperation.ts
Normal file
23
frontend/src/hooks/tools/repair/useRepairOperation.ts
Normal 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.'))
|
||||
});
|
||||
};
|
20
frontend/src/hooks/tools/repair/useRepairParameters.ts
Normal file
20
frontend/src/hooks/tools/repair/useRepairParameters.ts
Normal 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
|
||||
});
|
||||
};
|
46
frontend/src/hooks/tools/shared/useBaseParameters.ts
Normal file
46
frontend/src/hooks/tools/shared/useBaseParameters.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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.'))
|
||||
});
|
||||
};
|
@ -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',
|
||||
});
|
||||
};
|
@ -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.'))
|
||||
});
|
||||
};
|
@ -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',
|
||||
});
|
||||
};
|
@ -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;
|
||||
|
@ -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: {
|
||||
|
@ -71,7 +71,7 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasFiles || hasResults,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
|
@ -192,7 +192,7 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasFiles || hasResults,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: getSteps(),
|
||||
executeButton: {
|
||||
|
@ -63,7 +63,7 @@ const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasFiles || hasResults,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
|
@ -61,7 +61,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasFiles && !hasResults,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
|
@ -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: [
|
||||
|
@ -79,7 +79,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasFiles || hasResults,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
|
81
frontend/src/tools/RemoveCertificateSign.tsx
Normal file
81
frontend/src/tools/RemoveCertificateSign.tsx
Normal file
@ -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;
|
@ -65,7 +65,7 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasFiles || hasResults,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
|
81
frontend/src/tools/Repair.tsx
Normal file
81
frontend/src/tools/Repair.tsx
Normal file
@ -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;
|
@ -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: [
|
||||
|
81
frontend/src/tools/SingleLargePage.tsx
Normal file
81
frontend/src/tools/SingleLargePage.tsx
Normal file
@ -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;
|
@ -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: [
|
||||
|
81
frontend/src/tools/UnlockPdfForms.tsx
Normal file
81
frontend/src/tools/UnlockPdfForms.tsx
Normal file
@ -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;
|
@ -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;
|
||||
|
7
frontend/src/types/parameters.ts
Normal file
7
frontend/src/types/parameters.ts
Normal 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.
|
||||
}
|
152
frontend/src/utils/downloadUtils.ts
Normal file
152
frontend/src/utils/downloadUtils.ts
Normal file
@ -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<void> {
|
||||
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<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: FileMetadata[], 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;
|
||||
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<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');
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user