Merge remote-tracking branch 'origin/V2' into feature/react-convert

This commit is contained in:
Connor Yoh 2025-07-28 14:01:09 +01:00
commit a8b370fd52
28 changed files with 1356 additions and 405 deletions

View File

@ -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

View File

@ -7,12 +7,12 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="Web site created using Vite" content="The Free Adobe Acrobat alternative (10M+ Downloads)"
/> />
<link rel="apple-touch-icon" href="/logo192.png" /> <link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>Vite App</title> <title>Stirling PDF</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -27,6 +27,7 @@
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"material-symbols": "^0.33.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"react": "^19.1.0", "react": "^19.1.0",
@ -5092,6 +5093,12 @@
"semver": "bin/semver.js" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@ -23,6 +23,7 @@
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"material-symbols": "^0.33.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"react": "^19.1.0", "react": "^19.1.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -7,6 +7,7 @@ import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile'; import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext } from '../../contexts/FileContext'; import { useFileContext } from '../../contexts/FileContext';
import { useFileSelection } from '../../contexts/FileSelectionContext';
import { FileOperation } from '../../types/fileContext'; import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage'; import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
@ -31,20 +32,16 @@ interface FileEditorProps {
onOpenPageEditor?: (file: File) => void; onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void; onMergeFiles?: (files: File[]) => void;
toolMode?: boolean; toolMode?: boolean;
multiSelect?: boolean;
showUpload?: boolean; showUpload?: boolean;
showBulkActions?: boolean; showBulkActions?: boolean;
onFileSelect?: (files: File[]) => void;
} }
const FileEditor = ({ const FileEditor = ({
onOpenPageEditor, onOpenPageEditor,
onMergeFiles, onMergeFiles,
toolMode = false, toolMode = false,
multiSelect = true,
showUpload = true, showUpload = true,
showBulkActions = true, showBulkActions = true
onFileSelect
}: FileEditorProps) => { }: FileEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -63,6 +60,14 @@ const FileEditor = ({
markOperationApplied markOperationApplied
} = fileContext; } = fileContext;
// Get file selection context
const {
selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles,
maxFiles,
isToolMode
} = useFileSelection();
const [files, setFiles] = useState<FileItem[]>([]); const [files, setFiles] = useState<FileItem[]>([]);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -99,14 +104,14 @@ const FileEditor = ({
const lastActiveFilesRef = useRef<string[]>([]); const lastActiveFilesRef = useRef<string[]>([]);
const lastProcessedFilesRef = useRef<number>(0); const lastProcessedFilesRef = useRef<number>(0);
// Map context selected file names to local file IDs // Get selected file IDs from context (defensive programming)
// Defensive programming: ensure selectedFileIds is always an array const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
const localSelectedFiles = files // Map context selections to local file IDs for UI display
const localSelectedIds = files
.filter(file => { .filter(file => {
const fileId = (file.file as any).id || file.name; const fileId = (file.file as any).id || file.name;
return safeSelectedFileIds.includes(fileId); return contextSelectedIds.includes(fileId);
}) })
.map(file => file.id); .map(file => file.id);
@ -396,44 +401,41 @@ const FileEditor = ({
if (!targetFile) return; if (!targetFile) return;
const contextFileId = (targetFile.file as any).id || targetFile.name; const contextFileId = (targetFile.file as any).id || targetFile.name;
const isSelected = contextSelectedIds.includes(contextFileId);
if (!multiSelect) { let newSelection: string[];
// Single select mode for tools - toggle on/off
const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId); if (isSelected) {
if (isCurrentlySelected) { // Remove file from selection
// Deselect the file newSelection = contextSelectedIds.filter(id => id !== contextFileId);
setContextSelectedFiles([]);
if (onFileSelect) {
onFileSelect([]);
}
} else {
// Select the file
setContextSelectedFiles([contextFileId]);
if (onFileSelect) {
onFileSelect([targetFile.file]);
}
}
} else { } else {
// Multi select mode (default) // Add file to selection
setContextSelectedFiles(prev => { if (maxFiles === 1) {
const safePrev = Array.isArray(prev) ? prev : []; newSelection = [contextFileId];
return safePrev.includes(contextFileId) } else {
? safePrev.filter(id => id !== contextFileId) // Check if we've hit the selection limit
: [...safePrev, contextFileId]; if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) {
}); setStatus(`Maximum ${maxFiles} files can be selected`);
return;
// Notify parent with selected files }
if (onFileSelect) { newSelection = [...contextSelectedIds, contextFileId];
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);
} }
} }
}, [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(() => { const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => { setSelectionMode(prev => {
@ -450,15 +452,15 @@ const FileEditor = ({
const handleDragStart = useCallback((fileId: string) => { const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId); setDraggedFile(fileId);
if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) { if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) {
setMultiFileDrag({ setMultiFileDrag({
fileIds: localSelectedFiles, fileIds: localSelectedIds,
count: localSelectedFiles.length count: localSelectedIds.length
}); });
} else { } else {
setMultiFileDrag(null); setMultiFileDrag(null);
} }
}, [selectionMode, localSelectedFiles]); }, [selectionMode, localSelectedIds]);
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setDraggedFile(null); setDraggedFile(null);
@ -519,8 +521,8 @@ const FileEditor = ({
if (targetIndex === -1) return; if (targetIndex === -1) return;
} }
const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile) const filesToMove = selectionMode && localSelectedIds.includes(draggedFile)
? localSelectedFiles ? localSelectedIds
: [draggedFile]; : [draggedFile];
// Update the local files state and sync with activeFiles // Update the local files state and sync with activeFiles
@ -545,7 +547,7 @@ const FileEditor = ({
const moveCount = multiFileDrag ? multiFileDrag.count : 1; const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]); }, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]);
const handleEndZoneDragEnter = useCallback(() => { const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) { if (draggedFile) {
@ -764,7 +766,7 @@ const FileEditor = ({
) : ( ) : (
<DragDropGrid <DragDropGrid
items={files} items={files}
selectedItems={localSelectedFiles} selectedItems={localSelectedIds}
selectionMode={selectionMode} selectionMode={selectionMode}
isAnimating={isAnimating} isAnimating={isAnimating}
onDragStart={handleDragStart} onDragStart={handleDragStart}
@ -783,7 +785,7 @@ const FileEditor = ({
file={file} file={file}
index={index} index={index}
totalFiles={files.length} totalFiles={files.length}
selectedFiles={localSelectedFiles} selectedFiles={localSelectedIds}
selectionMode={selectionMode} selectionMode={selectionMode}
draggedFile={draggedFile} draggedFile={draggedFile}
dropTarget={dropTarget} dropTarget={dropTarget}

View File

@ -0,0 +1,179 @@
.activeIconScale {
transform: scale(1.3);
transition: transform 0.2s;
z-index: 1;
}
.iconContainer {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
}
/* Action icon styles */
.action-icon-style {
background-color: var(--icon-user-bg);
color: var(--icon-user-color);
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
}
/* Main container styles */
.quick-access-bar-main {
background-color: var(--bg-muted);
width: 5rem;
min-width: 5rem;
max-width: 5rem;
position: relative;
z-index: 10;
}
/* Rainbow mode container */
.quick-access-bar-main.rainbow-mode {
background-color: var(--bg-muted);
width: 5rem;
min-width: 5rem;
max-width: 5rem;
position: relative;
z-index: 10;
}
/* Header padding */
.quick-access-header {
padding: 1rem 0.5rem 0.5rem 0.5rem;
}
.nav-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 0;
gap: 0.5rem;
}
/* Nav header divider */
.nav-header-divider {
width: 3.75rem;
border-color: var(--color-gray-300);
margin-top: 0.5rem;
margin-bottom: 1rem;
}
/* All tools text styles */
.all-tools-text {
margin-top: 0.75rem;
font-size: 0.75rem;
text-rendering: optimizeLegibility;
font-synthesis: none;
}
.all-tools-text.active {
color: var(--text-primary);
font-weight: bold;
}
.all-tools-text.inactive {
color: var(--color-gray-700);
font-weight: normal;
}
/* Overflow divider */
.overflow-divider {
width: 3.75rem;
border-color: var(--color-gray-300);
margin: 0 0.5rem;
}
/* Scrollable content area */
.quick-access-bar {
overflow-x: auto;
overflow-y: auto;
scrollbar-gutter: stable both-edges;
-webkit-overflow-scrolling: touch;
padding: 0 0.5rem 1rem 0.5rem;
}
/* Scrollable content container */
.scrollable-content {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
}
/* Button text styles */
.button-text {
margin-top: 0.75rem;
font-size: 0.75rem;
text-rendering: optimizeLegibility;
font-synthesis: none;
}
.button-text.active {
color: var(--text-primary);
font-weight: bold;
}
.button-text.inactive {
color: var(--color-gray-700);
font-weight: normal;
}
/* Content divider */
.content-divider {
width: 3.75rem;
border-color: var(--color-gray-300);
}
/* Spacer */
.spacer {
flex: 1;
margin-top: 1rem;
}
/* Config button text */
.config-button-text {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--color-gray-700);
font-weight: normal;
text-rendering: optimizeLegibility;
font-synthesis: none;
}
/* Font size utility */
.font-size-20 {
font-size: 20px;
}
/* Hide scrollbar by default, show on scroll (Webkit browsers - Chrome, Safari, Edge) */
.quick-access-bar::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
background: transparent;
}
.quick-access-bar:hover::-webkit-scrollbar,
.quick-access-bar:active::-webkit-scrollbar,
.quick-access-bar:focus::-webkit-scrollbar {
background: rgba(0, 0, 0, 0.1);
}
.quick-access-bar::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 0.25rem;
}
.quick-access-bar::-webkit-scrollbar-track {
background: transparent;
}
/* Firefox scrollbar styling */
.quick-access-bar {
scrollbar-width: auto;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}

View File

@ -1,11 +1,17 @@
import React, { useState } from "react"; import React, { useState, useRef } from "react";
import { ActionIcon, Stack, Tooltip } from "@mantine/core"; import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core";
import MenuBookIcon from "@mui/icons-material/MenuBook"; import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
import AppsIcon from "@mui/icons-material/Apps"; import AppsIcon from "@mui/icons-material/AppsRounded";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/SettingsRounded";
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesomeRounded";
import FolderIcon from "@mui/icons-material/FolderRounded";
import PersonIcon from "@mui/icons-material/PersonRounded";
import NotificationsIcon from "@mui/icons-material/NotificationsRounded";
import { useRainbowThemeContext } from "./RainbowThemeProvider"; import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css'; import rainbowStyles from '../../styles/rainbow.module.css';
import AppConfigModal from './AppConfigModal'; import AppConfigModal from './AppConfigModal';
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
import './QuickAccessBar.css';
interface QuickAccessBarProps { interface QuickAccessBarProps {
onToolsClick: () => void; onToolsClick: () => void;
@ -16,6 +22,86 @@ interface QuickAccessBarProps {
readerMode: boolean; 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 (
<>
<div className="nav-header">
<Tooltip label="User Profile" position="right">
<ActionIcon
size="md"
variant="subtle"
className="action-icon-style"
>
<PersonIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Notifications" position="right">
<ActionIcon
size="md"
variant="subtle"
className="action-icon-style"
>
<NotificationsIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</div>
{/* Divider after top icons */}
<Divider
size="xs"
className="nav-header-divider"
/>
{/* All Tools button below divider */}
<Tooltip label="View all available tools" position="right">
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon
size="lg"
variant="subtle"
onClick={() => {
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' : ''}
>
<span className="iconContainer">
<AppsIcon sx={{ fontSize: "1.75rem" }} />
</span>
</ActionIcon>
<span className={`all-tools-text ${activeButton === 'tools' ? 'active' : 'inactive'}`}>
All Tools
</span>
</div>
</Tooltip>
</>
);
}
const QuickAccessBar = ({ const QuickAccessBar = ({
onToolsClick, onToolsClick,
onReaderToggle, onReaderToggle,
@ -26,55 +112,201 @@ const QuickAccessBar = ({
}: QuickAccessBarProps) => { }: QuickAccessBarProps) => {
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const [configModalOpen, setConfigModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
const isOverflow = useIsOverflowing(scrollableRef);
const buttonConfigs: ButtonConfig[] = [
{
id: 'read',
name: 'Read',
icon: <MenuBookIcon sx={{ fontSize: "1.5rem" }} />,
tooltip: 'Read documents',
size: 'lg',
isRound: false,
onClick: () => {
setActiveButton('read');
onReaderToggle();
}
},
{
id: 'sign',
name: 'Sign',
icon:
<span className="material-symbols-rounded font-size-20">
signature
</span>,
tooltip: 'Sign your document',
size: 'lg',
isRound: false,
onClick: () => setActiveButton('sign')
},
{
id: 'automate',
name: 'Automate',
icon: <AutoAwesomeIcon sx={{ fontSize: "1.5rem" }} />,
tooltip: 'Automate workflows',
size: 'lg',
isRound: false,
onClick: () => setActiveButton('automate')
},
{
id: 'files',
name: 'Files',
icon: <FolderIcon sx={{ fontSize: "1.5rem" }} />,
tooltip: 'Manage files',
isRound: true,
size: 'lg',
onClick: () => setActiveButton('files')
},
{
id: 'activity',
name: 'Activity',
icon:
<span className="material-symbols-rounded font-size-20">
vital_signs
</span>,
tooltip: 'View activity and analytics',
isRound: true,
size: 'lg',
onClick: () => setActiveButton('activity')
},
{
id: 'config',
name: 'Config',
icon: <SettingsIcon sx={{ fontSize: "1rem" }} />,
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 ( return (
<div <div
className={`h-screen flex flex-col w-20 ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`} className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
style={{
padding: '1rem 0.5rem',
backgroundColor: 'var(--bg-muted)'
}}
> >
<Stack gap="lg" align="center" className="flex-1"> {/* Fixed header outside scrollable area */}
{/* All Tools Button */} <div className="quick-access-header">
<div className="flex flex-col items-center gap-1"> <NavHeader
<ActionIcon activeButton={activeButton}
size="xl" setActiveButton={setActiveButton}
variant={leftPanelView === 'toolPicker' && !readerMode ? "filled" : "subtle"} onReaderToggle={onReaderToggle}
onClick={onToolsClick} onToolsClick={onToolsClick}
> />
<AppsIcon sx={{ fontSize: 28 }} /> </div>
</ActionIcon>
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Tools</span>
</div>
{/* Reader Mode Button */} {/* Conditional divider when overflowing */}
<div className="flex flex-col items-center gap-1"> {isOverflow && (
<ActionIcon <Divider
size="xl" size="xs"
variant={readerMode ? "filled" : "subtle"} className="overflow-divider"
onClick={onReaderToggle} />
> )}
<MenuBookIcon sx={{ fontSize: 28 }} />
</ActionIcon>
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Read</span>
</div>
{/* Spacer */} {/* Scrollable content area */}
<div className="flex-1" /> <div
ref={scrollableRef}
{/* Config Modal Button (for testing) */} className="quick-access-bar flex-1"
<div className="flex flex-col items-center gap-1"> onWheel={(e) => {
<ActionIcon // Prevent the wheel event from bubbling up to parent containers
size="lg" e.stopPropagation();
variant="subtle" }}
onClick={() => setConfigModalOpen(true)} >
> <div className="scrollable-content">
<SettingsIcon sx={{ fontSize: 20 }} /> {/* Top section with main buttons */}
</ActionIcon> <Stack gap="lg" align="center">
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Config</span> {buttonConfigs.slice(0, -1).map((config, index) => (
<React.Fragment key={config.id}>
<Tooltip label={config.tooltip} position="right">
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
size={config.size || 'xl'}
variant="subtle"
onClick={config.onClick}
style={getButtonStyle(config)}
className={activeButton === config.id ? 'activeIconScale' : ''}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${activeButton === config.id ? 'active' : 'inactive'}`}>
{config.name}
</span>
</div>
</Tooltip>
{/* Add divider after Automate button (index 2) */}
{index === 2 && (
<Divider
size="xs"
className="content-divider"
/>
)}
</React.Fragment>
))}
</Stack>
{/* Spacer to push Config button to bottom */}
<div className="spacer" />
{/* Config button at the bottom */}
<Tooltip label="Configure settings" position="right">
<div className="flex flex-col items-center gap-1">
<ActionIcon
size="lg"
variant="subtle"
onClick={() => {
setConfigModalOpen(true);
}}
style={{
backgroundColor: 'var(--icon-inactive-bg)',
color: 'var(--icon-inactive-color)',
border: 'none',
borderRadius: '8px',
}}
>
<span className="iconContainer">
<SettingsIcon sx={{ fontSize: "1rem" }} />
</span>
</ActionIcon>
<span className="config-button-text">
Config
</span>
</div>
</Tooltip>
</div> </div>
</Stack> </div>
<AppConfigModal <AppConfigModal
opened={configModalOpen} opened={configModalOpen}

View File

@ -0,0 +1,14 @@
import { Center, Stack, Loader, Text } from "@mantine/core";
export default function ToolLoadingFallback({ toolName }: { toolName?: string }) {
return (
<Center h="100%" w="100%">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed" size="sm">
{toolName ? `Loading ${toolName}...` : "Loading tool..."}
</Text>
</Stack>
</Center>
)
}

View File

@ -1,15 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core"; import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ToolRegistry } from "../../types/tool";
type Tool = {
icon: React.ReactNode;
name: string;
};
type ToolRegistry = {
[id: string]: Tool;
};
interface ToolPickerProps { interface ToolPickerProps {
selectedToolKey: string | null; selectedToolKey: string | null;

View File

@ -1,23 +1,18 @@
import { FileWithUrl } from "../../types/file"; import React, { Suspense } from "react";
import { useToolManagement } from "../../hooks/useToolManagement"; import { useToolManagement } from "../../hooks/useToolManagement";
import { BaseToolProps } from "../../types/tool";
import ToolLoadingFallback from "./ToolLoadingFallback";
interface ToolRendererProps { interface ToolRendererProps extends BaseToolProps {
selectedToolKey: string; selectedToolKey: string;
pdfFile: any;
files: FileWithUrl[];
toolParams: any;
updateParams: (params: any) => void;
toolSelectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
} }
const ToolRenderer = ({ const ToolRenderer = ({
selectedToolKey, selectedToolKey,
files,
toolParams,
updateParams,
toolSelectedFiles = [],
onPreviewFile, onPreviewFile,
onComplete,
onError,
}: ToolRendererProps) => { }: ToolRendererProps) => {
// Get the tool from registry // Get the tool from registry
const { toolRegistry } = useToolManagement(); const { toolRegistry } = useToolManagement();
@ -29,46 +24,16 @@ files,
const ToolComponent = selectedTool.component; const ToolComponent = selectedTool.component;
// Pass tool-specific props // Wrap lazy-loaded component with Suspense
switch (selectedToolKey) { return (
case "split": <Suspense fallback={<ToolLoadingFallback toolName={selectedTool.name} />}>
return ( <ToolComponent
<ToolComponent onPreviewFile={onPreviewFile}
selectedFiles={toolSelectedFiles} onComplete={onComplete}
onPreviewFile={onPreviewFile} onError={onError}
/> />
); </Suspense>
case "compress": );
return (
<ToolComponent
selectedFiles={toolSelectedFiles}
onPreviewFile={onPreviewFile}
/>
);
case "convert":
return (
<ToolComponent
selectedFiles={toolSelectedFiles}
onPreviewFile={onPreviewFile}
/>
);
case "merge":
return (
<ToolComponent
files={files}
params={toolParams}
updateParams={updateParams}
/>
);
default:
return (
<ToolComponent
files={files}
params={toolParams}
updateParams={updateParams}
/>
);
}
}; };
export default ToolRenderer; export default ToolRenderer;

View File

@ -22,6 +22,7 @@ import { useEnhancedProcessedFiles } from '../hooks/useEnhancedProcessedFiles';
import { fileStorage } from '../services/fileStorage'; import { fileStorage } from '../services/fileStorage';
import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { getFileId } from '../utils/fileUtils';
// Initial state // Initial state
const initialViewerConfig: ViewerConfig = { const initialViewerConfig: ViewerConfig = {
@ -98,7 +99,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
case 'REMOVE_FILES': case 'REMOVE_FILES':
const remainingFiles = state.activeFiles.filter(file => { const remainingFiles = state.activeFiles.filter(file => {
const fileId = (file as any).id || file.name; const fileId = getFileId(file);
return !action.payload.includes(fileId); return !action.payload.includes(fileId);
}); });
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : []; const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
@ -347,7 +348,7 @@ export function FileContextProvider({
// Cleanup timers and refs // Cleanup timers and refs
const cleanupTimers = useRef<Map<string, NodeJS.Timeout>>(new Map()); const cleanupTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
const blobUrls = useRef<Set<string>>(new Set()); const blobUrls = useRef<Set<string>>(new Set());
const pdfDocuments = useRef<Map<string, any>>(new Map()); const pdfDocuments = useRef<Map<string, PDFDocument>>(new Map());
// Enhanced file processing hook // Enhanced file processing hook
const { const {
@ -381,7 +382,7 @@ export function FileContextProvider({
blobUrls.current.add(url); 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 // Clean up existing document for this file if any
const existing = pdfDocuments.current.get(fileId); const existing = pdfDocuments.current.get(fileId);
if (existing && existing.destroy) { if (existing && existing.destroy) {
@ -498,7 +499,7 @@ export function FileContextProvider({
for (const file of files) { for (const file of files) {
try { try {
// Check if file already has an ID (already in IndexedDB) // Check if file already has an ID (already in IndexedDB)
const fileId = (file as any).id; const fileId = getFileId(file);
if (!fileId) { if (!fileId) {
// File doesn't have ID, store it and get the ID // File doesn't have ID, store it and get the ID
const storedFile = await fileStorage.storeFile(file); const storedFile = await fileStorage.storeFile(file);
@ -680,7 +681,7 @@ export function FileContextProvider({
// Utility functions // Utility functions
const getFileById = useCallback((fileId: string): File | undefined => { const getFileById = useCallback((fileId: string): File | undefined => {
return state.activeFiles.find(file => { return state.activeFiles.find(file => {
const actualFileId = (file as any).id || file.name; const actualFileId = getFileId(file);
return actualFileId === fileId; return actualFileId === fileId;
}); });
}, [state.activeFiles]); }, [state.activeFiles]);

View File

@ -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<FileSelectionContextValue | undefined>(undefined);
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
const [isToolMode, setIsToolMode] = useState<boolean>(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 (
<FileSelectionContext.Provider value={contextValue}>
{children}
</FileSelectionContext.Provider>
);
}
/**
* Access the file selection context.
* Throws if used outside a <FileSelectionProvider>.
*/
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<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'canSelectMore' | 'isAtLimit' | 'selectionCount'> {
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<FileSelectionContextValue, 'setSelectedFiles' | 'clearSelection' | 'setMaxFiles' | 'setIsToolMode'> {
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<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'isToolMode'> {
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<FileSelectionContextValue, 'canSelectMore' | 'isAtLimit' | 'selectionCount' | 'isMultiFileMode'> {
const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection();
return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode };
}

View File

@ -4,3 +4,4 @@ declare module "../tools/Merge";
declare module "../components/PageEditor"; declare module "../components/PageEditor";
declare module "../components/Viewer"; declare module "../components/Viewer";
declare module "*.js"; declare module "*.js";
declare module '*.module.css';

View File

@ -20,7 +20,7 @@ export interface CompressOperationHook {
parameters: CompressParameters, parameters: CompressParameters,
selectedFiles: File[] selectedFiles: File[]
) => Promise<void>; ) => Promise<void>;
// Flattened result properties for cleaner access // Flattened result properties for cleaner access
files: File[]; files: File[];
thumbnails: string[]; thumbnails: string[];
@ -30,7 +30,7 @@ export interface CompressOperationHook {
status: string; status: string;
errorMessage: string | null; errorMessage: string | null;
isLoading: boolean; isLoading: boolean;
// Result management functions // Result management functions
resetResults: () => void; resetResults: () => void;
clearError: () => void; clearError: () => void;
@ -38,13 +38,13 @@ export interface CompressOperationHook {
export const useCompressOperation = (): CompressOperationHook => { export const useCompressOperation = (): CompressOperationHook => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
recordOperation, recordOperation,
markOperationApplied, markOperationApplied,
markOperationFailed, markOperationFailed,
addFiles addFiles
} = useFileContext(); } = useFileContext();
// Internal state management // Internal state management
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [thumbnails, setThumbnails] = useState<string[]>([]); const [thumbnails, setThumbnails] = useState<string[]>([]);
@ -55,15 +55,27 @@ export const useCompressOperation = (): CompressOperationHook => {
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Track blob URLs for cleanup
const [blobUrls, setBlobUrls] = useState<string[]>([]);
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(( const buildFormData = useCallback((
parameters: CompressParameters, parameters: CompressParameters,
selectedFiles: File[] file: File
) => { ) => {
const formData = new FormData(); const formData = new FormData();
selectedFiles.forEach(file => { formData.append("fileInput", file);
formData.append("fileInput", file);
});
if (parameters.compressionMethod === 'quality') { if (parameters.compressionMethod === 'quality') {
formData.append("optimizeLevel", parameters.compressionLevel.toString()); formData.append("optimizeLevel", parameters.compressionLevel.toString());
@ -74,7 +86,7 @@ export const useCompressOperation = (): CompressOperationHook => {
formData.append("expectedOutputSize", fileSize); formData.append("expectedOutputSize", fileSize);
} }
} }
formData.append("grayscale", parameters.grayscale.toString()); formData.append("grayscale", parameters.grayscale.toString());
const endpoint = "/api/v1/misc/compress-pdf"; const endpoint = "/api/v1/misc/compress-pdf";
@ -87,7 +99,7 @@ export const useCompressOperation = (): CompressOperationHook => {
selectedFiles: File[] selectedFiles: File[]
): { operation: FileOperation; operationId: string; fileId: string } => { ): { operation: FileOperation; operationId: string; fileId: string } => {
const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 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 = { const operation: FileOperation = {
id: operationId, id: operationId,
@ -96,74 +108,20 @@ export const useCompressOperation = (): CompressOperationHook => {
fileIds: selectedFiles.map(f => f.name), fileIds: selectedFiles.map(f => f.name),
status: 'pending', status: 'pending',
metadata: { metadata: {
originalFileName: selectedFiles[0].name, originalFileNames: selectedFiles.map(f => f.name),
parameters: { parameters: {
compressionLevel: parameters.compressionLevel, compressionLevel: parameters.compressionLevel,
grayscale: parameters.grayscale, grayscale: parameters.grayscale,
expectedSize: parameters.expectedSize, expectedSize: parameters.expectedSize,
}, },
fileSize: selectedFiles[0].size totalFileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0),
fileCount: selectedFiles.length
} }
}; };
return { operation, operationId, fileId }; 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 ( const executeOperation = useCallback(async (
parameters: CompressParameters, parameters: CompressParameters,
@ -173,32 +131,93 @@ export const useCompressOperation = (): CompressOperationHook => {
setStatus(t("noFileSelected")); setStatus(t("noFileSelected"));
return; 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 { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
const { formData, endpoint } = buildFormData(parameters, selectedFiles);
recordOperation(fileId, operation); recordOperation(fileId, operation);
setStatus(t("loading")); setStatus(t("loading"));
setIsLoading(true); setIsLoading(true);
setErrorMessage(null); setErrorMessage(null);
setFiles([]);
setThumbnails([]);
try { try {
const response = await axios.post(endpoint, formData, { responseType: "blob" }); const compressedFiles: File[] = [];
// 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); 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); markOperationApplied(fileId, operationId);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@ -214,9 +233,10 @@ export const useCompressOperation = (): CompressOperationHook => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]); }, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, addFiles]);
const resetResults = useCallback(() => { const resetResults = useCallback(() => {
cleanupBlobUrls();
setFiles([]); setFiles([]);
setThumbnails([]); setThumbnails([]);
setIsGeneratingThumbnails(false); setIsGeneratingThumbnails(false);
@ -224,7 +244,7 @@ export const useCompressOperation = (): CompressOperationHook => {
setStatus(''); setStatus('');
setErrorMessage(null); setErrorMessage(null);
setIsLoading(false); setIsLoading(false);
}, []); }, [cleanupBlobUrls]);
const clearError = useCallback(() => { const clearError = useCallback(() => {
setErrorMessage(null); setErrorMessage(null);
@ -232,8 +252,6 @@ export const useCompressOperation = (): CompressOperationHook => {
return { return {
executeOperation, executeOperation,
// Flattened result properties for cleaner access
files, files,
thumbnails, thumbnails,
isGeneratingThumbnails, isGeneratingThumbnails,
@ -242,9 +260,9 @@ export const useCompressOperation = (): CompressOperationHook => {
status, status,
errorMessage, errorMessage,
isLoading, isLoading,
// Result management functions // Result management functions
resetResults, resetResults,
clearError, clearError,
}; };
}; };

View File

@ -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<HTMLDivElement>(null);
const isOverflow = useIsOverflowing(scrollableRef);
Fallback example (for browsers without ResizeObserver):
return (
<div ref={scrollableRef} className="h-64 overflow-y-auto">
{Content that might overflow}
</div>
);
*/
export const useIsOverflowing = (ref: React.RefObject<HTMLElement | null>, callback?: (isOverflow: boolean) => void) => {
// State to track overflow status
const [isOverflow, setIsOverflow] = React.useState<boolean | undefined>(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;
};

View File

@ -1,68 +1,74 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react'; import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
import ContentCutIcon from "@mui/icons-material/ContentCut"; import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; 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 { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
// Add entry here with maxFiles, endpoints, and lazy component
const toolDefinitions: Record<string, ToolDefinition> = {
split: {
id: "split",
icon: <ContentCutIcon />,
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: <ZoomInMapIcon />,
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<any>;
view: string;
}; };
type ToolRegistry = { interface ToolManagementResult {
[key: string]: ToolRegistryEntry; selectedToolKey: string | null;
}; selectedTool: Tool | null;
toolSelectedFileIds: string[];
toolRegistry: ToolRegistry;
selectTool: (toolKey: string) => void;
clearToolSelection: () => void;
setToolSelectedFileIds: (fileIds: string[]) => void;
}
const baseToolRegistry = { export const useToolManagement = (): ToolManagementResult => {
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "split" },
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "compress" },
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "pageEditor" },
convert: { icon: <SwapHorizIcon />, component: ConvertPanel, view: "convert" },
};
// Tool endpoint mappings
const toolEndpoints: Record<string, string[]> = {
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 = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null); const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]); const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
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 { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => { const isToolAvailable = useCallback((toolKey: string): boolean => {
if (endpointsLoading) return true; if (endpointsLoading) return true;
const endpoints = toolEndpoints[toolKey] || []; const tool = toolDefinitions[toolKey];
return endpoints.some(endpoint => endpointStatus[endpoint] === true); if (!tool?.endpoints) return true;
return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true);
}, [endpointsLoading, endpointStatus]); }, [endpointsLoading, endpointStatus]);
const toolRegistry: ToolRegistry = useMemo(() => { const toolRegistry: ToolRegistry = useMemo(() => {
const availableToolRegistry: ToolRegistry = {}; const availableTools: ToolRegistry = {};
Object.keys(baseToolRegistry).forEach(toolKey => { Object.keys(toolDefinitions).forEach(toolKey => {
if (isToolAvailable(toolKey)) { if (isToolAvailable(toolKey)) {
availableToolRegistry[toolKey] = { const toolDef = toolDefinitions[toolKey];
...baseToolRegistry[toolKey as keyof typeof baseToolRegistry], availableTools[toolKey] = {
...toolDef,
name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1)) name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1))
}; };
} }
}); });
return availableToolRegistry; return availableTools;
}, [t, isToolAvailable]); }, [t, isToolAvailable]);
useEffect(() => { useEffect(() => {

View File

@ -1,3 +1,9 @@
@import 'material-symbols/rounded.css';
.material-symbols-rounded {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

View File

@ -1,9 +1,11 @@
import React, { useState, useCallback} from "react"; import React, { useState, useCallback, useEffect} from "react";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
import { useToolManagement } from "../hooks/useToolManagement"; import { useToolManagement } from "../hooks/useToolManagement";
import { Group, Box, Button, Container } from "@mantine/core"; import { Group, Box, Button, Container } from "@mantine/core";
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
import { PageEditorFunctions } from "../types/pageEditor";
import rainbowStyles from '../styles/rainbow.module.css'; import rainbowStyles from '../styles/rainbow.module.css';
import ToolPicker from "../components/tools/ToolPicker"; import ToolPicker from "../components/tools/ToolPicker";
@ -15,45 +17,50 @@ import Viewer from "../components/viewer/Viewer";
import FileUploadSelector from "../components/shared/FileUploadSelector"; import FileUploadSelector from "../components/shared/FileUploadSelector";
import ToolRenderer from "../components/tools/ToolRenderer"; import ToolRenderer from "../components/tools/ToolRenderer";
import QuickAccessBar from "../components/shared/QuickAccessBar"; import QuickAccessBar from "../components/shared/QuickAccessBar";
import { useMultipleEndpointsEnabled } from "../hooks/useEndpointConfig";
export default function HomePage() { function HomePageContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
// Get file context
const fileContext = useFileContext(); const fileContext = useFileContext();
const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext; const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext;
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
const { const {
selectedToolKey, selectedToolKey,
selectedTool, selectedTool,
toolParams,
toolRegistry, toolRegistry,
selectTool, selectTool,
clearToolSelection, clearToolSelection,
updateToolParams,
} = useToolManagement(); } = useToolManagement();
const [toolSelectedFiles, setToolSelectedFiles] = useState<File[]>([]);
const [sidebarsVisible, setSidebarsVisible] = useState(true); const [sidebarsVisible, setSidebarsVisible] = useState(true);
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
const [readerMode, setReaderMode] = useState(false); const [readerMode, setReaderMode] = useState(false);
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null); const [pageEditorFunctions, setPageEditorFunctions] = useState<PageEditorFunctions | null>(null);
const [previewFile, setPreviewFile] = useState<File | null>(null); const [previewFile, setPreviewFile] = useState<File | null>(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( const handleToolSelect = useCallback(
(id: string) => { (id: string) => {
selectTool(id); selectTool(id);
if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view); setCurrentView('fileEditor'); // Tools use fileEditor view for file selection
setLeftPanelView('toolContent'); setLeftPanelView('toolContent');
setReaderMode(false); setReaderMode(false);
}, },
[selectTool, toolRegistry, setCurrentView] [selectTool, setCurrentView]
); );
const handleQuickAccessTools = useCallback(() => { const handleQuickAccessTools = useCallback(() => {
@ -63,7 +70,7 @@ export default function HomePage() {
}, [clearToolSelection]); }, [clearToolSelection]);
const handleReaderToggle = useCallback(() => { const handleReaderToggle = useCallback(() => {
setReaderMode(!readerMode); setReaderMode(true);
}, [readerMode]); }, [readerMode]);
const handleViewChange = useCallback((view: string) => { const handleViewChange = useCallback((view: string) => {
@ -97,7 +104,7 @@ export default function HomePage() {
{/* Left: Tool Picker or Selected Tool Panel */} {/* Left: Tool Picker or Selected Tool Panel */}
<div <div
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 : ''}`} className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
style={{ style={{
width: sidebarsVisible && !readerMode ? '14vw' : '0', width: sidebarsVisible && !readerMode ? '14vw' : '0',
padding: sidebarsVisible && !readerMode ? '0.5rem' : '0' padding: sidebarsVisible && !readerMode ? '0.5rem' : '0'
@ -145,7 +152,6 @@ export default function HomePage() {
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<ToolRenderer <ToolRenderer
selectedToolKey={selectedToolKey} selectedToolKey={selectedToolKey}
toolSelectedFiles={toolSelectedFiles}
onPreviewFile={setPreviewFile} onPreviewFile={setPreviewFile}
/> />
</div> </div>
@ -157,9 +163,11 @@ export default function HomePage() {
{/* Main View */} {/* Main View */}
<Box <Box
className="flex-1 h-screen min-w-80 relative flex flex-col" className="flex-1 h-screen min-w-80 relative flex flex-col"
style={{ style={
backgroundColor: 'var(--bg-background)' isRainbowMode
}} ? {} // No background color in rainbow mode
: { backgroundColor: 'var(--bg-background)' }
}
> >
{/* Top Controls */} {/* Top Controls */}
<TopControls <TopControls
@ -196,14 +204,18 @@ export default function HomePage() {
</Container> </Container>
) : currentView === "fileEditor" ? ( ) : currentView === "fileEditor" ? (
<FileEditor <FileEditor
onOpenPageEditor={(file) => { toolMode={!!selectedToolKey}
handleViewChange("pageEditor"); showUpload={true}
}} showBulkActions={!selectedToolKey}
onMergeFiles={(filesToMerge) => { {...(!selectedToolKey && {
// Add merged files to active set onOpenPageEditor: (file) => {
filesToMerge.forEach(addToActiveFiles); handleViewChange("pageEditor");
handleViewChange("viewer"); },
}} onMergeFiles: (filesToMerge) => {
filesToMerge.forEach(addToActiveFiles);
handleViewChange("viewer");
}
})}
/> />
) : currentView === "viewer" ? ( ) : currentView === "viewer" ? (
<Viewer <Viewer
@ -258,37 +270,8 @@ export default function HomePage() {
/> />
)} )}
</> </>
) : currentView === "split" ? (
<FileEditor
toolMode={true}
multiSelect={false}
showUpload={true}
showBulkActions={true}
onFileSelect={(files) => {
setToolSelectedFiles(files);
}}
/>
) : currentView === "compress" ? (
<FileEditor
toolMode={true}
multiSelect={false} // TODO: make this work with multiple files
showUpload={true}
showBulkActions={true}
onFileSelect={(files) => {
setToolSelectedFiles(files);
}}
/>
) : currentView === "convert" ? (
<FileEditor
toolMode={true}
multiSelect={false}
showUpload={true}
showBulkActions={true}
onFileSelect={(files) => {
setToolSelectedFiles(files);
}}
/>
) : selectedToolKey && selectedTool ? ( ) : selectedToolKey && selectedTool ? (
// Fallback: if tool is selected but not in fileEditor view, show tool in main area
<ToolRenderer <ToolRenderer
selectedToolKey={selectedToolKey} selectedToolKey={selectedToolKey}
/> />
@ -315,3 +298,12 @@ export default function HomePage() {
</Group> </Group>
); );
} }
// Main HomePage component wrapped with FileSelectionProvider
export default function HomePage() {
return (
<FileSelectionProvider>
<HomePageContent />
</FileSelectionProvider>
);
}

View File

@ -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 * Extract PDF files from a ZIP archive
*/ */

View File

@ -72,6 +72,8 @@
--bg-surface: #ffffff; --bg-surface: #ffffff;
--bg-raised: #f9fafb; --bg-raised: #f9fafb;
--bg-muted: #f3f4f6; --bg-muted: #f3f4f6;
--bg-background: #f9fafb;
--bg-toolbar: #ffffff;
--text-primary: #111827; --text-primary: #111827;
--text-secondary: #4b5563; --text-secondary: #4b5563;
--text-muted: #6b7280; --text-muted: #6b7280;
@ -80,51 +82,101 @@
--border-strong: #9ca3af; --border-strong: #9ca3af;
--hover-bg: #f9fafb; --hover-bg: #f9fafb;
--active-bg: #f3f4f6; --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"] { [data-mantine-color-scheme="dark"] {
/* Dark theme gray scale (inverted) */ /* Dark theme gray scale (inverted) */
--gray-50: 17 24 39; --gray-50: 17 24 39;
--gray-100: 31 41 55; --gray-100: 31 35 41;
--gray-200: 55 65 81; --gray-200: 42 47 54;
--gray-300: 75 85 99; --gray-300: 55 65 81;
--gray-400: 107 114 128; --gray-400: 75 85 99;
--gray-500: 156 163 175; --gray-500: 107 114 128;
--gray-600: 209 213 219; --gray-600: 156 163 175;
--gray-700: 229 231 235; --gray-700: 209 213 219;
--gray-800: 243 244 246; --gray-800: 229 231 235;
--gray-900: 249 250 251; --gray-900: 243 244 246;
/* Dark semantic colors for Tailwind */ /* Dark semantic colors for Tailwind */
--surface: 31 41 55; --surface: 31 35 41;
--background: 17 24 39; --background: 42 47 54;
--border: 75 85 99; --border: 55 65 81;
/* Dark theme Mantine colors */ /* Dark theme Mantine colors */
--color-gray-50: #111827; --color-gray-50: #111827;
--color-gray-100: #1f2937; --color-gray-100: #1F2329;
--color-gray-200: #374151; --color-gray-200: #2A2F36;
--color-gray-300: #4b5563; --color-gray-300: #374151;
--color-gray-400: #6b7280; --color-gray-400: #4b5563;
--color-gray-500: #9ca3af; --color-gray-500: #6b7280;
--color-gray-600: #d1d5db; --color-gray-600: #9ca3af;
--color-gray-700: #e5e7eb; --color-gray-700: #d1d5db;
--color-gray-800: #f3f4f6; --color-gray-800: #e5e7eb;
--color-gray-900: #f9fafb; --color-gray-900: #f3f4f6;
/* Dark theme semantic colors */ /* Dark theme semantic colors */
--bg-surface: #1f2937; --bg-surface: #2A2F36;
--bg-raised: #374151; --bg-raised: #1F2329;
--bg-muted: #374151; --bg-muted: #1F2329;
--bg-background: #2A2F36;
--bg-toolbar: #272A2E;
--text-primary: #f9fafb; --text-primary: #f9fafb;
--text-secondary: #d1d5db; --text-secondary: #d1d5db;
--text-muted: #9ca3af; --text-muted: #9ca3af;
--border-subtle: #374151; --border-subtle: #2A2F36;
--border-default: #4b5563; --border-default: #374151;
--border-strong: #6b7280; --border-strong: #4b5563;
--hover-bg: #374151; --hover-bg: #374151;
--active-bg: #4b5563; --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 */ /* Adjust shadows for dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);

View File

@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; 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 { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation"; import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
import { BaseToolProps } from "../types/tool";
interface CompressProps { const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
selectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
}
const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setCurrentMode } = useFileContext(); const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const compressParams = useCompressParameters(); const compressParams = useCompressParameters();
const compressOperation = useCompressOperation(); const compressOperation = useCompressOperation();
@ -37,10 +35,19 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => {
}, [compressParams.parameters, selectedFiles]); }, [compressParams.parameters, selectedFiles]);
const handleCompress = async () => { const handleCompress = async () => {
await compressOperation.executeOperation( try {
compressParams.parameters, await compressOperation.executeOperation(
selectedFiles 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) => { const handleThumbnailClick = (file: File) => {
@ -56,7 +63,7 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => {
}; };
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const hasResults = compressOperation.downloadUrl !== null; const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
const filesCollapsed = hasFiles; const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults; const settingsCollapsed = hasResults;
@ -77,7 +84,11 @@ const Compress = ({ selectedFiles = [], onPreviewFile }: CompressProps) => {
isVisible={true} isVisible={true}
isCollapsed={filesCollapsed} isCollapsed={filesCollapsed}
isCompleted={filesCollapsed} isCompleted={filesCollapsed}
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined} completedMessage={hasFiles ?
selectedFiles.length === 1
? `Selected: ${selectedFiles[0].name}`
: `Selected: ${selectedFiles.length} files`
: undefined}
> >
<FileStatusIndicator <FileStatusIndicator
selectedFiles={selectedFiles} selectedFiles={selectedFiles}

View File

@ -4,26 +4,23 @@ import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification"; import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator"; import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview"; import ResultsPreview from "../components/tools/shared/ResultsPreview";
import SplitSettings from "../components/tools/split/SplitSettings"; import SplitSettings from "../components/tools/split/SplitSettings";
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters"; import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation"; import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
import { BaseToolProps } from "../types/tool";
interface SplitProps { const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
selectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
}
const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setCurrentMode } = useFileContext(); const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const splitParams = useSplitParameters(); const splitParams = useSplitParameters();
const splitOperation = useSplitOperation(); const splitOperation = useSplitOperation();
@ -39,11 +36,20 @@ const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => {
}, [splitParams.mode, splitParams.parameters, selectedFiles]); }, [splitParams.mode, splitParams.parameters, selectedFiles]);
const handleSplit = async () => { const handleSplit = async () => {
await splitOperation.executeOperation( try {
splitParams.mode, await splitOperation.executeOperation(
splitParams.parameters, splitParams.mode,
selectedFiles 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) => { const handleThumbnailClick = (file: File) => {

View File

@ -36,3 +36,19 @@ export interface UndoRedoState {
operations: PageOperation[]; operations: PageOperation[];
currentIndex: number; 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[];
}

View File

@ -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<Tool, 'name'>;
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<string, any>;
}
export interface Tool {
id: string;
name: string;
icon: React.ReactNode;
component: React.ComponentType<BaseToolProps>;
maxFiles: MaxFiles;
category?: ToolCategory;
description?: string;
endpoints?: string[];
supportedFormats?: string[];
validation?: (files: File[]) => ToolValidationResult;
}
export type ToolRegistry = Record<string, Tool>;
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 {}

View File

@ -1,6 +1,10 @@
import { FileWithUrl } from "../types/file"; import { FileWithUrl } from "../types/file";
import { StoredFile, fileStorage } from "../services/fileStorage"; 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 * Consolidated file size formatting utility
*/ */