mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-04 03:25:21 +00:00
Merge remote-tracking branch 'origin/V2' into feature/react-convert
This commit is contained in:
commit
a8b370fd52
183
.github/workflows/deploy-on-v2-commit.yml
vendored
Normal file
183
.github/workflows/deploy-on-v2-commit.yml
vendored
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -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",
|
||||||
|
@ -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 |
@ -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}
|
||||||
|
179
frontend/src/components/shared/QuickAccessBar.css
Normal file
179
frontend/src/components/shared/QuickAccessBar.css
Normal 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;
|
||||||
|
}
|
@ -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}
|
||||||
|
14
frontend/src/components/tools/ToolLoadingFallback.tsx
Normal file
14
frontend/src/components/tools/ToolLoadingFallback.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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]);
|
||||||
|
86
frontend/src/contexts/FileSelectionContext.tsx
Normal file
86
frontend/src/contexts/FileSelectionContext.tsx
Normal 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 };
|
||||||
|
}
|
1
frontend/src/global.d.ts
vendored
1
frontend/src/global.d.ts
vendored
@ -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';
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
73
frontend/src/hooks/useIsOverflowing.ts
Normal file
73
frontend/src/hooks/useIsOverflowing.ts
Normal 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;
|
||||||
|
};
|
@ -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(() => {
|
||||||
|
@ -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',
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
@ -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);
|
||||||
|
@ -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}
|
||||||
|
@ -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) => {
|
||||||
|
@ -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[];
|
||||||
|
}
|
||||||
|
73
frontend/src/types/tool.ts
Normal file
73
frontend/src/types/tool.ts
Normal 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 {}
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
Loading…
x
Reference in New Issue
Block a user