diff --git a/frontend/src/components/tools/ToolRenderer.tsx b/frontend/src/components/tools/ToolRenderer.tsx index 4a9b9901d..032d84828 100644 --- a/frontend/src/components/tools/ToolRenderer.tsx +++ b/frontend/src/components/tools/ToolRenderer.tsx @@ -41,10 +41,8 @@ files, case "compress": return ( {}} - params={toolParams} - updateParams={updateParams} + selectedFiles={toolSelectedFiles} + onPreviewFile={onPreviewFile} /> ); case "merge": diff --git a/frontend/src/components/tools/compress/CompressSettings.tsx b/frontend/src/components/tools/compress/CompressSettings.tsx new file mode 100644 index 000000000..717b07566 --- /dev/null +++ b/frontend/src/components/tools/compress/CompressSettings.tsx @@ -0,0 +1,161 @@ +import React, { useState } from "react"; +import { Button, Stack, Text, NumberInput, Select } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +interface CompressParameters { + compressionMethod: 'quality' | 'filesize'; + compressionLevel: number; + fileSizeValue: string; + fileSizeUnit: 'KB' | 'MB'; + grayscale: boolean; +} + +interface CompressSettingsProps { + parameters: CompressParameters; + onParameterChange: (key: keyof CompressParameters, value: any) => void; + disabled?: boolean; +} + +const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => { + const { t } = useTranslation(); + const [isSliding, setIsSliding] = useState(false); + + return ( + + {/* Compression Method */} + + Compression Method +
+ + +
+
+ + {/* Quality Adjustment */} + {parameters.compressionMethod === 'quality' && ( + + Compression Level +
+ onParameterChange('compressionLevel', parseInt(e.target.value))} + onMouseDown={() => setIsSliding(true)} + onMouseUp={() => setIsSliding(false)} + onTouchStart={() => setIsSliding(true)} + onTouchEnd={() => setIsSliding(false)} + disabled={disabled} + style={{ + width: '100%', + height: '6px', + borderRadius: '3px', + background: `linear-gradient(to right, #228be6 0%, #228be6 ${(parameters.compressionLevel - 1) / 8 * 100}%, #e9ecef ${(parameters.compressionLevel - 1) / 8 * 100}%, #e9ecef 100%)`, + outline: 'none', + WebkitAppearance: 'none' + }} + /> + {isSliding && ( +
+ {parameters.compressionLevel} +
+ )} +
+
+ Min 1 + Max 9 +
+ + {parameters.compressionLevel <= 3 && "1-3 PDF compression"} + {parameters.compressionLevel >= 4 && parameters.compressionLevel <= 6 && "4-6 lite image compression"} + {parameters.compressionLevel >= 7 && "7-9 intense image compression Will dramatically reduce image quality"} + +
+ )} + + {/* File Size Input */} + {parameters.compressionMethod === 'filesize' && ( + + Desired File Size +
+ onParameterChange('fileSizeValue', value?.toString() || '')} + min={0} + disabled={disabled} + style={{ flex: 1 }} + /> + onParameterChange('grayscale', e.target.checked)} + disabled={disabled} + /> + {t("compress.grayscale.label", "Apply Grayscale for compression")} + + + + ); +}; + +export default CompressSettings; \ No newline at end of file diff --git a/frontend/src/hooks/tools/compress/useCompressOperation.ts b/frontend/src/hooks/tools/compress/useCompressOperation.ts new file mode 100644 index 000000000..4582068a6 --- /dev/null +++ b/frontend/src/hooks/tools/compress/useCompressOperation.ts @@ -0,0 +1,250 @@ +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'; + +export interface CompressParameters { + compressionLevel: number; + grayscale: boolean; + expectedSize: string; + compressionMethod: 'quality' | 'filesize'; + fileSizeValue: string; + fileSizeUnit: 'KB' | 'MB'; +} + +export interface CompressOperationHook { + executeOperation: ( + parameters: CompressParameters, + selectedFiles: File[] + ) => Promise; + + // Flattened result properties for cleaner access + files: File[]; + thumbnails: string[]; + isGeneratingThumbnails: boolean; + downloadUrl: string | null; + downloadFilename: string; + status: string; + errorMessage: string | null; + isLoading: boolean; + + // Result management functions + resetResults: () => void; + clearError: () => void; +} + +export const useCompressOperation = (): CompressOperationHook => { + const { t } = useTranslation(); + const { + recordOperation, + markOperationApplied, + markOperationFailed, + addFiles + } = useFileContext(); + + // Internal state management + const [files, setFiles] = useState([]); + const [thumbnails, setThumbnails] = useState([]); + const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadFilename, setDownloadFilename] = useState(''); + const [status, setStatus] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const buildFormData = useCallback(( + parameters: CompressParameters, + selectedFiles: File[] + ) => { + const formData = new FormData(); + + selectedFiles.forEach(file => { + formData.append("fileInput", file); + }); + + if (parameters.compressionMethod === 'quality') { + formData.append("optimizeLevel", parameters.compressionLevel.toString()); + } else { + // File size method + const fileSize = parameters.fileSizeValue ? `${parameters.fileSizeValue}${parameters.fileSizeUnit}` : ''; + if (fileSize) { + formData.append("expectedOutputSize", fileSize); + } + } + + formData.append("grayscale", parameters.grayscale.toString()); + + const endpoint = "/api/v1/misc/compress-pdf"; + + return { formData, endpoint }; + }, []); + + const createOperation = useCallback(( + parameters: CompressParameters, + selectedFiles: File[] + ): { operation: FileOperation; operationId: string; fileId: string } => { + const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const fileId = selectedFiles[0].name; + + const operation: FileOperation = { + id: operationId, + type: 'compress', + timestamp: Date.now(), + fileIds: selectedFiles.map(f => f.name), + status: 'pending', + metadata: { + originalFileName: selectedFiles[0].name, + parameters: { + compressionLevel: parameters.compressionLevel, + grayscale: parameters.grayscale, + expectedSize: parameters.expectedSize, + }, + fileSize: selectedFiles[0].size + } + }; + + return { operation, operationId, fileId }; + }, []); + + const processResults = useCallback(async (blob: Blob, selectedFiles: File[]) => { + try { + // Check if the response is a PDF file directly or a ZIP file + const contentType = blob.type; + console.log('Response content type:', contentType); + + if (contentType === 'application/pdf') { + // Direct PDF response + const originalFileName = selectedFiles[0].name; + const pdfFile = new File([blob], `compressed_${originalFileName}`, { type: "application/pdf" }); + setFiles([pdfFile]); + setThumbnails([]); + setIsGeneratingThumbnails(true); + + // Add file to FileContext + await addFiles([pdfFile]); + + // Generate thumbnail + const thumbnail = await generateThumbnailForFile(pdfFile); + setThumbnails([thumbnail || '']); + setIsGeneratingThumbnails(false); + } else { + // ZIP file response (like split operation) + const zipFile = new File([blob], "compress_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 { + const thumbnail = await generateThumbnailForFile(file); + return thumbnail || ''; + } catch (error) { + console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + return ''; + } + }) + ); + + setThumbnails(thumbnails); + setIsGeneratingThumbnails(false); + } + } + } catch (extractError) { + console.warn('Failed to process results:', extractError); + } + }, [addFiles]); + + const executeOperation = useCallback(async ( + parameters: CompressParameters, + selectedFiles: File[] + ) => { + if (selectedFiles.length === 0) { + setStatus(t("noFileSelected")); + return; + } + + const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); + const { formData, endpoint } = buildFormData(parameters, selectedFiles); + + recordOperation(fileId, operation); + + setStatus(t("loading")); + setIsLoading(true); + setErrorMessage(null); + + try { + const response = await axios.post(endpoint, formData, { responseType: "blob" }); + + // Determine the correct content type from the response + const contentType = response.headers['content-type'] || 'application/zip'; + const blob = new Blob([response.data], { type: contentType }); + const url = window.URL.createObjectURL(blob); + + // Generate dynamic filename based on original file and content type + const originalFileName = selectedFiles[0].name; + const filename = `compressed_${originalFileName}`; + setDownloadFilename(filename); + setDownloadUrl(url); + setStatus(t("downloadComplete")); + + await processResults(blob, selectedFiles); + markOperationApplied(fileId, operationId); + } catch (error: any) { + console.error(error); + let errorMsg = t("error.pdfPassword", "An error occurred while compressing 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", "Compression 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, + downloadFilename, + status, + errorMessage, + isLoading, + + // Result management functions + resetResults, + clearError, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/compress/useCompressParameters.ts b/frontend/src/hooks/tools/compress/useCompressParameters.ts new file mode 100644 index 000000000..bbd1a0994 --- /dev/null +++ b/frontend/src/hooks/tools/compress/useCompressParameters.ts @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { CompressParameters } from './useCompressOperation'; + +export interface CompressParametersHook { + parameters: CompressParameters; + updateParameter: (parameter: keyof CompressParameters, value: string | boolean | number) => void; + resetParameters: () => void; + validateParameters: () => boolean; + getEndpointName: () => string; +} + +const initialParameters: CompressParameters = { + compressionLevel: 5, + grayscale: false, + expectedSize: '', + compressionMethod: 'quality', + fileSizeValue: '', + fileSizeUnit: 'MB', +}; + +export const useCompressParameters = (): CompressParametersHook => { + const [parameters, setParameters] = useState(initialParameters); + + const updateParameter = (parameter: keyof CompressParameters, value: string | boolean | number) => { + setParameters(prev => ({ ...prev, [parameter]: value })); + }; + + const resetParameters = () => { + setParameters(initialParameters); + }; + + const validateParameters = () => { + // For compression, we only need to validate that compression level is within range + // and that at least one file is selected (at least, I think that's all we need to do here) + return parameters.compressionLevel >= 1 && parameters.compressionLevel <= 9; + }; + + const getEndpointName = () => { + return 'compress-pdf'; + }; + + return { + parameters, + updateParameter, + resetParameters, + validateParameters, + getEndpointName, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 31e63e93d..317d6abbc 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -21,7 +21,7 @@ type ToolRegistry = { const baseToolRegistry = { split: { icon: , component: SplitPdfPanel, view: "split" }, - compress: { icon: , component: CompressPdfPanel, view: "viewer" }, + compress: { icon: , component: CompressPdfPanel, view: "compress" }, merge: { icon: , component: MergePdfPanel, view: "pageEditor" }, }; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index f858a5db9..eb4908856 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -100,7 +100,7 @@ export default function HomePage() { className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-surface)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`} style={{ width: sidebarsVisible && !readerMode ? '14vw' : '0', - padding: sidebarsVisible && !readerMode ? '1rem' : '0' + padding: sidebarsVisible && !readerMode ? '0.5rem' : '0' }} >
{/* Tool title */} -
+

{selectedTool?.name}

@@ -219,6 +219,11 @@ export default function HomePage() { setCurrentView('split'); setLeftPanelView('toolContent'); sessionStorage.removeItem('previousMode'); + } else if (previousMode === 'compress') { + selectTool('compress'); + setCurrentView('compress'); + setLeftPanelView('toolContent'); + sessionStorage.removeItem('previousMode'); } else { setCurrentView('fileEditor'); } @@ -251,7 +256,17 @@ export default function HomePage() { ) : currentView === "split" ? ( { + setToolSelectedFiles(files); + }} + /> + ) : currentView === "compress" ? ( + { diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index 70ca4e00d..b06945610 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -1,186 +1,157 @@ -import React, { useState } from "react"; +import React, { useEffect, useMemo } from "react"; +import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { Stack, Slider, Group, Text, Button, Checkbox, TextInput, Loader, Alert } from "@mantine/core"; -import { FileWithUrl } from "../types/file"; -import { fileStorage } from "../services/fileStorage"; +import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; +import { useFileContext } from "../contexts/FileContext"; -export interface CompressProps { - files?: FileWithUrl[]; - setDownloadUrl?: (url: string) => void; - setLoading?: (loading: boolean) => void; - params?: { - compressionLevel: number; - grayscale: boolean; - removeMetadata: boolean; - expectedSize: string; - aggressive: boolean; - }; - updateParams?: (newParams: Partial) => void; +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 CompressSettings from "../components/tools/compress/CompressSettings"; + +import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters"; +import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation"; + +interface CompressProps { + selectedFiles?: File[]; + onPreviewFile?: (file: File | null) => void; } -const CompressPdfPanel: React.FC = ({ - files = [], - setDownloadUrl, - setLoading, - params = { - compressionLevel: 5, - grayscale: false, - removeMetadata: false, - expectedSize: "", - aggressive: false, - }, - updateParams, -}) => { +const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => { const { t } = useTranslation(); + const { setCurrentMode } = useFileContext(); - const [selected, setSelected] = useState(files.map(() => false)); - const [localLoading, setLocalLoading] = useState(false); + const compressParams = useCompressParameters(); + const compressOperation = useCompressOperation(); + + // Endpoint validation const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf"); - const { - compressionLevel, - grayscale, - removeMetadata, - expectedSize, - aggressive, - } = params; - - // Update selection state if files prop changes - React.useEffect(() => { - setSelected(files.map(() => false)); - }, [files]); - - const handleCheckbox = (idx: number) => { - setSelected(sel => sel.map((v, i) => (i === idx ? !v : v))); - }; + useEffect(() => { + compressOperation.resetResults(); + onPreviewFile?.(null); + }, [compressParams.parameters, selectedFiles]); const handleCompress = async () => { - const selectedFiles = files.filter((_, i) => selected[i]); - if (selectedFiles.length === 0) return; - setLocalLoading(true); - setLoading?.(true); - - try { - const formData = new FormData(); - - // Handle IndexedDB files - for (const file of selectedFiles) { - if (!file.id) { - continue; // Skip files without an id - } - const storedFile = await fileStorage.getFile(file.id); - if (storedFile) { - const blob = new Blob([storedFile.data], { type: storedFile.type }); - const actualFile = new File([blob], storedFile.name, { - type: storedFile.type, - lastModified: storedFile.lastModified - }); - formData.append("fileInput", actualFile); - } - } - - formData.append("compressionLevel", compressionLevel.toString()); - formData.append("grayscale", grayscale.toString()); - formData.append("removeMetadata", removeMetadata.toString()); - formData.append("aggressive", aggressive.toString()); - if (expectedSize) formData.append("expectedSize", expectedSize); - - const res = await fetch("/api/v1/general/compress-pdf", { - method: "POST", - body: formData, - }); - const blob = await res.blob(); - setDownloadUrl?.(URL.createObjectURL(blob)); - } catch (error) { - console.error('Compression failed:', error); - } finally { - setLocalLoading(false); - setLoading?.(false); - } + await compressOperation.executeOperation( + compressParams.parameters, + selectedFiles + ); }; - if (endpointLoading) { - return ( - - - {t("loading", "Loading...")} - - ); - } + const handleThumbnailClick = (file: File) => { + onPreviewFile?.(file); + sessionStorage.setItem('previousMode', 'compress'); + setCurrentMode('viewer'); + }; - if (endpointEnabled === false) { - return ( - - - {t("endpointDisabled", "This feature is currently disabled.")} - - - ); - } + const handleSettingsReset = () => { + compressOperation.resetResults(); + onPreviewFile?.(null); + setCurrentMode('compress'); + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = compressOperation.downloadUrl !== null; + const filesCollapsed = hasFiles; + const settingsCollapsed = hasResults; + + const previewResults = useMemo(() => + compressOperation.files?.map((file, index) => ({ + file, + thumbnail: compressOperation.thumbnails[index] + })) || [], + [compressOperation.files, compressOperation.thumbnails] + ); return ( - - {t("multiPdfDropPrompt", "Select files to compress:")} - - {files.length === 0 && {t("noFileSelected")}} - {files.map((file, idx) => ( - handleCheckbox(idx)} - /> - ))} - - - {t("compress.selectText.2", "Compression Level")} - updateParams?.({ compressionLevel: value })} - marks={[ - { value: 1, label: "1" }, - { value: 5, label: "5" }, - { value: 9, label: "9" }, - ]} - style={{ flex: 1 }} - /> - - updateParams?.({ grayscale: e.currentTarget.checked })} - /> - updateParams?.({ removeMetadata: e.currentTarget.checked })} - /> - updateParams?.({ aggressive: e.currentTarget.checked })} - /> - updateParams?.({ expectedSize: e.currentTarget.value })} - /> - - - ); -}; + + -export default CompressPdfPanel; + {/* Settings Step */} + + + + + + + + + {/* Results Step */} + + + {compressOperation.status && ( + {compressOperation.status} + )} + + + + {compressOperation.downloadUrl && ( + + )} + + + + + + + ); +} + + +export default Compress; diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index e691d216a..c6cfbb893 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -73,7 +73,7 @@ const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => { return ( - + {/* Files Step */} { onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined} completedMessage={settingsCollapsed ? "Split completed" : undefined} > - + { title="Results" isVisible={hasResults} > - + {splitOperation.status && ( {splitOperation.status} )}