diff --git a/frontend/src/components/tools/shared/ErrorNotification.tsx b/frontend/src/components/tools/shared/ErrorNotification.tsx
new file mode 100644
index 000000000..a1740a1f6
--- /dev/null
+++ b/frontend/src/components/tools/shared/ErrorNotification.tsx
@@ -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 (
+
+ {error}
+
+ );
+}
+
+export default ErrorNotification;
diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx
new file mode 100644
index 000000000..5ff76c13c
--- /dev/null
+++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx
@@ -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 (
+
+ {placeholder}
+
+ );
+ }
+
+ if (isCompleted) {
+ return (
+
+ ✓ Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
+
+ );
+ }
+
+ return (
+
+ Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
+
+ );
+}
+
+export default FileStatusIndicator;
\ No newline at end of file
diff --git a/frontend/src/components/tools/shared/OperationButton.tsx b/frontend/src/components/tools/shared/OperationButton.tsx
new file mode 100644
index 000000000..c356b2cc2
--- /dev/null
+++ b/frontend/src/components/tools/shared/OperationButton.tsx
@@ -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 (
+
+ );
+}
+
+export default OperationButton;
\ No newline at end of file
diff --git a/frontend/src/components/tools/shared/ResultsPreview.tsx b/frontend/src/components/tools/shared/ResultsPreview.tsx
new file mode 100644
index 000000000..c13b0bac3
--- /dev/null
+++ b/frontend/src/components/tools/shared/ResultsPreview.tsx
@@ -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 (
+
+ {emptyMessage}
+
+ );
+ }
+
+ return (
+
+ {title && (
+
+ {title} ({files.length} files)
+
+ )}
+
+ {isGeneratingThumbnails ? (
+
+
+
+ {loadingMessage}
+
+
+ ) : (
+
+ {files.map((result, index) => (
+
+ onFileClick?.(result.file)}
+ style={{
+ textAlign: 'center',
+ height: '10rem',
+ width:'5rem',
+ display: 'flex',
+ flexDirection: 'column',
+ cursor: onFileClick ? 'pointer' : 'default',
+ transition: 'all 0.2s ease'
+ }}
+ >
+
+ {result.thumbnail ? (
+
+ ) : (
+ No preview
+ )}
+
+
+ {result.file.name}
+
+
+ {formatSize(result.file.size)}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default ResultsPreview;
diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx
new file mode 100644
index 000000000..4ac22aa10
--- /dev/null
+++ b/frontend/src/components/tools/shared/ToolStep.tsx
@@ -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(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 (
+
+
+ {shouldShowNumber ? `${stepNumber}. ` : ''}{title}
+
+
+ {isCollapsed ? (
+
+ {isCompleted && completedMessage && (
+
+ ✓ {completedMessage}
+ {onCollapsedClick && (
+
+ (click to change)
+
+ )}
+
+ )}
+
+ ) : (
+
+ {helpText && (
+
+ {helpText}
+
+ )}
+ {children}
+
+ )}
+
+ );
+}
+
+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 (
+
+ {children}
+
+ );
+}
+
+export default ToolStep;
diff --git a/frontend/src/components/tools/split/SplitSettings.tsx b/frontend/src/components/tools/split/SplitSettings.tsx
new file mode 100644
index 000000000..50ca49f20
--- /dev/null
+++ b/frontend/src/components/tools/split/SplitSettings.tsx
@@ -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 = () => (
+ onParameterChange('pages', e.target.value)}
+ disabled={disabled}
+ />
+ );
+
+ const renderBySectionsForm = () => (
+
+ onParameterChange('hDiv', e.target.value)}
+ placeholder={t("split-by-sections.horizontal.placeholder", "Enter number of horizontal divisions")}
+ disabled={disabled}
+ />
+ onParameterChange('vDiv', e.target.value)}
+ placeholder={t("split-by-sections.vertical.placeholder", "Enter number of vertical divisions")}
+ disabled={disabled}
+ />
+ onParameterChange('merge', e.currentTarget.checked)}
+ disabled={disabled}
+ />
+
+ );
+
+ const renderBySizeOrCountForm = () => (
+
+
+ );
+
+ const renderByChaptersForm = () => (
+
+ onParameterChange('bookmarkLevel', e.target.value)}
+ disabled={disabled}
+ />
+ onParameterChange('includeMetadata', e.currentTarget.checked)}
+ disabled={disabled}
+ />
+ onParameterChange('allowDuplicates', e.currentTarget.checked)}
+ disabled={disabled}
+ />
+
+ );
+
+ return (
+
+ {/* Mode Selector */}
+
+ );
+}
+
+export default SplitSettings;
diff --git a/frontend/src/constants/splitConstants.ts b/frontend/src/constants/splitConstants.ts
new file mode 100644
index 000000000..0da43ac57
--- /dev/null
+++ b/frontend/src/constants/splitConstants.ts
@@ -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];
\ No newline at end of file
diff --git a/frontend/src/hooks/tools/shared/useOperationResults.ts b/frontend/src/hooks/tools/shared/useOperationResults.ts
new file mode 100644
index 000000000..fca4f922a
--- /dev/null
+++ b/frontend/src/hooks/tools/shared/useOperationResults.ts
@@ -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(initialResults);
+ const [downloadUrl, setDownloadUrl] = useState(null);
+ const [status, setStatus] = useState('');
+ const [errorMessage, setErrorMessage] = useState(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,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/hooks/tools/split/useSplitOperation.ts b/frontend/src/hooks/tools/split/useSplitOperation.ts
new file mode 100644
index 000000000..3abf2981e
--- /dev/null
+++ b/frontend/src/hooks/tools/split/useSplitOperation.ts
@@ -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;
+
+ // 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([]);
+ const [thumbnails, setThumbnails] = useState([]);
+ const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
+ const [downloadUrl, setDownloadUrl] = useState(null);
+ const [status, setStatus] = useState('');
+ const [errorMessage, setErrorMessage] = useState(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,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/hooks/tools/split/useSplitParameters.ts b/frontend/src/hooks/tools/split/useSplitParameters.ts
new file mode 100644
index 000000000..4fac0b9d1
--- /dev/null
+++ b/frontend/src/hooks/tools/split/useSplitParameters.ts
@@ -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('');
+ const [parameters, setParameters] = useState(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,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx
index 8e3114d7c..e691d216a 100644
--- a/frontend/src/tools/Split.tsx
+++ b/frontend/src/tools/Split.tsx
@@ -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 = ({
- 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(null);
- const [downloadUrl, setDownloadUrl] = useState(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 (
-
-
- {/* Step 1: Files */}
- {showStep1 && (
-
- 1. Files
- {step1Collapsed ? (
-
- ✓ Selected: {selectedFiles[0]?.name}
-
- ) : (
-
- Select a PDF file in the main view to get started
-
+
+
+ {/* Files Step */}
+
+
+
+
+ {/* Settings Step */}
+
+
+
+
+ {splitParams.mode && (
+
)}
-
- )}
+
+
- {/* Step 2: Settings */}
- {showStep2 && (
- {
- // 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}
- >
- 2. Settings
- {step2Collapsed ? (
-
- ✓ Split completed (click to change settings)
-
- ) : (
-
-
- )}
-
- )}
-
- {/* Step 3: Results */}
- {showStep3 && (
-
- 3. Results
-
- {status && {status}}
-
- {errorMessage && (
- setErrorMessage(null)} mb="md">
- {errorMessage}
-
+ {/* Results Step */}
+
+
+ {splitOperation.status && (
+ {splitOperation.status}
)}
- {downloadUrl && (
+
+
+ {splitOperation.downloadUrl && (
}
color="green"
fullWidth
mb="md"
>
+ {t("download", "Download")}
)}
- {/* Split Results Preview */}
- {(splitResults.files.length > 0 || splitResults.isGeneratingThumbnails) && (
-
-
- Split Results ({splitResults.files.length} files)
-
-
- {splitResults.isGeneratingThumbnails ? (
-
-
-
- Generating previews...
-
-
- ) : (
-
- {splitResults.files.map((file, index) => (
-
- 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 = '';
- }}
- >
-
- {splitResults.thumbnails[index] ? (
-
- ) : (
- No preview
- )}
-
-
- {file.name}
-
-
- {(file.size / 1024).toFixed(1)} KB
-
-
-
- ))}
-
- )}
-
- )}
-
- )}
+
+
+
-
+
);
-};
+}
-export default SplitPdfPanel;
+export default Split;