Split refactor

This commit is contained in:
Reece 2025-07-16 00:12:54 +01:00
parent 37484f64f9
commit c100c9f10d
11 changed files with 1030 additions and 521 deletions

View File

@ -0,0 +1,35 @@
import { Notification } from '@mantine/core';
import { useTranslation } from 'react-i18next';
export interface ErrorNotificationProps {
error: string | null;
onClose: () => void;
title?: string;
color?: string;
mb?: string;
}
const ErrorNotification = ({
error,
onClose,
title,
color = 'red',
mb = 'md'
}: ErrorNotificationProps) => {
const { t } = useTranslation();
if (!error) return null;
return (
<Notification
color={color}
title={title || t("error._value", "Error")}
onClose={onClose}
mb={mb}
>
{error}
</Notification>
);
}
export default ErrorNotification;

View File

@ -0,0 +1,40 @@
import React from 'react';
import { Text } from '@mantine/core';
export interface FileStatusIndicatorProps {
selectedFiles?: File[];
isCompleted?: boolean;
placeholder?: string;
showFileName?: boolean;
}
const FileStatusIndicator = ({
selectedFiles = [],
isCompleted = false,
placeholder = "Select a PDF file in the main view to get started",
showFileName = true
}: FileStatusIndicatorProps) => {
if (selectedFiles.length === 0) {
return (
<Text size="sm" c="dimmed">
{placeholder}
</Text>
);
}
if (isCompleted) {
return (
<Text size="sm" c="green">
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
</Text>
);
}
return (
<Text size="sm" c="blue">
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
</Text>
);
}
export default FileStatusIndicator;

View File

@ -0,0 +1,51 @@
import React from 'react';
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
export interface OperationButtonProps {
onClick?: () => void;
isLoading?: boolean;
disabled?: boolean;
loadingText?: string;
submitText?: string;
variant?: 'filled' | 'outline' | 'subtle';
color?: string;
fullWidth?: boolean;
mt?: string;
type?: 'button' | 'submit' | 'reset';
}
const OperationButton = ({
onClick,
isLoading = false,
disabled = false,
loadingText,
submitText,
variant = 'filled',
color = 'blue',
fullWidth = true,
mt = 'md',
type = 'button'
}: OperationButtonProps) => {
const { t } = useTranslation();
return (
<Button
type={type}
onClick={onClick}
fullWidth={fullWidth}
mt={mt}
loading={isLoading}
disabled={disabled}
variant={variant}
color={color}
>
{isLoading
? (loadingText || t("loading", "Loading..."))
: (submitText || t("submit", "Submit"))
}
</Button>
);
}
export default OperationButton;

View File

@ -0,0 +1,112 @@
import { Grid, Paper, Box, Image, Text, Loader, Stack, Center } from '@mantine/core';
export interface ResultFile {
file: File;
thumbnail?: string;
}
export interface ResultsPreviewProps {
files: ResultFile[];
isGeneratingThumbnails?: boolean;
onFileClick?: (file: File) => void;
title?: string;
emptyMessage?: string;
loadingMessage?: string;
}
const ResultsPreview = ({
files,
isGeneratingThumbnails = false,
onFileClick,
title,
emptyMessage = "No files to preview",
loadingMessage = "Generating previews..."
}: ResultsPreviewProps) => {
const formatSize = (size: number) => {
if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
if (size > 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
};
if (files.length === 0 && !isGeneratingThumbnails) {
return (
<Text size="sm" c="dimmed">
{emptyMessage}
</Text>
);
}
return (
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }}>
{title && (
<Text fw={500} size="md" mb="sm">
{title} ({files.length} files)
</Text>
)}
{isGeneratingThumbnails ? (
<Center p="lg">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">{loadingMessage}</Text>
</Stack>
</Center>
) : (
<Grid>
{files.map((result, index) => (
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
<Paper
p="xs"
withBorder
onClick={() => onFileClick?.(result.file)}
style={{
textAlign: 'center',
height: '10rem',
width:'5rem',
display: 'flex',
flexDirection: 'column',
cursor: onFileClick ? 'pointer' : 'default',
transition: 'all 0.2s ease'
}}
>
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.thumbnail ? (
<Image
src={result.thumbnail}
alt={`Preview of ${result.file.name}`}
style={{
maxWidth: '100%',
maxHeight: '9rem',
objectFit: 'contain'
}}
/>
) : (
<Text size="xs" c="dimmed">No preview</Text>
)}
</Box>
<Text
size="xs"
c="dimmed"
mt="xs"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={result.file.name}
>
{result.file.name}
</Text>
<Text size="xs" c="dimmed">
{formatSize(result.file.size)}
</Text>
</Paper>
</Grid.Col>
))}
</Grid>
)}
</Box>
);
}
export default ResultsPreview;

View File

