mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-08 17:51:20 +02:00
Split refactor
This commit is contained in:
parent
37484f64f9
commit
c100c9f10d
35
frontend/src/components/tools/shared/ErrorNotification.tsx
Normal file
35
frontend/src/components/tools/shared/ErrorNotification.tsx
Normal 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;
|
40
frontend/src/components/tools/shared/FileStatusIndicator.tsx
Normal file
40
frontend/src/components/tools/shared/FileStatusIndicator.tsx
Normal 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;
|
51
frontend/src/components/tools/shared/OperationButton.tsx
Normal file
51
frontend/src/components/tools/shared/OperationButton.tsx
Normal 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;
|
112
frontend/src/components/tools/shared/ResultsPreview.tsx
Normal file
112
frontend/src/components/tools/shared/ResultsPreview.tsx
Normal 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;
|
120
frontend/src/components/tools/shared/ToolStep.tsx
Normal file
120
frontend/src/components/tools/shared/ToolStep.tsx
Normal 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;
|
148
frontend/src/components/tools/split/SplitSettings.tsx
Normal file
148
frontend/src/components/tools/split/SplitSettings.tsx
Normal 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;
|
22
frontend/src/constants/splitConstants.ts
Normal file
22
frontend/src/constants/splitConstants.ts
Normal 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];
|
67
frontend/src/hooks/tools/shared/useOperationResults.ts
Normal file
67
frontend/src/hooks/tools/shared/useOperationResults.ts
Normal 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,
|
||||
};
|
||||
};
|
242
frontend/src/hooks/tools/split/useSplitOperation.ts
Normal file
242
frontend/src/hooks/tools/split/useSplitOperation.ts
Normal 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,
|
||||
};
|
||||
};
|
71
frontend/src/hooks/tools/split/useSplitParameters.ts
Normal file
71
frontend/src/hooks/tools/split/useSplitParameters.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user