diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml new file mode 100644 index 000000000..eb12d5cd9 --- /dev/null +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -0,0 +1,183 @@ +name: Auto V2 Deploy on Push + +on: + push: + branches: + - V2 + - deploy-on-v2-commit + +permissions: + contents: read + +jobs: + deploy-v2-on-push: + runs-on: ubuntu-latest + concurrency: + group: deploy-v2-push-V2 + cancel-in-progress: true + + steps: + - name: Harden Runner + uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Get commit hashes for frontend and backend + id: commit-hashes + run: | + # Get last commit that touched the frontend folder, docker/frontend, or docker/compose + FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "") + if [ -z "$FRONTEND_HASH" ]; then + FRONTEND_HASH="no-frontend-changes" + fi + + # Get last commit that touched backend code, docker/backend, or docker/compose + BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "") + if [ -z "$BACKEND_HASH" ]; then + BACKEND_HASH="no-backend-changes" + fi + + echo "Frontend hash: $FRONTEND_HASH" + echo "Backend hash: $BACKEND_HASH" + + echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT + echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT + + # Short hashes for tags + if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then + echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT + else + echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT + fi + + if [ "$BACKEND_HASH" = "no-backend-changes" ]; then + echo "backend_short=no-backend" >> $GITHUB_OUTPUT + else + echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT + fi + + - name: Check if frontend image exists + id: check-frontend + run: | + if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Frontend image already exists, skipping build" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Frontend image needs to be built" + fi + + - name: Check if backend image exists + id: check-backend + run: | + if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Backend image already exists, skipping build" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Backend image needs to be built" + fi + + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_API }} + + - name: Build and push frontend image + if: steps.check-frontend.outputs.exists == 'false' + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/frontend/Dockerfile + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + build-args: VERSION_TAG=v2-alpha + platforms: linux/amd64 + + - name: Build and push backend image + if: steps.check-backend.outputs.exists == 'false' + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/backend/Dockerfile + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + build-args: VERSION_TAG=v2-alpha + platforms: linux/amd64 + + + - name: Set up SSH + run: | + mkdir -p ~/.ssh/ + echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key + chmod 600 ../private.key + + + - name: Deploy to VPS on port 3000 + run: | + export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml + + cat > $UNIQUE_NAME << EOF + version: '3.3' + services: + backend: + container_name: stirling-v2-backend + image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + ports: + - "13000:8080" + volumes: + - /stirling/V2/data:/usr/share/tessdata:rw + - /stirling/V2/config:/configs:rw + - /stirling/V2/logs:/logs:rw + environment: + DISABLE_ADDITIONAL_FEATURES: "true" + SECURITY_ENABLELOGIN: "false" + SYSTEM_DEFAULTLOCALE: en-GB + UI_APPNAME: "Stirling-PDF V2" + UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split" + UI_APPNAMENAVBAR: "V2 Deployment" + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + restart: on-failure:5 + + frontend: + container_name: stirling-v2-frontend + image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + ports: + - "3000:80" + environment: + VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000" + depends_on: + - backend + restart: on-failure:5 + EOF + + # Copy to remote with unique name + scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME + + # SSH and rename/move atomically to avoid interference + ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH + mkdir -p /stirling/V2/{data,config,logs} + mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml + cd /stirling/V2 + docker-compose down || true + docker-compose pull + docker-compose up -d + docker system prune -af --volumes + docker image prune -af --filter "until=336h" --filter "label!=keep=true" + ENDSSH + + - name: Cleanup temporary files + if: always() + run: | + rm -f ../private.key + diff --git a/frontend/index.html b/frontend/index.html index 0fc165c66..c4a808349 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,12 +7,12 @@ - Vite App + Stirling PDF diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e82d7349c..060a51d64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", "jszip": "^3.10.1", + "material-symbols": "^0.33.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", @@ -5092,6 +5093,12 @@ "semver": "bin/semver.js" } }, + "node_modules/material-symbols": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.33.0.tgz", + "integrity": "sha512-t9/Gz+14fClRgN7oVOt5CBuwsjFLxSNP9BRDyMrI5el3IZNvoD94IDGJha0YYivyAow24rCS0WOkAv4Dp+YjNg==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7207248b1..f95b43bd3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", "jszip": "^3.10.1", + "material-symbols": "^0.33.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index a11777cc4..6d6c8521c 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png index fc44b0a37..2994ca293 100644 Binary files a/frontend/public/logo192.png and b/frontend/public/logo192.png differ diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png index a4e47a654..b48155073 100644 Binary files a/frontend/public/logo512.png and b/frontend/public/logo512.png differ diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index c0badafd8..9e0dc2171 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -7,6 +7,7 @@ import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import { useFileContext } from '../../contexts/FileContext'; +import { useFileSelection } from '../../contexts/FileSelectionContext'; import { FileOperation } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; @@ -31,20 +32,16 @@ interface FileEditorProps { onOpenPageEditor?: (file: File) => void; onMergeFiles?: (files: File[]) => void; toolMode?: boolean; - multiSelect?: boolean; showUpload?: boolean; showBulkActions?: boolean; - onFileSelect?: (files: File[]) => void; } const FileEditor = ({ onOpenPageEditor, onMergeFiles, toolMode = false, - multiSelect = true, showUpload = true, - showBulkActions = true, - onFileSelect + showBulkActions = true }: FileEditorProps) => { const { t } = useTranslation(); @@ -63,6 +60,14 @@ const FileEditor = ({ markOperationApplied } = fileContext; + // Get file selection context + const { + selectedFiles: toolSelectedFiles, + setSelectedFiles: setToolSelectedFiles, + maxFiles, + isToolMode + } = useFileSelection(); + const [files, setFiles] = useState([]); const [status, setStatus] = useState(null); const [error, setError] = useState(null); @@ -99,14 +104,14 @@ const FileEditor = ({ const lastActiveFilesRef = useRef([]); const lastProcessedFilesRef = useRef(0); - // Map context selected file names to local file IDs - // Defensive programming: ensure selectedFileIds is always an array - const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; + // Get selected file IDs from context (defensive programming) + const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; - const localSelectedFiles = files + // Map context selections to local file IDs for UI display + const localSelectedIds = files .filter(file => { const fileId = (file.file as any).id || file.name; - return safeSelectedFileIds.includes(fileId); + return contextSelectedIds.includes(fileId); }) .map(file => file.id); @@ -396,44 +401,41 @@ const FileEditor = ({ if (!targetFile) return; const contextFileId = (targetFile.file as any).id || targetFile.name; + const isSelected = contextSelectedIds.includes(contextFileId); - if (!multiSelect) { - // Single select mode for tools - toggle on/off - const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId); - if (isCurrentlySelected) { - // Deselect the file - setContextSelectedFiles([]); - if (onFileSelect) { - onFileSelect([]); - } - } else { - // Select the file - setContextSelectedFiles([contextFileId]); - if (onFileSelect) { - onFileSelect([targetFile.file]); - } - } + let newSelection: string[]; + + if (isSelected) { + // Remove file from selection + newSelection = contextSelectedIds.filter(id => id !== contextFileId); } else { - // Multi select mode (default) - setContextSelectedFiles(prev => { - const safePrev = Array.isArray(prev) ? prev : []; - return safePrev.includes(contextFileId) - ? safePrev.filter(id => id !== contextFileId) - : [...safePrev, contextFileId]; - }); - - // Notify parent with selected files - if (onFileSelect) { - const selectedFiles = files - .filter(f => { - const fId = (f.file as any).id || f.name; - return safeSelectedFileIds.includes(fId) || fId === contextFileId; - }) - .map(f => f.file); - onFileSelect(selectedFiles); + // Add file to selection + if (maxFiles === 1) { + newSelection = [contextFileId]; + } else { + // Check if we've hit the selection limit + if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) { + setStatus(`Maximum ${maxFiles} files can be selected`); + return; + } + newSelection = [...contextSelectedIds, contextFileId]; } } - }, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]); + + // Update context + setContextSelectedFiles(newSelection); + + // Update tool selection context if in tool mode + if (isToolMode || toolMode) { + const selectedFiles = files + .filter(f => { + const fId = (f.file as any).id || f.name; + return newSelection.includes(fId); + }) + .map(f => f.file); + setToolSelectedFiles(selectedFiles); + } + }, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]); const toggleSelectionMode = useCallback(() => { setSelectionMode(prev => { @@ -450,15 +452,15 @@ const FileEditor = ({ const handleDragStart = useCallback((fileId: string) => { setDraggedFile(fileId); - if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) { + if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) { setMultiFileDrag({ - fileIds: localSelectedFiles, - count: localSelectedFiles.length + fileIds: localSelectedIds, + count: localSelectedIds.length }); } else { setMultiFileDrag(null); } - }, [selectionMode, localSelectedFiles]); + }, [selectionMode, localSelectedIds]); const handleDragEnd = useCallback(() => { setDraggedFile(null); @@ -519,8 +521,8 @@ const FileEditor = ({ if (targetIndex === -1) return; } - const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile) - ? localSelectedFiles + const filesToMove = selectionMode && localSelectedIds.includes(draggedFile) + ? localSelectedIds : [draggedFile]; // Update the local files state and sync with activeFiles @@ -545,7 +547,7 @@ const FileEditor = ({ const moveCount = multiFileDrag ? multiFileDrag.count : 1; setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - }, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]); + }, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]); const handleEndZoneDragEnter = useCallback(() => { if (draggedFile) { @@ -764,7 +766,7 @@ const FileEditor = ({ ) : ( void; @@ -16,6 +22,86 @@ interface QuickAccessBarProps { readerMode: boolean; } +interface ButtonConfig { + id: string; + name: string; + icon: React.ReactNode; + tooltip: string; + isRound?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; + onClick: () => void; +} + +function NavHeader({ + activeButton, + setActiveButton, + onReaderToggle, + onToolsClick +}: { + activeButton: string; + setActiveButton: (id: string) => void; + onReaderToggle: () => void; + onToolsClick: () => void; +}) { + return ( + <> +
+ + + + + + + + + + +
+ {/* Divider after top icons */} + + {/* All Tools button below divider */} + +
+ { + setActiveButton('tools'); + onReaderToggle(); + onToolsClick(); + }} + style={{ + backgroundColor: activeButton === 'tools' ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)', + color: activeButton === 'tools' ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)', + border: 'none', + borderRadius: '8px', + }} + className={activeButton === 'tools' ? 'activeIconScale' : ''} + > + + + + + + All Tools + +
+
+ + ); +} + const QuickAccessBar = ({ onToolsClick, onReaderToggle, @@ -26,55 +112,201 @@ const QuickAccessBar = ({ }: QuickAccessBarProps) => { const { isRainbowMode } = useRainbowThemeContext(); const [configModalOpen, setConfigModalOpen] = useState(false); + const [activeButton, setActiveButton] = useState('tools'); + const scrollableRef = useRef(null); + const isOverflow = useIsOverflowing(scrollableRef); + + const buttonConfigs: ButtonConfig[] = [ + { + id: 'read', + name: 'Read', + icon: , + tooltip: 'Read documents', + size: 'lg', + isRound: false, + onClick: () => { + setActiveButton('read'); + onReaderToggle(); + } + }, + { + id: 'sign', + name: 'Sign', + icon: + + signature + , + tooltip: 'Sign your document', + size: 'lg', + isRound: false, + onClick: () => setActiveButton('sign') + }, + { + id: 'automate', + name: 'Automate', + icon: , + tooltip: 'Automate workflows', + size: 'lg', + isRound: false, + onClick: () => setActiveButton('automate') + }, + { + id: 'files', + name: 'Files', + icon: , + tooltip: 'Manage files', + isRound: true, + size: 'lg', + onClick: () => setActiveButton('files') + }, + { + id: 'activity', + name: 'Activity', + icon: + + vital_signs + , + tooltip: 'View activity and analytics', + isRound: true, + size: 'lg', + onClick: () => setActiveButton('activity') + }, + { + id: 'config', + name: 'Config', + icon: , + tooltip: 'Configure settings', + size: 'lg', + onClick: () => { + setConfigModalOpen(true); + } + } + ]; + + const CIRCULAR_BORDER_RADIUS = '50%'; + const ROUND_BORDER_RADIUS = '8px'; + + const getBorderRadius = (config: ButtonConfig): string => { + return config.isRound ? CIRCULAR_BORDER_RADIUS : ROUND_BORDER_RADIUS; + }; + + const getButtonStyle = (config: ButtonConfig) => { + const isActive = activeButton === config.id; + + if (isActive) { + return { + backgroundColor: `var(--icon-${config.id}-bg)`, + color: `var(--icon-${config.id}-color)`, + border: 'none', + borderRadius: getBorderRadius(config), + }; + } + + // Inactive state - use consistent inactive colors + return { + backgroundColor: 'var(--icon-inactive-bg)', + color: 'var(--icon-inactive-color)', + border: 'none', + borderRadius: getBorderRadius(config), + }; + }; return (
- - {/* All Tools Button */} -
- - - - Tools -
+ {/* Fixed header outside scrollable area */} +
+ +
- {/* Reader Mode Button */} -
- - - - Read -
+ {/* Conditional divider when overflowing */} + {isOverflow && ( + + )} - {/* Spacer */} -
- - {/* Config Modal Button (for testing) */} -
- setConfigModalOpen(true)} - > - - - Config + {/* Scrollable content area */} +
{ + // Prevent the wheel event from bubbling up to parent containers + e.stopPropagation(); + }} + > +
+ {/* Top section with main buttons */} + + {buttonConfigs.slice(0, -1).map((config, index) => ( + + +
+ + + {config.icon} + + + + {config.name} + +
+
+ + {/* Add divider after Automate button (index 2) */} + {index === 2 && ( + + )} +
+ ))} +
+ + {/* Spacer to push Config button to bottom */} +
+ + {/* Config button at the bottom */} + +
+ { + setConfigModalOpen(true); + }} + style={{ + backgroundColor: 'var(--icon-inactive-bg)', + color: 'var(--icon-inactive-color)', + border: 'none', + borderRadius: '8px', + }} + > + + + + + + Config + +
+
- +
+ + + + {toolName ? `Loading ${toolName}...` : "Loading tool..."} + + + + ) +} diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index 0bb0ea566..7b678de98 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -1,15 +1,7 @@ import React, { useState } from "react"; import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; - -type Tool = { - icon: React.ReactNode; - name: string; -}; - -type ToolRegistry = { - [id: string]: Tool; -}; +import { ToolRegistry } from "../../types/tool"; interface ToolPickerProps { selectedToolKey: string | null; diff --git a/frontend/src/components/tools/ToolRenderer.tsx b/frontend/src/components/tools/ToolRenderer.tsx index 06c5157a8..493470935 100644 --- a/frontend/src/components/tools/ToolRenderer.tsx +++ b/frontend/src/components/tools/ToolRenderer.tsx @@ -1,23 +1,18 @@ -import { FileWithUrl } from "../../types/file"; +import React, { Suspense } from "react"; import { useToolManagement } from "../../hooks/useToolManagement"; +import { BaseToolProps } from "../../types/tool"; +import ToolLoadingFallback from "./ToolLoadingFallback"; -interface ToolRendererProps { +interface ToolRendererProps extends BaseToolProps { selectedToolKey: string; - pdfFile: any; - files: FileWithUrl[]; - toolParams: any; - updateParams: (params: any) => void; - toolSelectedFiles?: File[]; - onPreviewFile?: (file: File | null) => void; } + const ToolRenderer = ({ selectedToolKey, -files, - toolParams, - updateParams, - toolSelectedFiles = [], onPreviewFile, + onComplete, + onError, }: ToolRendererProps) => { // Get the tool from registry const { toolRegistry } = useToolManagement(); @@ -29,46 +24,16 @@ files, const ToolComponent = selectedTool.component; - // Pass tool-specific props - switch (selectedToolKey) { - case "split": - return ( - - ); - case "compress": - return ( - - ); - case "convert": - return ( - - ); - case "merge": - return ( - - ); - default: - return ( - - ); - } + // Wrap lazy-loaded component with Suspense + return ( + }> + + + ); }; export default ToolRenderer; diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 811a49db7..6e8a42fab 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -22,6 +22,7 @@ import { useEnhancedProcessedFiles } from '../hooks/useEnhancedProcessedFiles'; import { fileStorage } from '../services/fileStorage'; import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; +import { getFileId } from '../utils/fileUtils'; // Initial state const initialViewerConfig: ViewerConfig = { @@ -98,7 +99,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): case 'REMOVE_FILES': const remainingFiles = state.activeFiles.filter(file => { - const fileId = (file as any).id || file.name; + const fileId = getFileId(file); return !action.payload.includes(fileId); }); const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : []; @@ -347,7 +348,7 @@ export function FileContextProvider({ // Cleanup timers and refs const cleanupTimers = useRef>(new Map()); const blobUrls = useRef>(new Set()); - const pdfDocuments = useRef>(new Map()); + const pdfDocuments = useRef>(new Map()); // Enhanced file processing hook const { @@ -381,7 +382,7 @@ export function FileContextProvider({ blobUrls.current.add(url); }, []); - const trackPdfDocument = useCallback((fileId: string, pdfDoc: any) => { + const trackPdfDocument = useCallback((fileId: string, pdfDoc: PDFDocument) => { // Clean up existing document for this file if any const existing = pdfDocuments.current.get(fileId); if (existing && existing.destroy) { @@ -498,7 +499,7 @@ export function FileContextProvider({ for (const file of files) { try { // Check if file already has an ID (already in IndexedDB) - const fileId = (file as any).id; + const fileId = getFileId(file); if (!fileId) { // File doesn't have ID, store it and get the ID const storedFile = await fileStorage.storeFile(file); @@ -680,7 +681,7 @@ export function FileContextProvider({ // Utility functions const getFileById = useCallback((fileId: string): File | undefined => { return state.activeFiles.find(file => { - const actualFileId = (file as any).id || file.name; + const actualFileId = getFileId(file); return actualFileId === fileId; }); }, [state.activeFiles]); diff --git a/frontend/src/contexts/FileSelectionContext.tsx b/frontend/src/contexts/FileSelectionContext.tsx new file mode 100644 index 000000000..2c79882b2 --- /dev/null +++ b/frontend/src/contexts/FileSelectionContext.tsx @@ -0,0 +1,86 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { + MaxFiles, + FileSelectionContextValue +} from '../types/tool'; + +interface FileSelectionProviderProps { + children: ReactNode; +} + +const FileSelectionContext = createContext(undefined); + +export function FileSelectionProvider({ children }: FileSelectionProviderProps) { + const [selectedFiles, setSelectedFiles] = useState([]); + const [maxFiles, setMaxFiles] = useState(-1); + const [isToolMode, setIsToolMode] = useState(false); + + const clearSelection = useCallback(() => { + setSelectedFiles([]); + }, []); + + const selectionCount = selectedFiles.length; + const canSelectMore = maxFiles === -1 || selectionCount < maxFiles; + const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles; + const isMultiFileMode = maxFiles !== 1; + + const contextValue: FileSelectionContextValue = { + selectedFiles, + maxFiles, + isToolMode, + setSelectedFiles, + setMaxFiles, + setIsToolMode, + clearSelection, + canSelectMore, + isAtLimit, + selectionCount, + isMultiFileMode + }; + + return ( + + {children} + + ); +} + +/** + * Access the file selection context. + * Throws if used outside a . + */ +export function useFileSelection(): FileSelectionContextValue { + const context = useContext(FileSelectionContext); + if (!context) { + throw new Error('useFileSelection must be used within a FileSelectionProvider'); + } + return context; +} + +// Returns only the file selection values relevant for tools (e.g. merge, split, etc.) +// Use this in tool panels/components that need to know which files are selected and selection limits. +export function useToolFileSelection(): Pick { + const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection(); + return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount }; +} + +// Returns actions for manipulating file selection state. +// Use this in components that need to update the selection, clear it, or change selection mode. +export function useFileSelectionActions(): Pick { + const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection(); + return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode }; +} + +// Returns the raw file selection state (selected files, max files, tool mode). +// Use this for low-level state access, e.g. in context-aware UI. +export function useFileSelectionState(): Pick { + const { selectedFiles, maxFiles, isToolMode } = useFileSelection(); + return { selectedFiles, maxFiles, isToolMode }; +} + +// Returns computed values derived from file selection state. +// Use this for file selection UI logic (e.g. disabling buttons when at limit). +export function useFileSelectionComputed(): Pick { + const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection(); + return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode }; +} diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index 3e0f977c1..efbaa35a9 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -4,3 +4,4 @@ declare module "../tools/Merge"; declare module "../components/PageEditor"; declare module "../components/Viewer"; declare module "*.js"; +declare module '*.module.css'; \ No newline at end of file diff --git a/frontend/src/hooks/tools/compress/useCompressOperation.ts b/frontend/src/hooks/tools/compress/useCompressOperation.ts index 4582068a6..e66b3f43f 100644 --- a/frontend/src/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/src/hooks/tools/compress/useCompressOperation.ts @@ -20,7 +20,7 @@ export interface CompressOperationHook { parameters: CompressParameters, selectedFiles: File[] ) => Promise; - + // Flattened result properties for cleaner access files: File[]; thumbnails: string[]; @@ -30,7 +30,7 @@ export interface CompressOperationHook { status: string; errorMessage: string | null; isLoading: boolean; - + // Result management functions resetResults: () => void; clearError: () => void; @@ -38,13 +38,13 @@ export interface CompressOperationHook { export const useCompressOperation = (): CompressOperationHook => { const { t } = useTranslation(); - const { - recordOperation, - markOperationApplied, + const { + recordOperation, + markOperationApplied, markOperationFailed, addFiles } = useFileContext(); - + // Internal state management const [files, setFiles] = useState([]); const [thumbnails, setThumbnails] = useState([]); @@ -55,15 +55,27 @@ export const useCompressOperation = (): CompressOperationHook => { const [errorMessage, setErrorMessage] = useState(null); const [isLoading, setIsLoading] = useState(false); + // Track blob URLs for cleanup + const [blobUrls, setBlobUrls] = useState([]); + + const cleanupBlobUrls = useCallback(() => { + blobUrls.forEach(url => { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.warn('Failed to revoke blob URL:', error); + } + }); + setBlobUrls([]); + }, [blobUrls]); + const buildFormData = useCallback(( parameters: CompressParameters, - selectedFiles: File[] + file: File ) => { const formData = new FormData(); - - selectedFiles.forEach(file => { - formData.append("fileInput", file); - }); + + formData.append("fileInput", file); if (parameters.compressionMethod === 'quality') { formData.append("optimizeLevel", parameters.compressionLevel.toString()); @@ -74,7 +86,7 @@ export const useCompressOperation = (): CompressOperationHook => { formData.append("expectedOutputSize", fileSize); } } - + formData.append("grayscale", parameters.grayscale.toString()); const endpoint = "/api/v1/misc/compress-pdf"; @@ -87,7 +99,7 @@ export const useCompressOperation = (): CompressOperationHook => { 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 fileId = selectedFiles.map(f => f.name).join(','); const operation: FileOperation = { id: operationId, @@ -96,74 +108,20 @@ export const useCompressOperation = (): CompressOperationHook => { fileIds: selectedFiles.map(f => f.name), status: 'pending', metadata: { - originalFileName: selectedFiles[0].name, + originalFileNames: selectedFiles.map(f => f.name), parameters: { compressionLevel: parameters.compressionLevel, grayscale: parameters.grayscale, expectedSize: parameters.expectedSize, }, - fileSize: selectedFiles[0].size + totalFileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0), + fileCount: selectedFiles.length } }; 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, @@ -173,32 +131,93 @@ export const useCompressOperation = (): CompressOperationHook => { setStatus(t("noFileSelected")); return; } + const validFiles = selectedFiles.filter(file => file.size > 0); + if (validFiles.length === 0) { + setErrorMessage('No valid files to compress. All selected files are empty.'); + return; + } + + if (validFiles.length < selectedFiles.length) { + console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`); + } const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); - const { formData, endpoint } = buildFormData(parameters, selectedFiles); recordOperation(fileId, operation); setStatus(t("loading")); setIsLoading(true); setErrorMessage(null); + setFiles([]); + setThumbnails([]); 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")); + const compressedFiles: File[] = []; - await processResults(blob, selectedFiles); + const failedFiles: string[] = []; + + for (let i = 0; i < validFiles.length; i++) { + const file = validFiles[i]; + setStatus(`Compressing ${file.name} (${i + 1}/${validFiles.length})`); + + try { + const { formData, endpoint } = buildFormData(parameters, file); + const response = await axios.post(endpoint, formData, { responseType: "blob" }); + + const contentType = response.headers['content-type'] || 'application/pdf'; + const blob = new Blob([response.data], { type: contentType }); + const compressedFile = new File([blob], `compressed_${file.name}`, { type: contentType }); + + compressedFiles.push(compressedFile); + } catch (fileError) { + console.error(`Failed to compress ${file.name}:`, fileError); + failedFiles.push(file.name); + } + } + + if (failedFiles.length > 0 && compressedFiles.length === 0) { + throw new Error(`Failed to compress all files: ${failedFiles.join(', ')}`); + } + + if (failedFiles.length > 0) { + setStatus(`Compressed ${compressedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`); + } + + setFiles(compressedFiles); + setIsGeneratingThumbnails(true); + + await addFiles(compressedFiles); + + cleanupBlobUrls(); + + if (compressedFiles.length === 1) { + const url = window.URL.createObjectURL(compressedFiles[0]); + setDownloadUrl(url); + setBlobUrls([url]); + setDownloadFilename(`compressed_${selectedFiles[0].name}`); + } else { + const { zipFile } = await zipFileService.createZipFromFiles(compressedFiles, 'compressed_files.zip'); + const url = window.URL.createObjectURL(zipFile); + setDownloadUrl(url); + setBlobUrls([url]); + setDownloadFilename(`compressed_${validFiles.length}_files.zip`); + } + + const thumbnails = await Promise.all( + compressedFiles.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); + setStatus(t("downloadComplete")); markOperationApplied(fileId, operationId); } catch (error: any) { console.error(error); @@ -214,9 +233,10 @@ export const useCompressOperation = (): CompressOperationHook => { } finally { setIsLoading(false); } - }, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]); + }, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, addFiles]); const resetResults = useCallback(() => { + cleanupBlobUrls(); setFiles([]); setThumbnails([]); setIsGeneratingThumbnails(false); @@ -224,7 +244,7 @@ export const useCompressOperation = (): CompressOperationHook => { setStatus(''); setErrorMessage(null); setIsLoading(false); - }, []); + }, [cleanupBlobUrls]); const clearError = useCallback(() => { setErrorMessage(null); @@ -232,8 +252,6 @@ export const useCompressOperation = (): CompressOperationHook => { return { executeOperation, - - // Flattened result properties for cleaner access files, thumbnails, isGeneratingThumbnails, @@ -242,9 +260,9 @@ export const useCompressOperation = (): CompressOperationHook => { status, errorMessage, isLoading, - + // Result management functions resetResults, clearError, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useIsOverflowing.ts b/frontend/src/hooks/useIsOverflowing.ts new file mode 100644 index 000000000..b5e6d3962 --- /dev/null +++ b/frontend/src/hooks/useIsOverflowing.ts @@ -0,0 +1,73 @@ +import * as React from 'react'; + + +/** + Hook to detect if an element's content overflows its container + + + Parameters: + - ref: React ref to the element to monitor + - callback: Optional callback function called when overflow state changes + + Returns: boolean | undefined - true if overflowing, false if not, undefined before first check + + Usage example: + + useEffect(() => { + if (isOverflow) { + // Do something + } + }, [isOverflow]); + + const scrollableRef = useRef(null); + const isOverflow = useIsOverflowing(scrollableRef); + + Fallback example (for browsers without ResizeObserver): + + return ( +
+ {Content that might overflow} +
+ ); +*/ + + +export const useIsOverflowing = (ref: React.RefObject, callback?: (isOverflow: boolean) => void) => { + // State to track overflow status + const [isOverflow, setIsOverflow] = React.useState(undefined); + + React.useLayoutEffect(() => { + const { current } = ref; + + // Function to check if element is overflowing + const trigger = () => { + if (!current) return; + + // Compare scroll height (total content height) vs client height (visible height) + const hasOverflow = current.scrollHeight > current.clientHeight; + setIsOverflow(hasOverflow); + + // Call optional callback with overflow state + if (callback) callback(hasOverflow); + }; + + if (current) { + // Use ResizeObserver for modern browsers (real-time detection) + if ('ResizeObserver' in window) { + const resizeObserver = new ResizeObserver(trigger); + resizeObserver.observe(current); + + // Cleanup function to disconnect observer + return () => { + resizeObserver.disconnect(); + }; + } + + // Fallback for browsers without ResizeObserver support + // Add a small delay to ensure the element is fully rendered + setTimeout(trigger, 0); + } + }, [callback, ref]); + + return isOverflow; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 9f9a62a9e..5a71c882d 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -1,68 +1,74 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import AddToPhotosIcon from "@mui/icons-material/AddToPhotos"; import ContentCutIcon from "@mui/icons-material/ContentCut"; import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; -import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; -import SplitPdfPanel from "../tools/Split"; -import CompressPdfPanel from "../tools/Compress"; -import MergePdfPanel from "../tools/Merge"; -import ConvertPanel from "../tools/Convert"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; +import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool"; + + +// Add entry here with maxFiles, endpoints, and lazy component +const toolDefinitions: Record = { + split: { + id: "split", + icon: , + component: React.lazy(() => import("../tools/Split")), + maxFiles: 1, + category: "manipulation", + description: "Split PDF files into smaller parts", + endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"] + }, + compress: { + id: "compress", + icon: , + component: React.lazy(() => import("../tools/Compress")), + maxFiles: -1, + category: "optimization", + description: "Reduce PDF file size", + endpoints: ["compress-pdf"] + }, -type ToolRegistryEntry = { - icon: React.ReactNode; - name: string; - component: React.ComponentType; - view: string; }; -type ToolRegistry = { - [key: string]: ToolRegistryEntry; -}; +interface ToolManagementResult { + selectedToolKey: string | null; + selectedTool: Tool | null; + toolSelectedFileIds: string[]; + toolRegistry: ToolRegistry; + selectTool: (toolKey: string) => void; + clearToolSelection: () => void; + setToolSelectedFileIds: (fileIds: string[]) => void; +} -const baseToolRegistry = { - split: { icon: , component: SplitPdfPanel, view: "split" }, - compress: { icon: , component: CompressPdfPanel, view: "compress" }, - merge: { icon: , component: MergePdfPanel, view: "pageEditor" }, - convert: { icon: , component: ConvertPanel, view: "convert" }, -}; - -// Tool endpoint mappings -const toolEndpoints: Record = { - split: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"], - compress: ["compress-pdf"], - merge: ["merge-pdfs"], - convert: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-csv", "pdf-to-markdown", "pdf-to-html", "pdf-to-xml", "pdf-to-pdfa", "html-to-pdf", "markdown-to-pdf", "file-to-pdf", "eml-to-pdf"], -}; - - -export const useToolManagement = () => { +export const useToolManagement = (): ToolManagementResult => { const { t } = useTranslation(); const [selectedToolKey, setSelectedToolKey] = useState(null); const [toolSelectedFileIds, setToolSelectedFileIds] = useState([]); - const allEndpoints = Array.from(new Set(Object.values(toolEndpoints).flat())); + const allEndpoints = Array.from(new Set( + Object.values(toolDefinitions).flatMap(tool => tool.endpoints || []) + )); const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const isToolAvailable = useCallback((toolKey: string): boolean => { if (endpointsLoading) return true; - const endpoints = toolEndpoints[toolKey] || []; - return endpoints.some(endpoint => endpointStatus[endpoint] === true); + const tool = toolDefinitions[toolKey]; + if (!tool?.endpoints) return true; + return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true); }, [endpointsLoading, endpointStatus]); const toolRegistry: ToolRegistry = useMemo(() => { - const availableToolRegistry: ToolRegistry = {}; - Object.keys(baseToolRegistry).forEach(toolKey => { + const availableTools: ToolRegistry = {}; + Object.keys(toolDefinitions).forEach(toolKey => { if (isToolAvailable(toolKey)) { - availableToolRegistry[toolKey] = { - ...baseToolRegistry[toolKey as keyof typeof baseToolRegistry], + const toolDef = toolDefinitions[toolKey]; + availableTools[toolKey] = { + ...toolDef, name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1)) }; } }); - return availableToolRegistry; + return availableTools; }, [t, isToolAvailable]); useEffect(() => { diff --git a/frontend/src/index.css b/frontend/src/index.css index ec2585e8c..f7e5e0865 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,9 @@ +@import 'material-symbols/rounded.css'; + +.material-symbols-rounded { + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; +} + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index e8f4b648b..cb1569bc0 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,9 +1,11 @@ -import React, { useState, useCallback} from "react"; +import React, { useState, useCallback, useEffect} from "react"; import { useTranslation } from 'react-i18next'; import { useFileContext } from "../contexts/FileContext"; +import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; import { useToolManagement } from "../hooks/useToolManagement"; import { Group, Box, Button, Container } from "@mantine/core"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; +import { PageEditorFunctions } from "../types/pageEditor"; import rainbowStyles from '../styles/rainbow.module.css'; import ToolPicker from "../components/tools/ToolPicker"; @@ -15,45 +17,50 @@ import Viewer from "../components/viewer/Viewer"; import FileUploadSelector from "../components/shared/FileUploadSelector"; import ToolRenderer from "../components/tools/ToolRenderer"; import QuickAccessBar from "../components/shared/QuickAccessBar"; -import { useMultipleEndpointsEnabled } from "../hooks/useEndpointConfig"; -export default function HomePage() { +function HomePageContent() { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); - // Get file context const fileContext = useFileContext(); const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext; + const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); const { selectedToolKey, selectedTool, - toolParams, toolRegistry, selectTool, clearToolSelection, - updateToolParams, } = useToolManagement(); - - const [toolSelectedFiles, setToolSelectedFiles] = useState([]); const [sidebarsVisible, setSidebarsVisible] = useState(true); const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); const [readerMode, setReaderMode] = useState(false); - const [pageEditorFunctions, setPageEditorFunctions] = useState(null); + const [pageEditorFunctions, setPageEditorFunctions] = useState(null); const [previewFile, setPreviewFile] = useState(null); - + // Update file selection context when tool changes + useEffect(() => { + if (selectedTool) { + setMaxFiles(selectedTool.maxFiles); + setIsToolMode(true); + } else { + setMaxFiles(-1); + setIsToolMode(false); + setSelectedFiles([]); + } + }, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]); const handleToolSelect = useCallback( (id: string) => { selectTool(id); - if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view); + setCurrentView('fileEditor'); // Tools use fileEditor view for file selection setLeftPanelView('toolContent'); setReaderMode(false); }, - [selectTool, toolRegistry, setCurrentView] + [selectTool, setCurrentView] ); const handleQuickAccessTools = useCallback(() => { @@ -63,7 +70,7 @@ export default function HomePage() { }, [clearToolSelection]); const handleReaderToggle = useCallback(() => { - setReaderMode(!readerMode); + setReaderMode(true); }, [readerMode]); const handleViewChange = useCallback((view: string) => { @@ -97,7 +104,7 @@ export default function HomePage() { {/* Left: Tool Picker or Selected Tool Panel */}
@@ -157,9 +163,11 @@ export default function HomePage() { {/* Main View */} {/* Top Controls */} ) : currentView === "fileEditor" ? ( { - handleViewChange("pageEditor"); - }} - onMergeFiles={(filesToMerge) => { - // Add merged files to active set - filesToMerge.forEach(addToActiveFiles); - handleViewChange("viewer"); - }} + toolMode={!!selectedToolKey} + showUpload={true} + showBulkActions={!selectedToolKey} + {...(!selectedToolKey && { + onOpenPageEditor: (file) => { + handleViewChange("pageEditor"); + }, + onMergeFiles: (filesToMerge) => { + filesToMerge.forEach(addToActiveFiles); + handleViewChange("viewer"); + } + })} /> ) : currentView === "viewer" ? ( )} - ) : currentView === "split" ? ( - { - setToolSelectedFiles(files); - }} - /> - ) : currentView === "compress" ? ( - { - setToolSelectedFiles(files); - }} - /> - ) : currentView === "convert" ? ( - { - setToolSelectedFiles(files); - }} - /> ) : selectedToolKey && selectedTool ? ( + // Fallback: if tool is selected but not in fileEditor view, show tool in main area @@ -315,3 +298,12 @@ export default function HomePage() { ); } + +// Main HomePage component wrapped with FileSelectionProvider +export default function HomePage() { + return ( + + + + ); +} diff --git a/frontend/src/services/zipFileService.ts b/frontend/src/services/zipFileService.ts index 3c238e159..90f5b2574 100644 --- a/frontend/src/services/zipFileService.ts +++ b/frontend/src/services/zipFileService.ts @@ -103,6 +103,37 @@ export class ZipFileService { } } + /** + * Create a ZIP file from an array of files + */ + async createZipFromFiles(files: File[], zipFilename: string): Promise<{ zipFile: File; size: number }> { + try { + const zip = new JSZip(); + + // Add each file to the ZIP + for (const file of files) { + const content = await file.arrayBuffer(); + zip.file(file.name, content); + } + + // Generate ZIP blob + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }); + + const zipFile = new File([zipBlob], zipFilename, { + type: 'application/zip', + lastModified: Date.now() + }); + + return { zipFile, size: zipFile.size }; + } catch (error) { + throw new Error(`Failed to create ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + /** * Extract PDF files from a ZIP archive */ diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 98f05a2f9..7cdb46c55 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -72,6 +72,8 @@ --bg-surface: #ffffff; --bg-raised: #f9fafb; --bg-muted: #f3f4f6; + --bg-background: #f9fafb; + --bg-toolbar: #ffffff; --text-primary: #111827; --text-secondary: #4b5563; --text-muted: #6b7280; @@ -80,51 +82,101 @@ --border-strong: #9ca3af; --hover-bg: #f9fafb; --active-bg: #f3f4f6; + + /* Icon colors for light mode */ + --icon-user-bg: #9CA3AF; + --icon-user-color: #FFFFFF; + --icon-notifications-bg: #9CA3AF; + --icon-notifications-color: #FFFFFF; + --icon-tools-bg: #1E88E5; + --icon-tools-color: #FFFFFF; + --icon-read-bg: #4CAF50; + --icon-read-color: #FFFFFF; + --icon-sign-bg: #3BA99C; + --icon-sign-color: #FFFFFF; + --icon-automate-bg: #A576E3; + --icon-automate-color: #FFFFFF; + --icon-files-bg: #D3E7F7; + --icon-files-color: #0A8BFF; + --icon-activity-bg: #D3E7F7; + --icon-activity-color: #0A8BFF; + --icon-config-bg: #9CA3AF; + --icon-config-color: #FFFFFF; + + /* Inactive icon colors for light mode */ + --icon-inactive-bg: #9CA3AF; + --icon-inactive-color: #FFFFFF; } [data-mantine-color-scheme="dark"] { /* Dark theme gray scale (inverted) */ --gray-50: 17 24 39; - --gray-100: 31 41 55; - --gray-200: 55 65 81; - --gray-300: 75 85 99; - --gray-400: 107 114 128; - --gray-500: 156 163 175; - --gray-600: 209 213 219; - --gray-700: 229 231 235; - --gray-800: 243 244 246; - --gray-900: 249 250 251; + --gray-100: 31 35 41; + --gray-200: 42 47 54; + --gray-300: 55 65 81; + --gray-400: 75 85 99; + --gray-500: 107 114 128; + --gray-600: 156 163 175; + --gray-700: 209 213 219; + --gray-800: 229 231 235; + --gray-900: 243 244 246; /* Dark semantic colors for Tailwind */ - --surface: 31 41 55; - --background: 17 24 39; - --border: 75 85 99; + --surface: 31 35 41; + --background: 42 47 54; + --border: 55 65 81; /* Dark theme Mantine colors */ --color-gray-50: #111827; - --color-gray-100: #1f2937; - --color-gray-200: #374151; - --color-gray-300: #4b5563; - --color-gray-400: #6b7280; - --color-gray-500: #9ca3af; - --color-gray-600: #d1d5db; - --color-gray-700: #e5e7eb; - --color-gray-800: #f3f4f6; - --color-gray-900: #f9fafb; + --color-gray-100: #1F2329; + --color-gray-200: #2A2F36; + --color-gray-300: #374151; + --color-gray-400: #4b5563; + --color-gray-500: #6b7280; + --color-gray-600: #9ca3af; + --color-gray-700: #d1d5db; + --color-gray-800: #e5e7eb; + --color-gray-900: #f3f4f6; /* Dark theme semantic colors */ - --bg-surface: #1f2937; - --bg-raised: #374151; - --bg-muted: #374151; + --bg-surface: #2A2F36; + --bg-raised: #1F2329; + --bg-muted: #1F2329; + --bg-background: #2A2F36; + --bg-toolbar: #272A2E; --text-primary: #f9fafb; --text-secondary: #d1d5db; --text-muted: #9ca3af; - --border-subtle: #374151; - --border-default: #4b5563; - --border-strong: #6b7280; + --border-subtle: #2A2F36; + --border-default: #374151; + --border-strong: #4b5563; --hover-bg: #374151; --active-bg: #4b5563; + /* Icon colors for dark mode */ + --icon-user-bg: #2A2F36; + --icon-user-color: #6E7581; + --icon-notifications-bg: #2A2F36; + --icon-notifications-color: #6E7581; + --icon-tools-bg: #4B525A; + --icon-tools-color: #EAEAEA; + --icon-read-bg: #4B525A; + --icon-read-color: #EAEAEA; + --icon-sign-bg: #4B525A; + --icon-sign-color: #EAEAEA; + --icon-automate-bg: #4B525A; + --icon-automate-color: #EAEAEA; + --icon-files-bg: #4B525A; + --icon-files-color: #EAEAEA; + --icon-activity-bg: #4B525A; + --icon-activity-color: #EAEAEA; + --icon-config-bg: #4B525A; + --icon-config-color: #EAEAEA; + + /* Inactive icon colors for dark mode */ + --icon-inactive-bg: #2A2F36; + --icon-inactive-color: #6E7581; + /* Adjust shadows for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index b06945610..cc0cd5cbc 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useFileContext } from "../contexts/FileContext"; +import { useToolFileSelection } from "../contexts/FileSelectionContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import OperationButton from "../components/tools/shared/OperationButton"; @@ -15,15 +16,12 @@ import CompressSettings from "../components/tools/compress/CompressSettings"; import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters"; import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation"; +import { BaseToolProps } from "../types/tool"; -interface CompressProps { - selectedFiles?: File[]; - onPreviewFile?: (file: File | null) => void; -} - -const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => { +const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { setCurrentMode } = useFileContext(); + const { selectedFiles } = useToolFileSelection(); const compressParams = useCompressParameters(); const compressOperation = useCompressOperation(); @@ -37,10 +35,19 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => { }, [compressParams.parameters, selectedFiles]); const handleCompress = async () => { - await compressOperation.executeOperation( - compressParams.parameters, - selectedFiles - ); + try { + await compressOperation.executeOperation( + compressParams.parameters, + selectedFiles + ); + if (compressOperation.files && onComplete) { + onComplete(compressOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : 'Compress operation failed'); + } + } }; const handleThumbnailClick = (file: File) => { @@ -56,7 +63,7 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => { }; const hasFiles = selectedFiles.length > 0; - const hasResults = compressOperation.downloadUrl !== null; + const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null; const filesCollapsed = hasFiles; const settingsCollapsed = hasResults; @@ -77,7 +84,11 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => { isVisible={true} isCollapsed={filesCollapsed} isCompleted={filesCollapsed} - completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined} + completedMessage={hasFiles ? + selectedFiles.length === 1 + ? `Selected: ${selectedFiles[0].name}` + : `Selected: ${selectedFiles.length} files` + : undefined} > void; -} - -const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => { +const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { setCurrentMode } = useFileContext(); + const { selectedFiles } = useToolFileSelection(); const splitParams = useSplitParameters(); const splitOperation = useSplitOperation(); @@ -39,11 +36,20 @@ const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => { }, [splitParams.mode, splitParams.parameters, selectedFiles]); const handleSplit = async () => { - await splitOperation.executeOperation( - splitParams.mode, - splitParams.parameters, - selectedFiles - ); + try { + await splitOperation.executeOperation( + splitParams.mode, + splitParams.parameters, + selectedFiles + ); + if (splitOperation.files && onComplete) { + onComplete(splitOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : 'Split operation failed'); + } + } }; const handleThumbnailClick = (file: File) => { diff --git a/frontend/src/types/pageEditor.ts b/frontend/src/types/pageEditor.ts index 7e0dda16e..f5529aee3 100644 --- a/frontend/src/types/pageEditor.ts +++ b/frontend/src/types/pageEditor.ts @@ -36,3 +36,19 @@ export interface UndoRedoState { operations: PageOperation[]; currentIndex: number; } + +export interface PageEditorFunctions { + closePdf: () => void; + handleUndo: () => void; + handleRedo: () => void; + canUndo: boolean; + canRedo: boolean; + handleRotate: () => void; + handleDelete: () => void; + handleSplit: () => void; + onExportSelected: () => void; + onExportAll: () => void; + exportLoading: boolean; + selectionMode: boolean; + selectedPages: number[]; +} diff --git a/frontend/src/types/tool.ts b/frontend/src/types/tool.ts new file mode 100644 index 000000000..731f0b90e --- /dev/null +++ b/frontend/src/types/tool.ts @@ -0,0 +1,73 @@ +import React from 'react'; + +export type MaxFiles = number; // 1=single, >1=limited, -1=unlimited +export type ToolCategory = 'manipulation' | 'conversion' | 'analysis' | 'utility' | 'optimization' | 'security'; +export type ToolDefinition = Omit; +export type ToolStepType = 'files' | 'settings' | 'results'; + +export interface BaseToolProps { + onComplete?: (results: File[]) => void; + onError?: (error: string) => void; + onPreviewFile?: (file: File | null) => void; +} + +export interface ToolStepConfig { + type: ToolStepType; + title: string; + isVisible: boolean; + isCompleted: boolean; + isCollapsed?: boolean; + completedMessage?: string; + onCollapsedClick?: () => void; +} + +export interface ToolValidationResult { + valid: boolean; + errors?: string[]; + warnings?: string[]; +} + +export interface ToolResult { + success: boolean; + files?: File[]; + error?: string; + downloadUrl?: string; + metadata?: Record; +} + +export interface Tool { + id: string; + name: string; + icon: React.ReactNode; + component: React.ComponentType; + maxFiles: MaxFiles; + category?: ToolCategory; + description?: string; + endpoints?: string[]; + supportedFormats?: string[]; + validation?: (files: File[]) => ToolValidationResult; +} + +export type ToolRegistry = Record; + +export interface FileSelectionState { + selectedFiles: File[]; + maxFiles: MaxFiles; + isToolMode: boolean; +} + +export interface FileSelectionActions { + setSelectedFiles: (files: File[]) => void; + setMaxFiles: (maxFiles: MaxFiles) => void; + setIsToolMode: (isToolMode: boolean) => void; + clearSelection: () => void; +} + +export interface FileSelectionComputed { + canSelectMore: boolean; + isAtLimit: boolean; + selectionCount: number; + isMultiFileMode: boolean; +} + +export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {} \ No newline at end of file diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index f9d94eecc..bff3f5b1c 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -1,6 +1,10 @@ import { FileWithUrl } from "../types/file"; import { StoredFile, fileStorage } from "../services/fileStorage"; +export function getFileId(file: File): string { + return (file as File & { id?: string }).id || file.name; +} + /** * Consolidated file size formatting utility */