@ -0,0 +1,120 @@
import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Paper, Text, Stack, Box } from '@mantine/core';
interface ToolStepContextType {
visibleStepCount: number;
getStepNumber: () => number;
}
const ToolStepContext = createContext<ToolStepContextType | null>(null);
export interface ToolStepProps {
title: string;
isVisible?: boolean;
isCollapsed?: boolean;
isCompleted?: boolean;
onCollapsedClick?: () => void;
children?: React.ReactNode;
completedMessage?: string;
helpText?: string;
showNumber?: boolean;
}
const ToolStep = ({
title,
isVisible = true,
isCollapsed = false,
isCompleted = false,
onCollapsedClick,
children,
completedMessage,
helpText,
showNumber
}: ToolStepProps) => {
if (!isVisible) return null;
// Auto-detect if we should show numbers based on sibling count
const shouldShowNumber = useMemo(() => {
if (showNumber !== undefined) return showNumber;
const parent = useContext(ToolStepContext);
return parent ? parent.visibleStepCount >= 3 : false;
}, [showNumber]);
const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1;
return (
<Paper
p="md"
withBorder
style={{
cursor: isCollapsed && onCollapsedClick ? 'pointer' : 'default',
opacity: isCollapsed ? 0.8 : 1,
transition: 'opacity 0.2s ease'
}}
onClick={isCollapsed && onCollapsedClick ? onCollapsedClick : undefined}
>
<Text fw={500} size="lg" mb="sm">
{shouldShowNumber ? `${stepNumber}. ` : ''}{title}
</Text>
{isCollapsed ? (
<Box>
{isCompleted && completedMessage && (
<Text size="sm" c="green">
{completedMessage}
{onCollapsedClick && (
<Text span c="dimmed" size="xs" ml="sm">
(click to change)
</Text>
)}
</Text>
)}
</Box>
) : (
<Stack gap="md">
{helpText && (
<Text size="sm" c="dimmed">
{helpText}
</Text>
)}
{children}
</Stack>
)}
</Paper>
);
}
export interface ToolStepContainerProps {
children: React.ReactNode;
}
export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
const stepCounterRef = useRef(0);
// Count visible ToolStep children
const visibleStepCount = useMemo(() => {
let count = 0;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === ToolStep) {
const isVisible = child.props.isVisible !== false;
if (isVisible) count++;
}
});
return count;
}, [children]);
const contextValue = useMemo(() => ({
visibleStepCount,
getStepNumber: () => ++stepCounterRef.current
}), [visibleStepCount]);
stepCounterRef.current = 0;
return (
<ToolStepContext.Provider value={contextValue}>
{children}
</ToolStepContext.Provider>
);
}
export default ToolStep;

View File

