From c100c9f10d3c43d4bc5d3ff19157be334f9da87a Mon Sep 17 00:00:00 2001 From: Reece Date: Wed, 16 Jul 2025 00:12:54 +0100 Subject: [PATCH] Split refactor --- .../tools/shared/ErrorNotification.tsx | 35 + .../tools/shared/FileStatusIndicator.tsx | 40 ++ .../tools/shared/OperationButton.tsx | 51 ++ .../tools/shared/ResultsPreview.tsx | 112 +++ .../src/components/tools/shared/ToolStep.tsx | 120 ++++ .../components/tools/split/SplitSettings.tsx | 148 ++++ frontend/src/constants/splitConstants.ts | 22 + .../hooks/tools/shared/useOperationResults.ts | 67 ++ .../hooks/tools/split/useSplitOperation.ts | 242 +++++++ .../hooks/tools/split/useSplitParameters.ts | 71 ++ frontend/src/tools/Split.tsx | 643 ++++-------------- 11 files changed, 1030 insertions(+), 521 deletions(-) create mode 100644 frontend/src/components/tools/shared/ErrorNotification.tsx create mode 100644 frontend/src/components/tools/shared/FileStatusIndicator.tsx create mode 100644 frontend/src/components/tools/shared/OperationButton.tsx create mode 100644 frontend/src/components/tools/shared/ResultsPreview.tsx create mode 100644 frontend/src/components/tools/shared/ToolStep.tsx create mode 100644 frontend/src/components/tools/split/SplitSettings.tsx create mode 100644 frontend/src/constants/splitConstants.ts create mode 100644 frontend/src/hooks/tools/shared/useOperationResults.ts create mode 100644 frontend/src/hooks/tools/split/useSplitOperation.ts create mode 100644 frontend/src/hooks/tools/split/useSplitParameters.ts 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 ? ( + {`Preview + ) : ( + 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 = () => ( + + 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()} + + ); +} + +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) - - ) : ( - - 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") }, - ]} - /> - setSplitValue(e.target.value)} - /> - - )} - - {mode === "byChapters" && ( - - setBookmarkLevel(e.target.value)} - /> - setIncludeMetadata(e.currentTarget.checked)} - /> - setAllowDuplicates(e.currentTarget.checked)} - /> - - )} - - {/* Split Button */} - {mode && ( -
- -
- )} - - )} -
- )} - - {/* Step 3: Results */} - {showStep3 && ( - - 3. Results - - {status && {status}} - - {errorMessage && ( - setErrorMessage(null)} mb="md"> - {errorMessage} - + {/* Results Step */} + + + {splitOperation.status && ( + {splitOperation.status} )} - {downloadUrl && ( + + + {splitOperation.downloadUrl && ( )} - {/* 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] ? ( - {`Preview - ) : ( - No preview - )} - - - {file.name} - - - {(file.size / 1024).toFixed(1)} KB - - - - ))} - - )} -
- )} -
- )} + + + -
+ ); -}; +} -export default SplitPdfPanel; +export default Split;