@ -0,0 +1,148 @@
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
export interface SplitParameters {
pages: string;
hDiv: string;
vDiv: string;
merge: boolean;
splitType: SplitType | '';
splitValue: string;
bookmarkLevel: string;
includeMetadata: boolean;
allowDuplicates: boolean;
}
export interface SplitSettingsProps {
mode: SplitMode | '';
onModeChange: (mode: SplitMode | '') => void;
parameters: SplitParameters;
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
disabled?: boolean;
}
const SplitSettings = ({
mode,
onModeChange,
parameters,
onParameterChange,
disabled = false
}: SplitSettingsProps) => {
const { t } = useTranslation();
const renderByPagesForm = () => (
<TextInput
label={t("split.splitPages", "Pages")}
placeholder={t("pageSelectionPrompt", "e.g. 1,3,5-10")}
value={parameters.pages}
onChange={(e) => onParameterChange('pages', e.target.value)}
disabled={disabled}
/>
);
const renderBySectionsForm = () => (
<Stack gap="sm">
<TextInput
label={t("split-by-sections.horizontal.label", "Horizontal Divisions")}
type="number"
min="0"
max="300"
value={parameters.hDiv}
onChange={(e) => onParameterChange('hDiv', e.target.value)}
placeholder={t("split-by-sections.horizontal.placeholder", "Enter number of horizontal divisions")}
disabled={disabled}
/>
<TextInput
label={t("split-by-sections.vertical.label", "Vertical Divisions")}
type="number"
min="0"
max="300"
value={parameters.vDiv}
onChange={(e) => onParameterChange('vDiv', e.target.value)}
placeholder={t("split-by-sections.vertical.placeholder", "Enter number of vertical divisions")}
disabled={disabled}
/>
<Checkbox
label={t("split-by-sections.merge", "Merge sections into one PDF")}
checked={parameters.merge}
onChange={(e) => onParameterChange('merge', e.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
const renderBySizeOrCountForm = () => (
<Stack gap="sm">
<Select
label={t("split-by-size-or-count.type.label", "Split Type")}
value={parameters.splitType}
onChange={(v) => v && onParameterChange('splitType', v)}
disabled={disabled}
data={[
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },
{ value: SPLIT_TYPES.PAGES, label: t("split-by-size-or-count.type.pageCount", "By Page Count") },
{ value: SPLIT_TYPES.DOCS, label: t("split-by-size-or-count.type.docCount", "By Document Count") },
]}
/>
<TextInput
label={t("split-by-size-or-count.value.label", "Split Value")}
placeholder={t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages")}
value={parameters.splitValue}
onChange={(e) => onParameterChange('splitValue', e.target.value)}
disabled={disabled}
/>
</Stack>
);
const renderByChaptersForm = () => (
<Stack gap="sm">
<TextInput
label={t("splitByChapters.bookmarkLevel", "Bookmark Level")}
type="number"
value={parameters.bookmarkLevel}
onChange={(e) => onParameterChange('bookmarkLevel', e.target.value)}
disabled={disabled}
/>
<Checkbox
label={t("splitByChapters.includeMetadata", "Include Metadata")}
checked={parameters.includeMetadata}
onChange={(e) => onParameterChange('includeMetadata', e.currentTarget.checked)}
disabled={disabled}
/>
<Checkbox
label={t("splitByChapters.allowDuplicates", "Allow Duplicate Bookmarks")}
checked={parameters.allowDuplicates}
onChange={(e) => onParameterChange('allowDuplicates', e.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
return (
<Stack gap="md">
{/* Mode Selector */}
<Select
label="Choose split method"
placeholder="Select how to split the PDF"
value={mode}
onChange={(v) => v && onModeChange(v)}
disabled={disabled}
data={[
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
{ value: SPLIT_MODES.BY_SECTIONS, label: t("split-by-sections.title", "Split by Grid Sections") },
{ value: SPLIT_MODES.BY_SIZE_OR_COUNT, label: t("split-by-size-or-count.title", "Split by Size or Count") },
{ value: SPLIT_MODES.BY_CHAPTERS, label: t("splitByChapters.title", "Split by Chapters") },
]}
/>
{/* Parameter Form */}
{mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
{mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
{mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
{mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
</Stack>
);
}
export default SplitSettings;

View File

@ -0,0 +1,22 @@
export const SPLIT_MODES = {
BY_PAGES: 'byPages',
BY_SECTIONS: 'bySections',
BY_SIZE_OR_COUNT: 'bySizeOrCount',
BY_CHAPTERS: 'byChapters'
} as const;
export const SPLIT_TYPES = {
SIZE: 'size',
PAGES: 'pages',
DOCS: 'docs'
} as const;
export const ENDPOINTS = {
[SPLIT_MODES.BY_PAGES]: 'split-pages',
[SPLIT_MODES.BY_SECTIONS]: 'split-pdf-by-sections',
[SPLIT_MODES.BY_SIZE_OR_COUNT]: 'split-by-size-or-count',
[SPLIT_MODES.BY_CHAPTERS]: 'split-pdf-by-chapters'
} as const;
export type SplitMode = typeof SPLIT_MODES[keyof typeof SPLIT_MODES];
export type SplitType = typeof SPLIT_TYPES[keyof typeof SPLIT_TYPES];

View File

@ -0,0 +1,67 @@
import { useState, useCallback } from 'react';
export interface OperationResult {
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
}
export interface OperationResultsHook {
results: OperationResult;
downloadUrl: string | null;
status: string;
errorMessage: string | null;
isLoading: boolean;
setResults: (results: OperationResult) => void;
setDownloadUrl: (url: string | null) => void;
setStatus: (status: string) => void;
setErrorMessage: (error: string | null) => void;
setIsLoading: (loading: boolean) => void;
resetResults: () => void;
clearError: () => void;
}
const initialResults: OperationResult = {
files: [],
thumbnails: [],
isGeneratingThumbnails: false,
};
export const useOperationResults = (): OperationResultsHook => {
const [results, setResults] = useState<OperationResult>(initialResults);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const resetResults = useCallback(() => {
setResults(initialResults);
setDownloadUrl(null);
setStatus('');
setErrorMessage(null);
setIsLoading(false);
}, []);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
return {
results,
downloadUrl,
status,
errorMessage,
isLoading,
setResults,
setDownloadUrl,
setStatus,
setErrorMessage,
setIsLoading,
resetResults,
clearError,
};
};

View File

@ -0,0 +1,242 @@
import { useCallback, useState } from 'react';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import { useFileContext } from '../../../contexts/FileContext';
import { FileOperation } from '../../../types/fileContext';
import { zipFileService } from '../../../services/zipFileService';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
import { SPLIT_MODES, ENDPOINTS, type SplitMode } from '../../../constants/splitConstants';
export interface SplitOperationHook {
executeOperation: (
mode: SplitMode | '',
parameters: SplitParameters,
selectedFiles: File[]
) => Promise<void>;
// Flattened result properties for cleaner access
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
status: string;
errorMessage: string | null;
isLoading: boolean;
// Result management functions
resetResults: () => void;
clearError: () => void;
}
export const useSplitOperation = (): SplitOperationHook => {
const { t } = useTranslation();
const {
recordOperation,
markOperationApplied,
markOperationFailed,
addFiles
} = useFileContext();
// Internal state management (replacing useOperationResults)
const [files, setFiles] = useState<File[]>([]);
const [thumbnails, setThumbnails] = useState<string[]>([]);
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const buildFormData = useCallback((
mode: SplitMode | '',
parameters: SplitParameters,
selectedFiles: File[]
) => {
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append("fileInput", file);
});
if (!mode) {
throw new Error('Split mode is required');
}
let endpoint = "";
switch (mode) {
case SPLIT_MODES.BY_PAGES:
formData.append("pageNumbers", parameters.pages);
endpoint = "/api/v1/general/split-pages";
break;
case SPLIT_MODES.BY_SECTIONS:
formData.append("horizontalDivisions", parameters.hDiv);
formData.append("verticalDivisions", parameters.vDiv);
formData.append("merge", parameters.merge.toString());
endpoint = "/api/v1/general/split-pdf-by-sections";
break;
case SPLIT_MODES.BY_SIZE_OR_COUNT:
formData.append(
"splitType",
parameters.splitType === "size" ? "0" : parameters.splitType === "pages" ? "1" : "2"
);
formData.append("splitValue", parameters.splitValue);
endpoint = "/api/v1/general/split-by-size-or-count";
break;
case SPLIT_MODES.BY_CHAPTERS:
formData.append("bookmarkLevel", parameters.bookmarkLevel);
formData.append("includeMetadata", parameters.includeMetadata.toString());
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
endpoint = "/api/v1/general/split-pdf-by-chapters";
break;
default:
throw new Error(`Unknown split mode: ${mode}`);
}
return { formData, endpoint };
}, []);
const createOperation = useCallback((
mode: SplitMode | '',
parameters: SplitParameters,
selectedFiles: File[]
): { operation: FileOperation; operationId: string; fileId: string } => {
const operationId = `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const fileId = selectedFiles[0].name;
const operation: FileOperation = {
id: operationId,
type: 'split',
timestamp: Date.now(),
fileIds: selectedFiles.map(f => f.name),
status: 'pending',
metadata: {
originalFileName: selectedFiles[0].name,
parameters: {
mode,
pages: mode === SPLIT_MODES.BY_PAGES ? parameters.pages : undefined,
hDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.hDiv : undefined,
vDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.vDiv : undefined,
merge: mode === SPLIT_MODES.BY_SECTIONS ? parameters.merge : undefined,
splitType: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitType : undefined,
splitValue: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitValue : undefined,
bookmarkLevel: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.bookmarkLevel : undefined,
includeMetadata: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.includeMetadata : undefined,
allowDuplicates: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.allowDuplicates : undefined,
},
fileSize: selectedFiles[0].size
}
};
return { operation, operationId, fileId };
}, []);
const processResults = useCallback(async (blob: Blob) => {
try {
const zipFile = new File([blob], "split_result.zip", { type: "application/zip" });
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
// Set local state for preview
setFiles(extractionResult.extractedFiles);
setThumbnails([]);
setIsGeneratingThumbnails(true);
// Add extracted files to FileContext for future use
await addFiles(extractionResult.extractedFiles);
const thumbnails = await Promise.all(
extractionResult.extractedFiles.map(async (file) => {
try {
return await generateThumbnailForFile(file);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
return '';
}
})
);
setThumbnails(thumbnails);
setIsGeneratingThumbnails(false);
}
} catch (extractError) {
console.warn('Failed to extract files for preview:', extractError);
}
}, [addFiles]);
const executeOperation = useCallback(async (
mode: SplitMode | '',
parameters: SplitParameters,
selectedFiles: File[]
) => {
if (selectedFiles.length === 0) {
setStatus(t("noFileSelected"));
return;
}
const { operation, operationId, fileId } = createOperation(mode, parameters, selectedFiles);
const { formData, endpoint } = buildFormData(mode, parameters, selectedFiles);
recordOperation(fileId, operation);
setStatus(t("loading"));
setIsLoading(true);
setErrorMessage(null);
try {
const response = await axios.post(endpoint, formData, { responseType: "blob" });
const blob = new Blob([response.data], { type: "application/zip" });
const url = window.URL.createObjectURL(blob);
setDownloadUrl(url);
setStatus(t("downloadComplete"));
await processResults(blob);
markOperationApplied(fileId, operationId);
} catch (error: any) {
console.error(error);
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
if (error.response?.data && typeof error.response.data === 'string') {
errorMsg = error.response.data;
} else if (error.message) {
errorMsg = error.message;
}
setErrorMessage(errorMsg);
setStatus(t("error._value", "Split failed."));
markOperationFailed(fileId, operationId, errorMsg);
} finally {
setIsLoading(false);
}
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]);
const resetResults = useCallback(() => {
setFiles([]);
setThumbnails([]);
setIsGeneratingThumbnails(false);
setDownloadUrl(null);
setStatus('');
setErrorMessage(null);
setIsLoading(false);
}, []);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
return {
executeOperation,
// Flattened result properties for cleaner access
files,
thumbnails,
isGeneratingThumbnails,
downloadUrl,
status,
errorMessage,
isLoading,
// Result management functions
resetResults,
clearError,
};
};

View File

@ -0,0 +1,71 @@
import { useState } from 'react';
import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode, type SplitType } from '../../../constants/splitConstants';
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
export interface SplitParametersHook {
mode: SplitMode | '';
parameters: SplitParameters;
setMode: (mode: SplitMode | '') => void;
updateParameter: (parameter: keyof SplitParameters, value: string | boolean) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
}
const initialParameters: SplitParameters = {
pages: '',
hDiv: '2',
vDiv: '2',
merge: false,
splitType: SPLIT_TYPES.SIZE,
splitValue: '',
bookmarkLevel: '1',
includeMetadata: false,
allowDuplicates: false,
};
export const useSplitParameters = (): SplitParametersHook => {
const [mode, setMode] = useState<SplitMode | ''>('');
const [parameters, setParameters] = useState<SplitParameters>(initialParameters);
const updateParameter = (parameter: keyof SplitParameters, value: string | boolean) => {
setParameters(prev => ({ ...prev, [parameter]: value }));
};
const resetParameters = () => {
setParameters(initialParameters);
setMode('');
};
const validateParameters = () => {
if (!mode) return false;
switch (mode) {
case SPLIT_MODES.BY_PAGES:
return parameters.pages.trim() !== "";
case SPLIT_MODES.BY_SECTIONS:
return parameters.hDiv !== "" && parameters.vDiv !== "";
case SPLIT_MODES.BY_SIZE_OR_COUNT:
return parameters.splitValue.trim() !== "";
case SPLIT_MODES.BY_CHAPTERS:
return parameters.bookmarkLevel !== "";
default:
return false;
}
};
const getEndpointName = () => {
if (!mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES];
return ENDPOINTS[mode as SplitMode];
};
return {
mode,
parameters,
setMode,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
};
};

View File

@ -1,562 +1,163 @@
import React, { useState } from "react";
import axios from "axios";
import {
Button,
Select,
TextInput,
Checkbox,
Notification,
Stack,
Paper,
Text,
Alert,
Box,
Group,
Grid,
Image,
Loader,
Center,
} from "@mantine/core";
import React, { useEffect, useMemo } from "react";
import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useFileContext } from "../contexts/FileContext";
import { FileOperation } from "../types/fileContext";
import { zipFileService } from "../services/zipFileService";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
import FileEditor from "../components/fileEditor/FileEditor";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
export interface SplitPdfPanelProps {
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview";
import SplitSettings from "../components/tools/split/SplitSettings";
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
interface SplitProps {
selectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
}
const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
selectedFiles = [],
onPreviewFile,
}) => {
const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => {
const { t } = useTranslation();
const fileContext = useFileContext();
const { activeFiles, selectedFileIds, updateProcessedFile, recordOperation, markOperationApplied, markOperationFailed, setCurrentMode } = fileContext;
const { setCurrentMode } = useFileContext();
// Internal split parameter state
const [mode, setMode] = useState('');
const [pages, setPages] = useState('');
const [hDiv, setHDiv] = useState('2');
const [vDiv, setVDiv] = useState('2');
const [merge, setMerge] = useState(false);
const [splitType, setSplitType] = useState('size');
const [splitValue, setSplitValue] = useState('');
const [bookmarkLevel, setBookmarkLevel] = useState('1');
const [includeMetadata, setIncludeMetadata] = useState(false);
const [allowDuplicates, setAllowDuplicates] = useState(false);
const splitParams = useSplitParameters();
const splitOperation = useSplitOperation();
// Helper to get endpoint name from split mode
const getEndpointName = (mode: string) => {
switch (mode) {
case "byPages":
return "split-pages";
case "bySections":
return "split-pdf-by-sections";
case "bySizeOrCount":
return "split-by-size-or-count";
case "byChapters":
return "split-pdf-by-chapters";
default:
return "split-pages"; // Default fallback
}
};
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
splitParams.getEndpointName()
);
const [status, setStatus] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [splitResults, setSplitResults] = useState<{
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
}>({
files: [],
thumbnails: [],
isGeneratingThumbnails: false
});
// Clear download when parameters or files change
React.useEffect(() => {
if (downloadUrl) {
setDownloadUrl(null);
setStatus("");
}
// Clear split results
setSplitResults({
files: [],
thumbnails: [],
isGeneratingThumbnails: false
});
useEffect(() => {
splitOperation.resetResults();
onPreviewFile?.(null);
}, [mode, pages, hDiv, vDiv, merge, splitType, splitValue, bookmarkLevel, includeMetadata, allowDuplicates, selectedFiles]);
}, [splitParams.mode, splitParams.parameters, selectedFiles]);
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(getEndpointName(mode));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedFiles.length === 0) {
setStatus(t("noFileSelected"));
return;
}
const formData = new FormData();
// Use selected files from context
selectedFiles.forEach(file => {
formData.append("fileInput", file);
});
let endpoint = "";
switch (mode) {
case "byPages":
formData.append("pageNumbers", pages);
endpoint = "/api/v1/general/split-pages";
break;
case "bySections":
formData.append("horizontalDivisions", hDiv);
formData.append("verticalDivisions", vDiv);
formData.append("merge", merge.toString());
endpoint = "/api/v1/general/split-pdf-by-sections";
break;
case "bySizeOrCount":
formData.append(
"splitType",
splitType === "size" ? "0" : splitType === "pages" ? "1" : "2"
);
formData.append("splitValue", splitValue);
endpoint = "/api/v1/general/split-by-size-or-count";
break;
case "byChapters":
formData.append("bookmarkLevel", bookmarkLevel);
formData.append("includeMetadata", includeMetadata.toString());
formData.append("allowDuplicates", allowDuplicates.toString());
endpoint = "/api/v1/general/split-pdf-by-chapters";
break;
default:
return;
}
// Record the operation before starting
const operationId = `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const fileId = selectedFiles[0].name; // Use first file's name as primary ID
const operation: FileOperation = {
id: operationId,
type: 'split',
timestamp: Date.now(),
fileIds: selectedFiles.map(f => f.name),
status: 'pending',
metadata: {
originalFileName: selectedFiles[0].name,
parameters: {
mode,
pages: mode === 'byPages' ? pages : undefined,
hDiv: mode === 'bySections' ? hDiv : undefined,
vDiv: mode === 'bySections' ? vDiv : undefined,
merge: mode === 'bySections' ? merge : undefined,
splitType: mode === 'bySizeOrCount' ? splitType : undefined,
splitValue: mode === 'bySizeOrCount' ? splitValue : undefined,
bookmarkLevel: mode === 'byChapters' ? bookmarkLevel : undefined,
includeMetadata: mode === 'byChapters' ? includeMetadata : undefined,
allowDuplicates: mode === 'byChapters' ? allowDuplicates : undefined,
},
fileSize: selectedFiles[0].size
}
};
recordOperation(fileId, operation);
setStatus(t("loading"));
setIsLoading(true);
setErrorMessage(null);
try {
const response = await axios.post(endpoint, formData, { responseType: "blob" });
const blob = new Blob([response.data], { type: "application/zip" });
const url = window.URL.createObjectURL(blob);
setDownloadUrl(url);
setStatus(t("downloadComplete"));
// Extract files from ZIP response for preview
try {
// Create a File object from the blob to use with zipFileService
const zipFile = new File([blob], "split_result.zip", { type: "application/zip" });
// Extract PDF files for preview
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
setSplitResults(prev => ({
...prev,
files: extractionResult.extractedFiles,
isGeneratingThumbnails: true
}));
// Generate thumbnails for preview
const thumbnails = await Promise.all(
extractionResult.extractedFiles.map(async (file) => {
try {
return await generateThumbnailForFile(file);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
return ''; // Empty string for failed thumbnails
}
})
);
setSplitResults(prev => ({
...prev,
thumbnails,
isGeneratingThumbnails: false
}));
}
} catch (extractError) {
console.warn('Failed to extract files for preview:', extractError);
// Don't fail the whole operation just because preview extraction failed
}
// Mark operation as applied on success
markOperationApplied(fileId, operationId);
} catch (error: any) {
console.error(error);
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
if (error.response?.data && typeof error.response.data === 'string') {
errorMsg = error.response.data;
} else if (error.message) {
errorMsg = error.message;
}
setErrorMessage(errorMsg);
setStatus(t("error._value", "Split failed."));
// Mark operation as failed
markOperationFailed(fileId, operationId, errorMsg);
} finally {
setIsLoading(false);
}
const handleSplit = async () => {
await splitOperation.executeOperation(
splitParams.mode,
splitParams.parameters,
selectedFiles
);
};
// Check if current mode needs additional parameters
const modeNeedsParams = (currentMode: string) => {
return currentMode && currentMode !== ""; // All modes need some params
};
// Handle thumbnail click to open in viewer
const handleThumbnailClick = (file: File) => {
try {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'split');
setCurrentMode('viewer');
} catch (error) {
console.error('Failed to open file in viewer:', error);
}
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'split');
setCurrentMode('viewer');
};
// No longer needed - step completion is determined by split results
const handleSettingsReset = () => {
splitOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode('split');
};
// Check if step 2 settings are valid (for enabling Split button)
const step2SettingsValid = (() => {
if (!mode) return false;
const hasFiles = selectedFiles.length > 0;
const hasResults = splitOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
switch (mode) {
case "byPages":
return pages.trim() !== "";
case "bySections":
return hDiv !== "" && vDiv !== "";
case "bySizeOrCount":
return splitType !== "" && splitValue.trim() !== "";
case "byChapters":
return bookmarkLevel !== "";
default:
return false;
}
})();
// Determine what steps to show
const showStep1 = true; // Always show - Files
const showStep2 = selectedFiles.length > 0; // Settings (mode + params)
const showStep3 = downloadUrl !== null; // Review (show results after split)
// Determine if steps are collapsed (completed)
const step1Collapsed = selectedFiles.length > 0;
const step2Collapsed = downloadUrl !== null;
const previewResults = useMemo(() =>
splitOperation.files?.map((file, index) => ({
file,
thumbnail: splitOperation.thumbnails[index]
})) || [],
[splitOperation.files, splitOperation.thumbnails]
);
return (
<Box h="100%" p="md" style={{ overflow: 'auto' }}>
<Stack gap="md">
{/* Step 1: Files */}
{showStep1 && (
<Paper p="md" withBorder>
<Text fw={500} size="lg" mb="sm">1. Files</Text>
{step1Collapsed ? (
<Text size="sm" c="green">
Selected: {selectedFiles[0]?.name}
</Text>
) : (
<Text size="sm" c="dimmed">
Select a PDF file in the main view to get started
</Text>
<ToolStepContainer>
<Stack gap="md" h="100%" p="md" style={{ overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title="Files"
isVisible={true}
isCollapsed={filesCollapsed}
isCompleted={filesCollapsed}
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined}
>
<FileStatusIndicator
selectedFiles={selectedFiles}
placeholder="Select a PDF file in the main view to get started"
/>
</ToolStep>
{/* Settings Step */}
<ToolStep
title="Settings"
isVisible={hasFiles}
isCollapsed={settingsCollapsed}
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? "Split completed" : undefined}
>
<Stack gap="md">
<SplitSettings
mode={splitParams.mode}
onModeChange={splitParams.setMode}
parameters={splitParams.parameters}
onParameterChange={splitParams.updateParameter}
disabled={endpointLoading}
/>
{splitParams.mode && (
<OperationButton
onClick={handleSplit}
isLoading={splitOperation.isLoading}
disabled={!splitParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t("loading")}
submitText={t("split.submit", "Split PDF")}
/>
)}
</Paper>
)}
</Stack>
</ToolStep>
{/* Step 2: Settings */}
{showStep2 && (
<Paper
p="md"
withBorder
style={{
cursor: step2Collapsed ? 'pointer' : 'default',
opacity: step2Collapsed ? 0.8 : 1,
transition: 'opacity 0.2s ease'
}}
onClick={step2Collapsed ? () => {
// Reset to allow changing settings
setDownloadUrl(null);
setSplitResults({
files: [],
thumbnails: [],
isGeneratingThumbnails: false
});
setStatus("");
setErrorMessage(null);
// Clear any active preview and return to previous view
onPreviewFile?.(null);
setCurrentMode('split');
} : undefined}
>
<Text fw={500} size="lg" mb="sm">2. Settings</Text>
{step2Collapsed ? (
<Text size="sm" c="green">
Split completed <Text span c="dimmed" size="xs">(click to change settings)</Text>
</Text>
) : (
<Stack gap="md">
<Select
label="Choose split method"
placeholder="Select how to split the PDF"
value={mode}
onChange={(v) => v && setMode(v)}
data={[
{ value: "byPages", label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
{ value: "bySections", label: t("split-by-sections.title", "Split by Grid Sections") },
{ value: "bySizeOrCount", label: t("split-by-size-or-count.title", "Split by Size or Count") },
{ value: "byChapters", label: t("splitByChapters.title", "Split by Chapters") },
]}
/>
{/* Mode-specific Parameters */}
{mode === "byPages" && (
<TextInput
label={t("split.splitPages", "Pages")}
placeholder={t("pageSelectionPrompt", "e.g. 1,3,5-10")}
value={pages}
onChange={(e) => setPages(e.target.value)}
/>
)}
{mode === "bySections" && (
<Stack gap="sm">
<TextInput
label={t("split-by-sections.horizontal.label", "Horizontal Divisions")}
type="number"
min="0"
max="300"
value={hDiv}
onChange={(e) => setHDiv(e.target.value)}
placeholder={t("split-by-sections.horizontal.placeholder", "Enter number of horizontal divisions")}
/>
<TextInput
label={t("split-by-sections.vertical.label", "Vertical Divisions")}
type="number"
min="0"
max="300"
value={vDiv}
onChange={(e) => setVDiv(e.target.value)}
placeholder={t("split-by-sections.vertical.placeholder", "Enter number of vertical divisions")}
/>
<Checkbox
label={t("split-by-sections.merge", "Merge sections into one PDF")}
checked={merge}
onChange={(e) => setMerge(e.currentTarget.checked)}
/>
</Stack>
)}
{mode === "bySizeOrCount" && (
<Stack gap="sm">
<Select
label={t("split-by-size-or-count.type.label", "Split Type")}
value={splitType}
onChange={(v) => v && setSplitType(v)}
data={[
{ value: "size", label: t("split-by-size-or-count.type.size", "By Size") },
{ value: "pages", label: t("split-by-size-or-count.type.pageCount", "By Page Count") },
{ value: "docs", label: t("split-by-size-or-count.type.docCount", "By Document Count") },
]}
/>
<TextInput
label={t("split-by-size-or-count.value.label", "Split Value")}
placeholder={t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages")}
value={splitValue}
onChange={(e) => setSplitValue(e.target.value)}
/>
</Stack>
)}
{mode === "byChapters" && (
<Stack gap="sm">
<TextInput
label={t("splitByChapters.bookmarkLevel", "Bookmark Level")}
type="number"
value={bookmarkLevel}
onChange={(e) => setBookmarkLevel(e.target.value)}
/>
<Checkbox
label={t("splitByChapters.includeMetadata", "Include Metadata")}
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.currentTarget.checked)}
/>
<Checkbox
label={t("splitByChapters.allowDuplicates", "Allow Duplicate Bookmarks")}
checked={allowDuplicates}
onChange={(e) => setAllowDuplicates(e.currentTarget.checked)}
/>
</Stack>
)}
{/* Split Button */}
{mode && (
<form onSubmit={handleSubmit}>
<Button
type="submit"
fullWidth
mt="md"
loading={isLoading}
disabled={!step2SettingsValid || selectedFiles.length === 0}
>
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
</Button>
</form>
)}
</Stack>
)}
</Paper>
)}
{/* Step 3: Results */}
{showStep3 && (
<Paper p="md" withBorder>
<Text fw={500} size="lg" mb="sm">3. Results</Text>
{status && <Text size="sm" c="dimmed" mb="md">{status}</Text>}
{errorMessage && (
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)} mb="md">
{errorMessage}
</Notification>
{/* Results Step */}
<ToolStep
title="Results"
isVisible={hasResults}
>
<Stack gap="md">
{splitOperation.status && (
<Text size="sm" c="dimmed">{splitOperation.status}</Text>
)}
{downloadUrl && (
<ErrorNotification
error={splitOperation.errorMessage}
onClose={splitOperation.clearError}
/>
{splitOperation.downloadUrl && (
<Button
component="a"
href={downloadUrl}
href={splitOperation.downloadUrl}
download="split_output.zip"
leftSection={<DownloadIcon />}
color="green"
fullWidth
mb="md"
>
{t("download", "Download")}
</Button>
)}
{/* Split Results Preview */}
{(splitResults.files.length > 0 || splitResults.isGeneratingThumbnails) && (
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }}>
<Text fw={500} size="md" mb="sm">
Split Results ({splitResults.files.length} files)
</Text>
{splitResults.isGeneratingThumbnails ? (
<Center p="lg">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">Generating previews...</Text>
</Stack>
</Center>
) : (
<Grid>
{splitResults.files.map((file, index) => (
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
<Paper
p="xs"
withBorder
style={{
textAlign: 'center',
height: '200px',
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onClick={() => handleThumbnailClick(file)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.02)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '';
}}
>
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{splitResults.thumbnails[index] ? (
<Image
src={splitResults.thumbnails[index]}
alt={`Preview of ${file.name}`}
style={{
maxWidth: '100%',
maxHeight: '140px',
objectFit: 'contain'
}}
/>
) : (
<Text size="xs" c="dimmed">No preview</Text>
)}
</Box>
<Text
size="xs"
c="dimmed"
mt="xs"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={file.name}
>
{file.name}
</Text>
<Text size="xs" c="dimmed">
{(file.size / 1024).toFixed(1)} KB
</Text>
</Paper>
</Grid.Col>
))}
</Grid>
)}
</Box>
)}
</Paper>
)}
<ResultsPreview
files={previewResults}
onFileClick={handleThumbnailClick}
isGeneratingThumbnails={splitOperation.isGeneratingThumbnails}
title="Split Results"
/>
</Stack>
</ToolStep>
</Stack>
</Box>
</ToolStepContainer>
);
};
}
export default SplitPdfPanel;
export default Split;