mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-07-27 07:35:22 +00:00
commit
bfc679edc5
124
CLAUDE.md
Normal file
124
CLAUDE.md
Normal file
@ -0,0 +1,124 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
- **Build project**: `./gradlew clean build`
|
||||
- **Run locally**: `./gradlew bootRun`
|
||||
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
|
||||
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
|
||||
|
||||
### Docker Development
|
||||
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
|
||||
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
|
||||
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
|
||||
- **Example compose files**: Located in `exampleYmlFiles/` directory
|
||||
|
||||
### Security Mode Development
|
||||
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
||||
|
||||
### Frontend Development
|
||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
|
||||
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
|
||||
- **Package Installation**: DO NOT run npm install commands - package management handled separately
|
||||
|
||||
#### Tailwind CSS Setup (if not already installed)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install -D tailwindcss postcss autoprefixer
|
||||
npx tailwindcss init -p
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application with Thymeleaf templating
|
||||
- **Frontend**: React-based SPA in `/frontend` directory (replacing legacy Thymeleaf templates)
|
||||
- **Current Status**: Active development to replace Thymeleaf UI with modern React SPA
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **URL Parameters**: Deep linking support for tool states and configurations
|
||||
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
|
||||
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||
|
||||
### Controller Architecture
|
||||
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
|
||||
- Organized by function: converters, security, misc, pipeline
|
||||
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
|
||||
- **Web Controllers** (`src/main/java/.../controller/web/`): Serve Thymeleaf templates
|
||||
- Pattern: `@Controller` + return template names
|
||||
|
||||
### Key Components
|
||||
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
|
||||
- **ConfigInitializer**: Handles runtime configuration and settings files
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Template System (Legacy + Modern)
|
||||
- **Legacy Thymeleaf Templates**: Located in `src/main/resources/templates/` (being phased out)
|
||||
- **Modern React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
|
||||
- Conversion Script: `scripts/convert_properties_to_json.py`
|
||||
|
||||
### Configuration Modes
|
||||
- **Ultra-lite**: Basic PDF operations only
|
||||
- **Standard**: Full feature set
|
||||
- **Fat**: Pre-downloaded dependencies for air-gapped environments
|
||||
- **Security Mode**: Adds authentication, user management, and enterprise features
|
||||
|
||||
### Testing Strategy
|
||||
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
|
||||
- **Docker Testing**: `test.sh` validates all Docker variants
|
||||
- **Manual Testing**: No unit tests currently - relies on UI and API testing
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Local Development**:
|
||||
- Backend: `./gradlew bootRun` (runs on localhost:8080)
|
||||
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
|
||||
2. **Docker Testing**: Use `./test.sh` before submitting PRs
|
||||
3. **Code Style**: Spotless enforces Google Java Format automatically
|
||||
4. **Translations**:
|
||||
- Backend: Use helper scripts in `/scripts` for multi-language updates
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Migration Notes
|
||||
|
||||
- **Current Branch**: `feature/react-overhaul` - Active React SPA development
|
||||
- **Migration Status**: Core tools (Split, Merge, Compress) converted to React with URL parameter support
|
||||
- **File Management**: Implemented IndexedDB storage with thumbnail generation using PDF.js
|
||||
- **Tools Architecture**: Each tool receives `params` and `updateParams` for URL state synchronization
|
||||
- **Remaining Work**: Convert remaining Thymeleaf templates to React components
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
- No apologies or conversational filler
|
||||
- Answer questions directly without preamble
|
||||
- Explain reasoning concisely when asked
|
||||
- Avoid unnecessary elaboration
|
||||
|
||||
## Decision Making
|
||||
- Ask clarifying questions before making assumptions
|
||||
- Stop and ask when uncertain about project-specific details
|
||||
- Confirm approach before making structural changes
|
||||
- Request guidance on preferences (cross-platform vs specific tools, etc.)
|
||||
- Verify understanding of requirements before proceeding
|
@ -2,21 +2,38 @@
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This guide focuses on Docker-based development and testing, which is the recommended approach for working with the full version of Stirling-PDF.
|
||||
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite, replacing the legacy Thymeleaf-based UI with a modern React SPA (Single Page Application).
|
||||
|
||||
This guide focuses on developing for Stirling 2.0, including both the React frontend and Spring Boot backend development workflows.
|
||||
|
||||
## 2. Project Overview
|
||||
|
||||
Stirling-PDF is built using:
|
||||
**Stirling 2.0** is built using:
|
||||
|
||||
- Spring Boot + Thymeleaf
|
||||
- PDFBox
|
||||
- LibreOffice
|
||||
- qpdf
|
||||
- HTML, CSS, JavaScript
|
||||
- Docker
|
||||
- PDF.js
|
||||
- PDF-LIB.js
|
||||
- Lombok
|
||||
**Backend:**
|
||||
- Spring Boot (Java 17+, JDK 21 recommended)
|
||||
- PDFBox for core PDF operations
|
||||
- LibreOffice for document conversions
|
||||
- qpdf for PDF optimization
|
||||
- Spring Security (optional, controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- Lombok for reducing boilerplate code
|
||||
|
||||
**Frontend (React SPA):**
|
||||
- React + TypeScript
|
||||
- Vite for build tooling and development server
|
||||
- Mantine UI component library
|
||||
- TailwindCSS for styling
|
||||
- PDF.js for client-side PDF rendering
|
||||
- PDF-LIB.js for client-side PDF manipulation
|
||||
- IndexedDB for client-side file storage and thumbnails
|
||||
- i18next for internationalization
|
||||
|
||||
**Infrastructure:**
|
||||
- Docker for containerization
|
||||
- Gradle for build management
|
||||
|
||||
**Legacy (reference only during development):**
|
||||
- Thymeleaf templates (being completely replaced in 2.0)
|
||||
|
||||
## 3. Development Environment Setup
|
||||
|
||||
@ -24,7 +41,8 @@ Stirling-PDF is built using:
|
||||
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 17 or later
|
||||
- Java JDK 17 or later (JDK 21 recommended)
|
||||
- Node.js 18+ and npm (required for frontend development)
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
|
||||
### Setup Steps
|
||||
@ -54,17 +72,45 @@ Stirling-PDF is built using:
|
||||
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
|
||||
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
|
||||
|
||||
5. Add environment variable
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling-PDF. To do this, you must add the environment flag DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
|
||||
5. **Frontend Setup (Required for Stirling 2.0)**
|
||||
Navigate to the frontend directory and install dependencies using npm.
|
||||
|
||||
## 4. Project Structure
|
||||
## 4. Stirling 2.0 Development Workflow
|
||||
|
||||
### Frontend Development (React)
|
||||
The frontend is a React SPA that runs independently during development:
|
||||
|
||||
1. **Start the backend**: Run the Spring Boot application (serves API endpoints on localhost:8080)
|
||||
2. **Start the frontend dev server**: Navigate to the frontend directory and run the development server (serves UI on localhost:5173)
|
||||
3. **Development flow**: The Vite dev server automatically proxies API calls to the backend
|
||||
|
||||
### File Storage Architecture
|
||||
Stirling 2.0 uses client-side file storage:
|
||||
- **IndexedDB**: Stores files locally in the browser with automatic thumbnail generation
|
||||
- **PDF.js**: Handles client-side PDF rendering and processing
|
||||
- **URL Parameters**: Support for deep linking and tool state persistence
|
||||
|
||||
### Legacy Code Reference
|
||||
The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release.
|
||||
|
||||
## 5. Project Structure
|
||||
|
||||
```bash
|
||||
Stirling-PDF/
|
||||
├── .github/ # GitHub-specific files (workflows, issue templates)
|
||||
├── configs/ # Configuration files used by stirling at runtime (generated at runtime)
|
||||
├── cucumber/ # Cucumber test files
|
||||
│ ├── features/
|
||||
├── frontend/ # React SPA frontend (Stirling 2.0)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── tools/ # Tool-specific React components
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── services/ # API and utility services
|
||||
│ │ ├── types/ # TypeScript type definitions
|
||||
│ │ └── utils/ # Utility functions
|
||||
│ ├── public/
|
||||
│ │ └── locales/ # Internationalization files (JSON)
|
||||
│ ├── package.json # Frontend dependencies
|
||||
│ └── vite.config.ts # Vite configuration
|
||||
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
|
||||
├── docs/ # Documentation files
|
||||
├── exampleYmlFiles/ # Example YAML configuration files
|
||||
@ -84,16 +130,14 @@ Stirling-PDF/
|
||||
│ │ │ ├── service/
|
||||
│ │ │ └── utils/
|
||||
│ │ └── resources/
|
||||
│ │ ├── static/
|
||||
│ │ ├── static/ # Legacy static assets (reference only)
|
||||
│ │ │ ├── css/
|
||||
│ │ │ ├── js/
|
||||
│ │ │ └── pdfjs/
|
||||
│ │ └── templates/
|
||||
│ │ └── templates/ # Legacy Thymeleaf templates (reference only)
|
||||
│ └── test/
|
||||
│ └── java/
|
||||
│ └── stirling/
|
||||
│ └── software/
|
||||
│ └── SPDF/
|
||||
├── testing/ # Cucumber and integration tests
|
||||
│ └── cucumber/ # Cucumber test files
|
||||
├── build.gradle # Gradle build configuration
|
||||
├── Dockerfile # Main Dockerfile
|
||||
├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version
|
||||
@ -102,7 +146,7 @@ Stirling-PDF/
|
||||
└── test.sh # Test script to deploy all docker versions and run cuke tests
|
||||
```
|
||||
|
||||
## 5. Docker-based Development
|
||||
## 6. Docker-based Development
|
||||
|
||||
Stirling-PDF offers several Docker versions:
|
||||
|
||||
@ -202,7 +246,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
|
||||
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
|
||||
|
||||
## 6. Testing
|
||||
## 7. Testing
|
||||
|
||||
### Comprehensive Testing Script
|
||||
|
||||
@ -228,6 +272,15 @@ Note: The `test.sh` script will run automatically when you raise a PR. However,
|
||||
|
||||
2. Access the application at `http://localhost:8080` and manually test all features developed.
|
||||
|
||||
### Frontend Development Testing (Stirling 2.0)
|
||||
|
||||
For React frontend development:
|
||||
|
||||
1. Start the backend: Run the Spring Boot application to serve API endpoints on localhost:8080
|
||||
2. Start the frontend dev server: Navigate to the frontend directory and run the development server on localhost:5173
|
||||
3. The Vite dev server automatically proxies API calls to the backend
|
||||
4. Test React components, UI interactions, and IndexedDB file operations using browser developer tools
|
||||
|
||||
### Local Testing (Java and UI Components)
|
||||
|
||||
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
|
||||
@ -258,7 +311,7 @@ Important notes:
|
||||
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
|
||||
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
|
||||
|
||||
## 7. Contributing
|
||||
## 8. Contributing
|
||||
|
||||
1. Fork the repository on GitHub.
|
||||
2. Create a new branch for your feature or bug fix.
|
||||
@ -283,11 +336,11 @@ When you raise a PR:
|
||||
|
||||
Address any issues that arise from these checks before finalizing your pull request.
|
||||
|
||||
## 8. API Documentation
|
||||
## 9. API Documentation
|
||||
|
||||
API documentation is available at `/swagger-ui/index.html` when running the application. You can also view the latest API documentation [here](https://app.swaggerhub.com/apis-docs/Stirling-Tools/Stirling-PDF/).
|
||||
|
||||
## 9. Customization
|
||||
## 10. Customization
|
||||
|
||||
Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include:
|
||||
|
||||
@ -306,7 +359,7 @@ docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
|
||||
|
||||
Refer to the main README for a full list of customization options.
|
||||
|
||||
## 10. Language Translations
|
||||
## 11. Language Translations
|
||||
|
||||
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
|
||||
|
||||
@ -326,7 +379,56 @@ Remember to test your changes thoroughly to ensure they don't break any existing
|
||||
|
||||
## Code examples
|
||||
|
||||
### Overview of Thymeleaf
|
||||
### React Component Development (Stirling 2.0)
|
||||
|
||||
For Stirling 2.0, new features are built as React components instead of Thymeleaf templates:
|
||||
|
||||
#### Creating a New Tool Component
|
||||
|
||||
1. **Create the React Component:**
|
||||
```typescript
|
||||
// frontend/src/tools/NewTool.tsx
|
||||
import { useState } from 'react';
|
||||
import { Button, FileInput, Container } from '@mantine/core';
|
||||
|
||||
interface NewToolProps {
|
||||
params: Record<string, any>;
|
||||
updateParams: (updates: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export default function NewTool({ params, updateParams }: NewToolProps) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const handleProcess = async () => {
|
||||
// Process files using API or client-side logic
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<FileInput
|
||||
multiple
|
||||
accept="application/pdf"
|
||||
onChange={setFiles}
|
||||
/>
|
||||
<Button onClick={handleProcess}>Process</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add API Integration:**
|
||||
```typescript
|
||||
// Use existing API endpoints or create new ones
|
||||
const response = await fetch('/api/v1/new-tool', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
```
|
||||
|
||||
3. **Register in Tool Picker:**
|
||||
Update the tool picker component to include the new tool with proper routing and URL parameter support.
|
||||
|
||||
### Legacy Reference: Overview of Thymeleaf
|
||||
|
||||
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
|
||||
|
||||
|
38
frontend/package-lock.json
generated
38
frontend/package-lock.json
generated
@ -25,6 +25,7 @@
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@ -1394,6 +1395,22 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@ -4213,6 +4230,11 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@ -4278,6 +4300,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "3.11.174",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||
|
@ -21,6 +21,7 @@
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
@ -1622,5 +1622,48 @@
|
||||
"toolPicker": {
|
||||
"searchPlaceholder": "Search tools...",
|
||||
"noToolsFound": "No tools found"
|
||||
},
|
||||
"fileUpload": {
|
||||
"selectFile": "Select a file",
|
||||
"selectFiles": "Select files",
|
||||
"selectPdfToView": "Select a PDF to view",
|
||||
"selectPdfToEdit": "Select a PDF to edit",
|
||||
"chooseFromStorage": "Choose a file from storage or upload a new PDF",
|
||||
"chooseFromStorageMultiple": "Choose files from storage or upload new PDFs",
|
||||
"loadFromStorage": "Load from Storage",
|
||||
"filesAvailable": "files available",
|
||||
"loading": "Loading...",
|
||||
"or": "or",
|
||||
"dropFileHere": "Drop file here or click to upload",
|
||||
"dropFilesHere": "Drop files here or click to upload",
|
||||
"pdfFilesOnly": "PDF files only",
|
||||
"supportedFileTypes": "Supported file types",
|
||||
"uploadFile": "Upload File",
|
||||
"uploadFiles": "Upload Files",
|
||||
"noFilesInStorage": "No files available in storage. Upload some files first.",
|
||||
"selectFromStorage": "Select from Storage",
|
||||
"backToTools": "Back to Tools"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "Upload PDF Files",
|
||||
"subtitle": "Add files to your storage for easy access across tools",
|
||||
"filesSelected": "files selected",
|
||||
"clearSelection": "Clear Selection",
|
||||
"openInFileEditor": "Open in File Editor",
|
||||
"uploadError": "Failed to upload some files.",
|
||||
"failedToOpen": "Failed to open file. It may have been removed from storage.",
|
||||
"failedToLoad": "Failed to load file to active set.",
|
||||
"storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.",
|
||||
"clearAll": "Clear All",
|
||||
"reloadFiles": "Reload Files"
|
||||
},
|
||||
"storage": {
|
||||
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
||||
"storageLimit": "Storage limit",
|
||||
"storageUsed": "Temporary Storage used",
|
||||
"storageFull": "Storage is nearly full. Consider removing some files.",
|
||||
"fileTooLarge": "File too large. Maximum size per file is",
|
||||
"storageQuotaExceeded": "Storage quota exceeded. Please remove some files before uploading more.",
|
||||
"approximateSize": "Approximate size"
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
{}
|
@ -1,6 +1,15 @@
|
||||
import './index.css';
|
||||
import React from 'react';
|
||||
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
|
||||
import HomePage from './pages/HomePage';
|
||||
|
||||
// Import global styles
|
||||
import './styles/tailwind.css';
|
||||
import './index.css';
|
||||
|
||||
export default function App() {
|
||||
return <HomePage/>;
|
||||
return (
|
||||
<RainbowThemeProvider>
|
||||
<HomePage />
|
||||
</RainbowThemeProvider>
|
||||
);
|
||||
}
|
||||
|
334
frontend/src/commands/pageCommands.ts
Normal file
334
frontend/src/commands/pageCommands.ts
Normal file
@ -0,0 +1,334 @@
|
||||
import { Command, CommandSequence } from '../hooks/useUndoRedo';
|
||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||
|
||||
// Base class for page operations
|
||||
abstract class PageCommand implements Command {
|
||||
protected pdfDocument: PDFDocument;
|
||||
protected setPdfDocument: (doc: PDFDocument) => void;
|
||||
protected previousState: PDFDocument;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void
|
||||
) {
|
||||
this.pdfDocument = pdfDocument;
|
||||
this.setPdfDocument = setPdfDocument;
|
||||
this.previousState = JSON.parse(JSON.stringify(pdfDocument)); // Deep clone
|
||||
}
|
||||
|
||||
abstract execute(): void;
|
||||
abstract description: string;
|
||||
|
||||
undo(): void {
|
||||
this.setPdfDocument(this.previousState);
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate pages command
|
||||
export class RotatePagesCommand extends PageCommand {
|
||||
private pageIds: string[];
|
||||
private rotation: number;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageIds: string[],
|
||||
rotation: number
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageIds = pageIds;
|
||||
this.rotation = rotation;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const updatedPages = this.pdfDocument.pages.map(page => {
|
||||
if (this.pageIds.includes(page.id)) {
|
||||
return { ...page, rotation: page.rotation + this.rotation };
|
||||
}
|
||||
return page;
|
||||
});
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
const direction = this.rotation > 0 ? 'right' : 'left';
|
||||
return `Rotate ${this.pageIds.length} page(s) ${direction}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete pages command
|
||||
export class DeletePagesCommand extends PageCommand {
|
||||
private pageIds: string[];
|
||||
private deletedPages: PDFPage[];
|
||||
private deletedPositions: Map<string, number>;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageIds: string[]
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageIds = pageIds;
|
||||
this.deletedPages = [];
|
||||
this.deletedPositions = new Map();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store deleted pages and their positions for undo
|
||||
this.deletedPages = this.pdfDocument.pages.filter(page =>
|
||||
this.pageIds.includes(page.id)
|
||||
);
|
||||
|
||||
this.deletedPages.forEach(page => {
|
||||
const index = this.pdfDocument.pages.findIndex(p => p.id === page.id);
|
||||
this.deletedPositions.set(page.id, index);
|
||||
});
|
||||
|
||||
const updatedPages = this.pdfDocument.pages
|
||||
.filter(page => !this.pageIds.includes(page.id))
|
||||
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
let restoredPages = [...this.pdfDocument.pages];
|
||||
|
||||
// Insert deleted pages back at their original positions
|
||||
this.deletedPages
|
||||
.sort((a, b) => (this.deletedPositions.get(a.id) || 0) - (this.deletedPositions.get(b.id) || 0))
|
||||
.forEach(page => {
|
||||
const originalIndex = this.deletedPositions.get(page.id) || 0;
|
||||
restoredPages.splice(originalIndex, 0, page);
|
||||
});
|
||||
|
||||
// Update page numbers
|
||||
restoredPages = restoredPages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: restoredPages,
|
||||
totalPages: restoredPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Delete ${this.pageIds.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Move pages command
|
||||
export class MovePagesCommand extends PageCommand {
|
||||
private pageIds: string[];
|
||||
private targetIndex: number;
|
||||
private originalIndices: Map<string, number>;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageIds: string[],
|
||||
targetIndex: number
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageIds = pageIds;
|
||||
this.targetIndex = targetIndex;
|
||||
this.originalIndices = new Map();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store original positions
|
||||
this.pageIds.forEach(pageId => {
|
||||
const index = this.pdfDocument.pages.findIndex(p => p.id === pageId);
|
||||
this.originalIndices.set(pageId, index);
|
||||
});
|
||||
|
||||
let newPages = [...this.pdfDocument.pages];
|
||||
const pagesToMove = this.pageIds
|
||||
.map(id => this.pdfDocument.pages.find(p => p.id === id))
|
||||
.filter((page): page is PDFPage => page !== undefined);
|
||||
|
||||
// Remove pages to move
|
||||
newPages = newPages.filter(page => !this.pageIds.includes(page.id));
|
||||
|
||||
// Insert pages at target position
|
||||
newPages.splice(this.targetIndex, 0, ...pagesToMove);
|
||||
|
||||
// Update page numbers
|
||||
newPages = newPages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: newPages });
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Move ${this.pageIds.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder single page command (for drag-and-drop)
|
||||
export class ReorderPageCommand extends PageCommand {
|
||||
private pageId: string;
|
||||
private targetIndex: number;
|
||||
private originalIndex: number;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageId: string,
|
||||
targetIndex: number
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageId = pageId;
|
||||
this.targetIndex = targetIndex;
|
||||
this.originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId);
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const newPages = [...this.pdfDocument.pages];
|
||||
const [movedPage] = newPages.splice(this.originalIndex, 1);
|
||||
newPages.splice(this.targetIndex, 0, movedPage);
|
||||
|
||||
// Update page numbers
|
||||
const updatedPages = newPages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Reorder page ${this.originalIndex + 1} to position ${this.targetIndex + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle split markers command
|
||||
export class ToggleSplitCommand extends PageCommand {
|
||||
private pageIds: string[];
|
||||
private previousSplitStates: Map<string, boolean>;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
pageIds: string[]
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.pageIds = pageIds;
|
||||
this.previousSplitStates = new Map();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Store previous split states
|
||||
this.pageIds.forEach(pageId => {
|
||||
const page = this.pdfDocument.pages.find(p => p.id === pageId);
|
||||
if (page) {
|
||||
this.previousSplitStates.set(pageId, !!page.splitBefore);
|
||||
}
|
||||
});
|
||||
|
||||
const updatedPages = this.pdfDocument.pages.map(page => {
|
||||
if (this.pageIds.includes(page.id)) {
|
||||
return { ...page, splitBefore: !page.splitBefore };
|
||||
}
|
||||
return page;
|
||||
});
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const updatedPages = this.pdfDocument.pages.map(page => {
|
||||
if (this.pageIds.includes(page.id)) {
|
||||
const previousState = this.previousSplitStates.get(page.id);
|
||||
return { ...page, splitBefore: previousState };
|
||||
}
|
||||
return page;
|
||||
});
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Toggle split markers for ${this.pageIds.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add pages command (for inserting new files)
|
||||
export class AddPagesCommand extends PageCommand {
|
||||
private newPages: PDFPage[];
|
||||
private insertIndex: number;
|
||||
|
||||
constructor(
|
||||
pdfDocument: PDFDocument,
|
||||
setPdfDocument: (doc: PDFDocument) => void,
|
||||
newPages: PDFPage[],
|
||||
insertIndex: number = -1 // -1 means append to end
|
||||
) {
|
||||
super(pdfDocument, setPdfDocument);
|
||||
this.newPages = newPages;
|
||||
this.insertIndex = insertIndex === -1 ? pdfDocument.pages.length : insertIndex;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const newPagesArray = [...this.pdfDocument.pages];
|
||||
newPagesArray.splice(this.insertIndex, 0, ...this.newPages);
|
||||
|
||||
// Update page numbers for all pages
|
||||
const updatedPages = newPagesArray.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const updatedPages = this.pdfDocument.pages
|
||||
.filter(page => !this.newPages.some(newPage => newPage.id === page.id))
|
||||
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return `Add ${this.newPages.length} page(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Command sequence for bulk operations
|
||||
export class PageCommandSequence implements CommandSequence {
|
||||
commands: Command[];
|
||||
description: string;
|
||||
|
||||
constructor(commands: Command[], description?: string) {
|
||||
this.commands = commands;
|
||||
this.description = description || `Execute ${commands.length} operations`;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.commands.forEach(command => command.execute());
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Undo in reverse order
|
||||
[...this.commands].reverse().forEach(command => command.undo());
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Stack, Text, Group } from '@mantine/core';
|
||||
|
||||
const DeepLinks: React.FC = () => {
|
||||
const commonLinks = [
|
||||
{
|
||||
name: "Split PDF Pages 1-5",
|
||||
url: "/?tool=split&splitMode=byPages&pages=1-5&view=viewer",
|
||||
description: "Split a PDF and extract pages 1-5"
|
||||
},
|
||||
{
|
||||
name: "Compress PDF (High)",
|
||||
url: "/?tool=compress&level=9&grayscale=true&view=viewer",
|
||||
description: "Compress a PDF with high compression level"
|
||||
},
|
||||
{
|
||||
name: "Merge PDFs",
|
||||
url: "/?tool=merge&view=fileManager",
|
||||
description: "Combine multiple PDF files into one"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text fw={500}>Common PDF Operations</Text>
|
||||
{commonLinks.map((link, index) => (
|
||||
<Group key={index}>
|
||||
<Button
|
||||
component={Link}
|
||||
to={link.url}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
{link.name}
|
||||
</Button>
|
||||
<Text size="sm" color="dimmed">{link.description}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeepLinks;
|
@ -1,125 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '../i18n';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import styles from './LanguageSelector.module.css';
|
||||
|
||||
const LanguageSelector: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const languageOptions = Object.entries(supportedLanguages)
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([code, name]) => ({
|
||||
value: code,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
i18n.changeLanguage(value);
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
supportedLanguages['en-GB'];
|
||||
|
||||
return (
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={600}
|
||||
position="bottom-start"
|
||||
offset={8}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
|
||||
styles={{
|
||||
root: {
|
||||
border: 'none',
|
||||
color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7],
|
||||
'&:hover': {
|
||||
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.languageText}>
|
||||
{currentLanguage}
|
||||
</span>
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`,
|
||||
}}
|
||||
>
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={styles.languageItem}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => handleLanguageChange(option.value)}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: '4px',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
justifyContent: 'flex-start',
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
|
||||
) : 'transparent',
|
||||
color: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
|
||||
),
|
||||
'&:hover': {
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
|
||||
),
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '13px',
|
||||
fontWeight: option.value === i18n.language ? 600 : 400,
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
@ -1,198 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Paper, Button, Group, Text, Stack, Center, Checkbox, ScrollArea, Box, Tooltip, ActionIcon, Notification
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||
|
||||
export interface PageEditorProps {
|
||||
file: { file: File; url: string } | null;
|
||||
setFile?: (file: { file: File; url: string } | null) => void;
|
||||
downloadUrl?: string | null;
|
||||
setDownloadUrl?: (url: string | null) => void;
|
||||
}
|
||||
|
||||
const DUMMY_PAGE_COUNT = 8; // Replace with real page count from PDF
|
||||
|
||||
const PageEditor: React.FC<PageEditorProps> = ({
|
||||
file,
|
||||
setFile,
|
||||
downloadUrl,
|
||||
setDownloadUrl,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPages, setSelectedPages] = useState<number[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [undoStack, setUndoStack] = useState<number[][]>([]);
|
||||
const [redoStack, setRedoStack] = useState<number[][]>([]);
|
||||
|
||||
// Dummy page thumbnails
|
||||
const pages = Array.from({ length: DUMMY_PAGE_COUNT }, (_, i) => i + 1);
|
||||
|
||||
const selectAll = () => setSelectedPages(pages);
|
||||
const deselectAll = () => setSelectedPages([]);
|
||||
const togglePage = (page: number) =>
|
||||
setSelectedPages((prev) =>
|
||||
prev.includes(page) ? prev.filter((p) => p !== page) : [...prev, page]
|
||||
);
|
||||
|
||||
// Undo/redo logic for selection
|
||||
const handleUndo = () => {
|
||||
if (undoStack.length > 0) {
|
||||
setRedoStack([selectedPages, ...redoStack]);
|
||||
setSelectedPages(undoStack[0]);
|
||||
setUndoStack(undoStack.slice(1));
|
||||
}
|
||||
};
|
||||
const handleRedo = () => {
|
||||
if (redoStack.length > 0) {
|
||||
setUndoStack([selectedPages, ...undoStack]);
|
||||
setSelectedPages(redoStack[0]);
|
||||
setRedoStack(redoStack.slice(1));
|
||||
}
|
||||
};
|
||||
|
||||
// Example action handlers (replace with real API calls)
|
||||
const handleRotateLeft = () => setStatus(t("pageEditor.rotatedLeft", "Rotated left: ") + selectedPages.join(", "));
|
||||
const handleRotateRight = () => setStatus(t("pageEditor.rotatedRight", "Rotated right: ") + selectedPages.join(", "));
|
||||
const handleDelete = () => setStatus(t("pageEditor.deleted", "Deleted: ") + selectedPages.join(", "));
|
||||
const handleMoveLeft = () => setStatus(t("pageEditor.movedLeft", "Moved left: ") + selectedPages.join(", "));
|
||||
const handleMoveRight = () => setStatus(t("pageEditor.movedRight", "Moved right: ") + selectedPages.join(", "));
|
||||
const handleSplit = () => setStatus(t("pageEditor.splitAt", "Split at: ") + selectedPages.join(", "));
|
||||
const handleInsertPageBreak = () => setStatus(t("pageEditor.insertedPageBreak", "Inserted page break at: ") + selectedPages.join(", "));
|
||||
const handleAddFile = () => setStatus(t("pageEditor.addFileNotImplemented", "Add file not implemented in demo"));
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<Paper shadow="xs" radius="md" p="md">
|
||||
<Center>
|
||||
<Text color="dimmed">{t("pageEditor.noPdfLoaded", "No PDF loaded. Please upload a PDF to edit.")}</Text>
|
||||
</Center>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper shadow="xs" radius="md" p="md">
|
||||
<Group align="flex-start" gap="lg">
|
||||
{/* Sidebar */}
|
||||
<Stack w={180} gap="xs">
|
||||
<Text fw={600} size="lg">{t("pageEditor.title", "PDF Multitool")}</Text>
|
||||
<Button onClick={selectAll} fullWidth variant="light">{t("multiTool.selectAll", "Select All")}</Button>
|
||||
<Button onClick={deselectAll} fullWidth variant="light">{t("multiTool.deselectAll", "Deselect All")}</Button>
|
||||
<Button onClick={handleUndo} leftSection={<UndoIcon fontSize="small" />} fullWidth disabled={undoStack.length === 0}>{t("multiTool.undo", "Undo")}</Button>
|
||||
<Button onClick={handleRedo} leftSection={<RedoIcon fontSize="small" />} fullWidth disabled={redoStack.length === 0}>{t("multiTool.redo", "Redo")}</Button>
|
||||
<Button onClick={handleAddFile} leftSection={<AddIcon fontSize="small" />} fullWidth>{t("multiTool.addFile", "Add File")}</Button>
|
||||
<Button onClick={handleInsertPageBreak} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.insertPageBreak", "Insert Page Break")}</Button>
|
||||
<Button onClick={handleSplit} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.split", "Split")}</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl || "#"}
|
||||
download="edited.pdf"
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
fullWidth
|
||||
color="green"
|
||||
variant="light"
|
||||
disabled={!downloadUrl}
|
||||
>
|
||||
{t("multiTool.downloadAll", "Download All")}
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl || "#"}
|
||||
download="selected.pdf"
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
fullWidth
|
||||
color="blue"
|
||||
variant="light"
|
||||
disabled={!downloadUrl || selectedPages.length === 0}
|
||||
>
|
||||
{t("multiTool.downloadSelected", "Download Selected")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => setFile && setFile(null)}
|
||||
fullWidth
|
||||
>
|
||||
{t("pageEditor.closePdf", "Close PDF")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Main multitool area */}
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Group mb="sm">
|
||||
<Tooltip label={t("multiTool.rotateLeft", "Rotate Left")}>
|
||||
<ActionIcon onClick={handleRotateLeft} disabled={selectedPages.length === 0} color="blue" variant="light">
|
||||
<RotateLeftIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("multiTool.rotateRight", "Rotate Right")}>
|
||||
<ActionIcon onClick={handleRotateRight} disabled={selectedPages.length === 0} color="blue" variant="light">
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("delete", "Delete")}>
|
||||
<ActionIcon onClick={handleDelete} disabled={selectedPages.length === 0} color="red" variant="light">
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("multiTool.moveLeft", "Move Left")}>
|
||||
<ActionIcon onClick={handleMoveLeft} disabled={selectedPages.length === 0} color="gray" variant="light">
|
||||
<ArrowBackIosNewIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("multiTool.moveRight", "Move Right")}>
|
||||
<ActionIcon onClick={handleMoveRight} disabled={selectedPages.length === 0} color="gray" variant="light">
|
||||
<ArrowForwardIosIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea h={350}>
|
||||
<Group>
|
||||
{pages.map((page) => (
|
||||
<Stack key={page} align="center" gap={2}>
|
||||
<Checkbox
|
||||
checked={selectedPages.includes(page)}
|
||||
onChange={() => togglePage(page)}
|
||||
label={t("page", "Page") + ` ${page}`}
|
||||
/>
|
||||
<Box
|
||||
w={60}
|
||||
h={80}
|
||||
bg={selectedPages.includes(page) ? "blue.1" : "gray.1"}
|
||||
style={{ border: "1px solid #ccc", borderRadius: 4 }}
|
||||
>
|
||||
{/* Replace with real thumbnail */}
|
||||
<Center h="100%">
|
||||
<Text size="xs" color="dimmed">
|
||||
{page}
|
||||
</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Group>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Group>
|
||||
{status && (
|
||||
<Notification color="blue" mt="md" onClose={() => setStatus(null)}>
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditor;
|
42
frontend/src/components/editor/BulkSelectionPanel.tsx
Normal file
42
frontend/src/components/editor/BulkSelectionPanel.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
|
||||
|
||||
interface BulkSelectionPanelProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPages: string[];
|
||||
onUpdatePagesFromCSV: () => void;
|
||||
}
|
||||
|
||||
const BulkSelectionPanel = ({
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPages,
|
||||
onUpdatePagesFromCSV,
|
||||
}: BulkSelectionPanelProps) => {
|
||||
return (
|
||||
<Paper p="md" mb="md" withBorder>
|
||||
<Group>
|
||||
<TextInput
|
||||
value={csvInput}
|
||||
onChange={(e) => setCsvInput(e.target.value)}
|
||||
placeholder="1,3,5-10"
|
||||
label="Page Selection"
|
||||
onBlur={onUpdatePagesFromCSV}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button onClick={onUpdatePagesFromCSV} mt="xl">
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
{selectedPages.length > 0 && (
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
Selected: {selectedPages.length} pages
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkSelectionPanel;
|
515
frontend/src/components/editor/FileEditor.tsx
Normal file
515
frontend/src/components/editor/FileEditor.tsx
Normal file
@ -0,0 +1,515 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
||||
Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
import styles from './PageEditor.module.css';
|
||||
import FileThumbnail from './FileThumbnail';
|
||||
import BulkSelectionPanel from './BulkSelectionPanel';
|
||||
import DragDropGrid from './shared/DragDropGrid';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string;
|
||||
size: number;
|
||||
file: File;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: (file: File) => void;
|
||||
onMergeFiles?: (files: File[]) => void;
|
||||
sharedFiles?: { file: File; url: string }[];
|
||||
setSharedFiles?: (files: { file: File; url: string }[]) => void;
|
||||
preSelectedFiles?: { file: File; url: string }[];
|
||||
onClearPreSelection?: () => void;
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
onOpenPageEditor,
|
||||
onMergeFiles,
|
||||
sharedFiles = [],
|
||||
setSharedFiles,
|
||||
preSelectedFiles = [],
|
||||
onClearPreSelection
|
||||
}: FileEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const files = sharedFiles; // Use sharedFiles as the source of truth
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [csvInput, setCsvInput] = useState<string>('');
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Convert shared files to FileEditor format
|
||||
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
|
||||
// Generate thumbnail if not already available
|
||||
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
|
||||
|
||||
return {
|
||||
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
|
||||
name: (sharedFile.file?.name || sharedFile.name || 'unknown').replace(/\.pdf$/i, ''),
|
||||
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now
|
||||
thumbnail,
|
||||
size: sharedFile.file?.size || sharedFile.size || 0,
|
||||
file: sharedFile.file || sharedFile,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Only load shared files when explicitly passed (not on mount)
|
||||
useEffect(() => {
|
||||
const loadSharedFiles = async () => {
|
||||
// Only load if we have pre-selected files (coming from FileManager)
|
||||
if (preSelectedFiles.length > 0) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
preSelectedFiles.map(convertToFileItem)
|
||||
);
|
||||
setFiles(convertedFiles);
|
||||
} catch (err) {
|
||||
console.error('Error converting pre-selected files:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSharedFiles();
|
||||
}, [preSelectedFiles, convertToFileItem]);
|
||||
|
||||
// Handle pre-selected files
|
||||
useEffect(() => {
|
||||
if (preSelectedFiles.length > 0) {
|
||||
const preSelectedIds = preSelectedFiles.map(f => f.id || f.name);
|
||||
setSelectedFiles(preSelectedIds);
|
||||
onClearPreSelection?.();
|
||||
}
|
||||
}, [preSelectedFiles, onClearPreSelection]);
|
||||
|
||||
// Process uploaded files
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newFiles: FileItem[] = [];
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
if (file.type !== 'application/pdf') {
|
||||
setError('Please upload only PDF files');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate thumbnail and get page count
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
|
||||
const fileItem: FileItem = {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
name: file.name.replace(/\.pdf$/i, ''),
|
||||
pageCount: Math.floor(Math.random() * 20) + 1, // Mock page count
|
||||
thumbnail,
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
|
||||
newFiles.push(fileItem);
|
||||
|
||||
// Store in IndexedDB
|
||||
await fileStorage.storeFile(file, thumbnail);
|
||||
}
|
||||
|
||||
if (setSharedFiles) {
|
||||
setSharedFiles(prev => [...prev, ...newFiles]);
|
||||
}
|
||||
|
||||
setStatus(`Added ${newFiles.length} files`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setSharedFiles]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedFiles(files.map(f => f.id));
|
||||
}, [files]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedFiles([]), []);
|
||||
|
||||
const toggleFile = useCallback((fileId: string) => {
|
||||
setSelectedFiles(prev =>
|
||||
prev.includes(fileId)
|
||||
? prev.filter(id => id !== fileId)
|
||||
: [...prev, fileId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
const newMode = !prev;
|
||||
if (!newMode) {
|
||||
setSelectedFiles([]);
|
||||
setCsvInput('');
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const parseCSVInput = useCallback((csv: string) => {
|
||||
const fileIds: string[] = [];
|
||||
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
ranges.forEach(range => {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= files.length; i++) {
|
||||
if (i > 0) {
|
||||
const file = files[i - 1];
|
||||
if (file) fileIds.push(file.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fileIndex = parseInt(range);
|
||||
if (fileIndex > 0 && fileIndex <= files.length) {
|
||||
const file = files[fileIndex - 1];
|
||||
if (file) fileIds.push(file.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return fileIds;
|
||||
}, [files]);
|
||||
|
||||
const updateFilesFromCSV = useCallback(() => {
|
||||
const fileIds = parseCSVInput(csvInput);
|
||||
setSelectedFiles(fileIds);
|
||||
}, [csvInput, parseCSVInput]);
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = useCallback((fileId: string) => {
|
||||
setDraggedFile(fileId);
|
||||
|
||||
if (selectionMode && selectedFiles.includes(fileId) && selectedFiles.length > 1) {
|
||||
setMultiFileDrag({
|
||||
fileIds: selectedFiles,
|
||||
count: selectedFiles.length
|
||||
});
|
||||
} else {
|
||||
setMultiFileDrag(null);
|
||||
}
|
||||
}, [selectionMode, selectedFiles]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedFile(null);
|
||||
setDropTarget(null);
|
||||
setMultiFileDrag(null);
|
||||
setDragPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedFile) return;
|
||||
|
||||
if (multiFileDrag) {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!elementUnderCursor) return;
|
||||
|
||||
const fileContainer = elementUnderCursor.closest('[data-file-id]');
|
||||
if (fileContainer) {
|
||||
const fileId = fileContainer.getAttribute('data-file-id');
|
||||
if (fileId && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
||||
if (endZone) {
|
||||
setDropTarget('end');
|
||||
return;
|
||||
}
|
||||
|
||||
setDropTarget(null);
|
||||
}, [draggedFile, multiFileDrag]);
|
||||
|
||||
const handleDragEnter = useCallback((fileId: string) => {
|
||||
if (draggedFile && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
// Let dragover handle this
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => {
|
||||
e.preventDefault();
|
||||
if (!draggedFile || draggedFile === targetFileId) return;
|
||||
|
||||
let targetIndex: number;
|
||||
if (targetFileId === 'end') {
|
||||
targetIndex = files.length;
|
||||
} else {
|
||||
targetIndex = files.findIndex(f => f.id === targetFileId);
|
||||
if (targetIndex === -1) return;
|
||||
}
|
||||
|
||||
const filesToMove = selectionMode && selectedFiles.includes(draggedFile)
|
||||
? selectedFiles
|
||||
: [draggedFile];
|
||||
|
||||
if (setSharedFiles) {
|
||||
setSharedFiles(prev => {
|
||||
const newFiles = [...prev];
|
||||
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
|
||||
|
||||
// Remove moved files
|
||||
filesToMove.forEach(id => {
|
||||
const index = newFiles.findIndex(f => f.id === id);
|
||||
if (index !== -1) newFiles.splice(index, 1);
|
||||
});
|
||||
|
||||
// Insert at target position
|
||||
newFiles.splice(targetIndex, 0, ...movedFiles);
|
||||
return newFiles;
|
||||
});
|
||||
}
|
||||
|
||||
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
|
||||
handleDragEnd();
|
||||
}, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setSharedFiles]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedFile) {
|
||||
setDropTarget('end');
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
// File operations
|
||||
const handleDeleteFile = useCallback((fileId: string) => {
|
||||
if (setSharedFiles) {
|
||||
setSharedFiles(prev => prev.filter(f => f.id !== fileId));
|
||||
}
|
||||
setSelectedFiles(prev => prev.filter(id => id !== fileId));
|
||||
}, [setSharedFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file.file);
|
||||
}
|
||||
}, [files, onOpenPageEditor]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: string) => {
|
||||
const startIndex = files.findIndex(f => f.id === fileId);
|
||||
if (startIndex === -1) return;
|
||||
|
||||
const filesToMerge = files.slice(startIndex).map(f => f.file);
|
||||
if (onMergeFiles) {
|
||||
onMergeFiles(filesToMerge);
|
||||
}
|
||||
}, [files, onMergeFiles]);
|
||||
|
||||
const handleSplitFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file.file);
|
||||
}
|
||||
}, [files, onOpenPageEditor]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(convertToFileItem)
|
||||
);
|
||||
setFiles(prev => [...prev, ...convertedFiles]);
|
||||
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
setError('Failed to load some files from storage');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [convertToFileItem]);
|
||||
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={loading} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
<Button
|
||||
onClick={toggleSelectionMode}
|
||||
variant={selectionMode ? "filled" : "outline"}
|
||||
color={selectionMode ? "blue" : "gray"}
|
||||
styles={{
|
||||
root: {
|
||||
transition: 'all 0.2s ease',
|
||||
...(selectionMode && {
|
||||
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectionMode ? "Exit Selection" : "Select Files"}
|
||||
</Button>
|
||||
{selectionMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Load from storage and upload buttons */}
|
||||
<Button
|
||||
variant="outline"
|
||||
color="blue"
|
||||
onClick={() => setShowFilePickerModal(true)}
|
||||
>
|
||||
Load from Storage
|
||||
</Button>
|
||||
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={["application/pdf"]}
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{ display: 'contents' }}
|
||||
>
|
||||
<Button variant="outline" color="green">
|
||||
Upload Files
|
||||
</Button>
|
||||
</Dropzone>
|
||||
</Group>
|
||||
|
||||
{selectionMode && (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPages={selectedFiles}
|
||||
onUpdatePagesFromCSV={updateFilesFromCSV}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DragDropGrid
|
||||
items={files}
|
||||
selectedItems={selectedFiles}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||
draggedItem={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
multiItemDrag={multiFileDrag}
|
||||
dragPosition={dragPosition}
|
||||
renderItem={(file, index, refs) => (
|
||||
<FileThumbnail
|
||||
file={file}
|
||||
index={index}
|
||||
totalFiles={files.length}
|
||||
selectedFiles={selectedFiles}
|
||||
selectionMode={selectionMode}
|
||||
draggedFile={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
isAnimating={isAnimating}
|
||||
fileRefs={refs}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onMergeFromHere={handleMergeFromHere}
|
||||
onSplitFile={handleSplitFile}
|
||||
onSetStatus={setStatus}
|
||||
/>
|
||||
)}
|
||||
renderSplitMarker={(file, index) => (
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: '24rem',
|
||||
borderLeft: '2px dashed #3b82f6',
|
||||
backgroundColor: 'transparent',
|
||||
marginLeft: '-0.75rem',
|
||||
marginRight: '-0.75rem',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
sharedFiles={sharedFiles || []}
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
327
frontend/src/components/editor/FileThumbnail.tsx
Normal file
327
frontend/src/components/editor/FileThumbnail.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import React from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import MergeIcon from '@mui/icons-material/Merge';
|
||||
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import styles from './PageEditor.module.css';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string;
|
||||
size: number;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
interface FileThumbnailProps {
|
||||
file: FileItem;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: string[];
|
||||
selectionMode: boolean;
|
||||
draggedFile: string | null;
|
||||
dropTarget: string | null;
|
||||
isAnimating: boolean;
|
||||
fileRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
onDragStart: (fileId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (fileId: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, fileId: string) => void;
|
||||
onToggleFile: (fileId: string) => void;
|
||||
onDeleteFile: (fileId: string) => void;
|
||||
onViewFile: (fileId: string) => void;
|
||||
onMergeFromHere: (fileId: string) => void;
|
||||
onSplitFile: (fileId: string) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
}
|
||||
|
||||
const FileThumbnail = ({
|
||||
file,
|
||||
index,
|
||||
totalFiles,
|
||||
selectedFiles,
|
||||
selectionMode,
|
||||
draggedFile,
|
||||
dropTarget,
|
||||
isAnimating,
|
||||
fileRefs,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onViewFile,
|
||||
onMergeFromHere,
|
||||
onSplitFile,
|
||||
onSetStatus,
|
||||
}: FileThumbnailProps) => {
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
fileRefs.current.set(file.id, el);
|
||||
} else {
|
||||
fileRefs.current.delete(file.id);
|
||||
}
|
||||
}}
|
||||
data-file-id={file.id}
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
cursor-grab
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[24rem]
|
||||
flex flex-col items-center justify-center
|
||||
flex-shrink-0
|
||||
shadow-sm
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${draggedFile === file.id ? 'opacity-50 scale-95' : ''}
|
||||
`}
|
||||
style={{
|
||||
transform: (() => {
|
||||
if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) {
|
||||
return 'translateX(20px)';
|
||||
}
|
||||
return 'translateX(0)';
|
||||
})(),
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(file.id)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={() => onDragEnter(file.id)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, file.id)}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 4,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFiles.includes(file.id)}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleFile(file.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File content area */}
|
||||
<div className="file-container w-[90%] h-[80%] relative">
|
||||
{/* Stacked file effect - multiple shadows to simulate pages */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Page count badge */}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{file.pageCount} pages
|
||||
</Badge>
|
||||
|
||||
{/* File name overlay */}
|
||||
<Text
|
||||
className={styles.pageNumber}
|
||||
size="xs"
|
||||
fw={500}
|
||||
c="white"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 5,
|
||||
left: 5,
|
||||
right: 5,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '4px 6px',
|
||||
borderRadius: 4,
|
||||
zIndex: 2,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</Text>
|
||||
|
||||
{/* Hover controls */}
|
||||
<div
|
||||
className={styles.pageHoverControls}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 20,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
zIndex: 3,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<Tooltip label="View File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
onSetStatus(`Opened ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Merge from here">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMergeFromHere(file.id);
|
||||
onSetStatus(`Starting merge from ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<MergeIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Split File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSplitFile(file.id);
|
||||
onSetStatus(`Opening ${file.name} in page editor`);
|
||||
}}
|
||||
>
|
||||
<SplitscreenIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Delete File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<DragIndicatorIcon
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
fontSize: 16,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="w-full px-4 py-2 text-center">
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatFileSize(file.size)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileThumbnail;
|
63
frontend/src/components/editor/PageEditor.module.css
Normal file
63
frontend/src/components/editor/PageEditor.module.css
Normal file
@ -0,0 +1,63 @@
|
||||
/* Page container hover effects */
|
||||
.pageContainer {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.pageContainer:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageNumber {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageHoverControls {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Checkbox container - prevent transform inheritance */
|
||||
.checkboxContainer {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Page movement animations */
|
||||
.pageMoveAnimation {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.pageMoving {
|
||||
z-index: 10;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Multi-page drag indicator */
|
||||
.multiDragIndicator {
|
||||
position: fixed;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
808
frontend/src/components/editor/PageEditor.tsx
Normal file
808
frontend/src/components/editor/PageEditor.tsx
Normal file
@ -0,0 +1,808 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
|
||||
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
|
||||
Stack, Group, Paper, SimpleGrid
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { usePDFProcessor } from "../../hooks/usePDFProcessor";
|
||||
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
import { generateThumbnailForFile } from "../../utils/thumbnailUtils";
|
||||
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
||||
import {
|
||||
RotatePagesCommand,
|
||||
DeletePagesCommand,
|
||||
ReorderPageCommand,
|
||||
MovePagesCommand,
|
||||
ToggleSplitCommand
|
||||
} from "../../commands/pageCommands";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
import styles from './PageEditor.module.css';
|
||||
import PageThumbnail from './PageThumbnail';
|
||||
import BulkSelectionPanel from './BulkSelectionPanel';
|
||||
import DragDropGrid from './shared/DragDropGrid';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import FileUploadSelector from '../shared/FileUploadSelector';
|
||||
|
||||
export interface PageEditorProps {
|
||||
file: { file: File; url: string } | null;
|
||||
setFile?: (file: { file: File; url: string } | null) => void;
|
||||
downloadUrl?: string | null;
|
||||
setDownloadUrl?: (url: string | null) => void;
|
||||
sharedFiles?: { file: File; url: string }[];
|
||||
|
||||
// Optional callbacks to expose internal functions
|
||||
onFunctionsReady?: (functions: {
|
||||
handleUndo: () => void;
|
||||
handleRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
handleRotate: (direction: 'left' | 'right') => void;
|
||||
handleDelete: () => void;
|
||||
handleSplit: () => void;
|
||||
showExportPreview: (selectedOnly: boolean) => void;
|
||||
exportLoading: boolean;
|
||||
selectionMode: boolean;
|
||||
selectedPages: string[];
|
||||
closePdf: () => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const PageEditor = ({
|
||||
file,
|
||||
setFile,
|
||||
downloadUrl,
|
||||
setDownloadUrl,
|
||||
onFunctionsReady,
|
||||
sharedFiles,
|
||||
}: PageEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
|
||||
|
||||
const [pdfDocument, setPdfDocument] = useState<PDFDocument | null>(null);
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [csvInput, setCsvInput] = useState<string>("");
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [filename, setFilename] = useState<string>("");
|
||||
const [draggedPage, setDraggedPage] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [multiPageDrag, setMultiPageDrag] = useState<{pageIds: string[], count: number} | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
|
||||
const [movingPage, setMovingPage] = useState<string | null>(null);
|
||||
const [pagePositions, setPagePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const pageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const fileInputRef = useRef<() => void>(null);
|
||||
|
||||
// Undo/Redo system
|
||||
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||
|
||||
// Process uploaded file
|
||||
const handleFileUpload = useCallback(async (uploadedFile: File | any) => {
|
||||
if (!uploadedFile) {
|
||||
setError('No file provided');
|
||||
return;
|
||||
}
|
||||
|
||||
let fileToProcess: File;
|
||||
|
||||
// Handle FileWithUrl objects from storage
|
||||
if (uploadedFile.storedInIndexedDB && uploadedFile.arrayBuffer) {
|
||||
try {
|
||||
console.log('Converting FileWithUrl to File:', uploadedFile.name);
|
||||
const arrayBuffer = await uploadedFile.arrayBuffer();
|
||||
const blob = new Blob([arrayBuffer], { type: uploadedFile.type || 'application/pdf' });
|
||||
fileToProcess = new File([blob], uploadedFile.name, {
|
||||
type: uploadedFile.type || 'application/pdf',
|
||||
lastModified: uploadedFile.lastModified || Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error converting FileWithUrl:', error);
|
||||
setError('Unable to load file from storage');
|
||||
return;
|
||||
}
|
||||
} else if (uploadedFile instanceof File) {
|
||||
fileToProcess = uploadedFile;
|
||||
} else {
|
||||
setError('Invalid file object');
|
||||
console.error('handleFileUpload received unsupported object:', uploadedFile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileToProcess.type !== 'application/pdf') {
|
||||
setError('Please upload a valid PDF file');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const document = await processPDFFile(fileToProcess);
|
||||
setPdfDocument(document);
|
||||
setFilename(fileToProcess.name.replace(/\.pdf$/i, ''));
|
||||
setSelectedPages([]);
|
||||
|
||||
if (document.pages.length > 0) {
|
||||
// Only store if it's a new file (not from storage)
|
||||
if (!uploadedFile.storedInIndexedDB) {
|
||||
const thumbnail = await generateThumbnailForFile(fileToProcess);
|
||||
await fileStorage.storeFile(fileToProcess, thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
if (setFile) {
|
||||
const fileUrl = URL.createObjectURL(fileToProcess);
|
||||
setFile({ file: fileToProcess, url: fileUrl });
|
||||
}
|
||||
|
||||
setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF';
|
||||
setError(errorMessage);
|
||||
console.error('PDF processing error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [processPDFFile, setFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (file?.file && !pdfDocument) {
|
||||
handleFileUpload(file.file);
|
||||
}
|
||||
}, [file, pdfDocument, handleFileUpload]);
|
||||
|
||||
// Global drag cleanup to handle drops outside valid areas
|
||||
useEffect(() => {
|
||||
const handleGlobalDragEnd = () => {
|
||||
// Clean up drag state when drag operation ends anywhere
|
||||
setDraggedPage(null);
|
||||
setDropTarget(null);
|
||||
setMultiPageDrag(null);
|
||||
setDragPosition(null);
|
||||
};
|
||||
|
||||
const handleGlobalDrop = (e: DragEvent) => {
|
||||
// Prevent default to avoid browser navigation on invalid drops
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (draggedPage) {
|
||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
||||
document.addEventListener('drop', handleGlobalDrop);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
||||
document.removeEventListener('drop', handleGlobalDrop);
|
||||
};
|
||||
}, [draggedPage]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
if (pdfDocument) {
|
||||
setSelectedPages(pdfDocument.pages.map(p => p.id));
|
||||
}
|
||||
}, [pdfDocument]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedPages([]), []);
|
||||
|
||||
const togglePage = useCallback((pageId: string) => {
|
||||
setSelectedPages(prev =>
|
||||
prev.includes(pageId)
|
||||
? prev.filter(id => id !== pageId)
|
||||
: [...prev, pageId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
const newMode = !prev;
|
||||
if (!newMode) {
|
||||
// Clear selections when exiting selection mode
|
||||
setSelectedPages([]);
|
||||
setCsvInput("");
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const parseCSVInput = useCallback((csv: string) => {
|
||||
if (!pdfDocument) return [];
|
||||
|
||||
const pageIds: string[] = [];
|
||||
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
ranges.forEach(range => {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= pdfDocument.totalPages; i++) {
|
||||
if (i > 0) {
|
||||
const page = pdfDocument.pages.find(p => p.pageNumber === i);
|
||||
if (page) pageIds.push(page.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pageNum = parseInt(range);
|
||||
if (pageNum > 0 && pageNum <= pdfDocument.totalPages) {
|
||||
const page = pdfDocument.pages.find(p => p.pageNumber === pageNum);
|
||||
if (page) pageIds.push(page.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pageIds;
|
||||
}, [pdfDocument]);
|
||||
|
||||
const updatePagesFromCSV = useCallback(() => {
|
||||
const pageIds = parseCSVInput(csvInput);
|
||||
setSelectedPages(pageIds);
|
||||
}, [csvInput, parseCSVInput]);
|
||||
|
||||
const handleDragStart = useCallback((pageId: string) => {
|
||||
setDraggedPage(pageId);
|
||||
|
||||
// Check if this is a multi-page drag in selection mode
|
||||
if (selectionMode && selectedPages.includes(pageId) && selectedPages.length > 1) {
|
||||
setMultiPageDrag({
|
||||
pageIds: selectedPages,
|
||||
count: selectedPages.length
|
||||
});
|
||||
} else {
|
||||
setMultiPageDrag(null);
|
||||
}
|
||||
}, [selectionMode, selectedPages]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
// Clean up drag state regardless of where the drop happened
|
||||
setDraggedPage(null);
|
||||
setDropTarget(null);
|
||||
setMultiPageDrag(null);
|
||||
setDragPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedPage) return;
|
||||
|
||||
// Update drag position for multi-page indicator
|
||||
if (multiPageDrag) {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
// Get the element under the mouse cursor
|
||||
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!elementUnderCursor) return;
|
||||
|
||||
// Find the closest page container
|
||||
const pageContainer = elementUnderCursor.closest('[data-page-id]');
|
||||
if (pageContainer) {
|
||||
const pageId = pageContainer.getAttribute('data-page-id');
|
||||
if (pageId && pageId !== draggedPage) {
|
||||
setDropTarget(pageId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if over the end zone
|
||||
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
||||
if (endZone) {
|
||||
setDropTarget('end');
|
||||
return;
|
||||
}
|
||||
|
||||
// If not over any valid drop target, clear it
|
||||
setDropTarget(null);
|
||||
}, [draggedPage, multiPageDrag]);
|
||||
|
||||
const handleDragEnter = useCallback((pageId: string) => {
|
||||
if (draggedPage && pageId !== draggedPage) {
|
||||
setDropTarget(pageId);
|
||||
}
|
||||
}, [draggedPage]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
// Don't clear drop target on drag leave - let dragover handle it
|
||||
}, []);
|
||||
|
||||
const animateReorder = useCallback((pageId: string, targetIndex: number) => {
|
||||
if (!pdfDocument || isAnimating) return;
|
||||
|
||||
// In selection mode, if the dragged page is selected, move all selected pages
|
||||
const pagesToMove = selectionMode && selectedPages.includes(pageId)
|
||||
? selectedPages
|
||||
: [pageId];
|
||||
|
||||
const originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId);
|
||||
if (originalIndex === -1 || originalIndex === targetIndex) return;
|
||||
|
||||
setIsAnimating(true);
|
||||
|
||||
// Get current positions of all pages
|
||||
const currentPositions = new Map<string, { x: number; y: number }>();
|
||||
pdfDocument.pages.forEach((page) => {
|
||||
const element = pageRefs.current.get(page.id);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
currentPositions.set(page.id, { x: rect.left, y: rect.top });
|
||||
}
|
||||
});
|
||||
|
||||
// Execute the reorder - for multi-page, we use a different command
|
||||
if (pagesToMove.length > 1) {
|
||||
// Multi-page move - use MovePagesCommand
|
||||
const command = new MovePagesCommand(pdfDocument, setPdfDocument, pagesToMove, targetIndex);
|
||||
executeCommand(command);
|
||||
} else {
|
||||
// Single page move
|
||||
const command = new ReorderPageCommand(pdfDocument, setPdfDocument, pageId, targetIndex);
|
||||
executeCommand(command);
|
||||
}
|
||||
|
||||
// Wait for DOM to update, then get new positions and animate
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const newPositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
// Get the updated document from the state after command execution
|
||||
// The command has already updated the document, so we need to get the new order
|
||||
const currentDoc = pdfDocument; // This should be the updated version after command
|
||||
|
||||
currentDoc.pages.forEach((page) => {
|
||||
const element = pageRefs.current.get(page.id);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
newPositions.set(page.id, { x: rect.left, y: rect.top });
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate and apply animations
|
||||
currentDoc.pages.forEach((page) => {
|
||||
const element = pageRefs.current.get(page.id);
|
||||
const currentPos = currentPositions.get(page.id);
|
||||
const newPos = newPositions.get(page.id);
|
||||
|
||||
if (element && currentPos && newPos) {
|
||||
const deltaX = currentPos.x - newPos.x;
|
||||
const deltaY = currentPos.y - newPos.y;
|
||||
|
||||
// Apply initial transform (from new position back to old position)
|
||||
element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
||||
element.style.transition = 'none';
|
||||
|
||||
// Force reflow
|
||||
element.offsetHeight;
|
||||
|
||||
// Animate to final position
|
||||
element.style.transition = 'transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
element.style.transform = 'translate(0px, 0px)';
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up after animation
|
||||
setTimeout(() => {
|
||||
currentDoc.pages.forEach((page) => {
|
||||
const element = pageRefs.current.get(page.id);
|
||||
if (element) {
|
||||
element.style.transform = '';
|
||||
element.style.transition = '';
|
||||
}
|
||||
});
|
||||
setIsAnimating(false);
|
||||
}, 400);
|
||||
});
|
||||
});
|
||||
}, [pdfDocument, isAnimating, executeCommand, selectionMode, selectedPages]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => {
|
||||
e.preventDefault();
|
||||
if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return;
|
||||
|
||||
let targetIndex: number;
|
||||
if (targetPageId === 'end') {
|
||||
targetIndex = pdfDocument.pages.length;
|
||||
} else {
|
||||
targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId);
|
||||
if (targetIndex === -1) return;
|
||||
}
|
||||
|
||||
animateReorder(draggedPage, targetIndex);
|
||||
|
||||
setDraggedPage(null);
|
||||
setDropTarget(null);
|
||||
setMultiPageDrag(null);
|
||||
setDragPosition(null);
|
||||
|
||||
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
|
||||
}, [draggedPage, pdfDocument, animateReorder, multiPageDrag]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedPage) {
|
||||
setDropTarget('end');
|
||||
}
|
||||
}, [draggedPage]);
|
||||
|
||||
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
const rotation = direction === 'left' ? -90 : 90;
|
||||
const pagesToRotate = selectionMode
|
||||
? selectedPages
|
||||
: pdfDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPages.length === 0) return;
|
||||
|
||||
const command = new RotatePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
pagesToRotate,
|
||||
rotation
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
|
||||
setStatus(`Rotated ${pageCount} pages ${direction}`);
|
||||
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
const pagesToDelete = selectionMode
|
||||
? selectedPages
|
||||
: pdfDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPages.length === 0) return;
|
||||
|
||||
const command = new DeletePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
pagesToDelete
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
if (selectionMode) {
|
||||
setSelectedPages([]);
|
||||
}
|
||||
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
|
||||
setStatus(`Deleted ${pageCount} pages`);
|
||||
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
const pagesToSplit = selectionMode
|
||||
? selectedPages
|
||||
: pdfDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPages.length === 0) return;
|
||||
|
||||
const command = new ToggleSplitCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
pagesToSplit
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
|
||||
setStatus(`Split markers toggled for ${pageCount} pages`);
|
||||
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
|
||||
|
||||
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
const exportPageIds = selectedOnly ? selectedPages : [];
|
||||
const preview = pdfExportService.getExportInfo(pdfDocument, exportPageIds, selectedOnly);
|
||||
setExportPreview(preview);
|
||||
setShowExportModal(true);
|
||||
}, [pdfDocument, selectedPages]);
|
||||
|
||||
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
||||
if (!pdfDocument) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const exportPageIds = selectedOnly ? selectedPages : [];
|
||||
const errors = pdfExportService.validateExport(pdfDocument, exportPageIds, selectedOnly);
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSplitMarkers = pdfDocument.pages.some(page => page.splitBefore);
|
||||
|
||||
if (hasSplitMarkers) {
|
||||
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
|
||||
selectedOnly,
|
||||
filename,
|
||||
splitDocuments: true
|
||||
}) as { blobs: Blob[]; filenames: string[] };
|
||||
|
||||
result.blobs.forEach((blob, index) => {
|
||||
setTimeout(() => {
|
||||
pdfExportService.downloadFile(blob, result.filenames[index]);
|
||||
}, index * 500);
|
||||
});
|
||||
|
||||
setStatus(`Exported ${result.blobs.length} split documents`);
|
||||
} else {
|
||||
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
|
||||
selectedOnly,
|
||||
filename
|
||||
}) as { blob: Blob; filename: string };
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setStatus('PDF exported successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Export failed';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [pdfDocument, selectedPages, filename]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (undo()) {
|
||||
setStatus('Operation undone');
|
||||
}
|
||||
}, [undo]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (redo()) {
|
||||
setStatus('Operation redone');
|
||||
}
|
||||
}, [redo]);
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
setPdfDocument(null);
|
||||
setFile && setFile(null);
|
||||
}, [setFile]);
|
||||
|
||||
// Expose functions to parent component
|
||||
useEffect(() => {
|
||||
if (onFunctionsReady) {
|
||||
onFunctionsReady({
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleSplit,
|
||||
showExportPreview,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages,
|
||||
closePdf,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
onFunctionsReady,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleSplit,
|
||||
showExportPreview,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages,
|
||||
closePdf
|
||||
]);
|
||||
|
||||
if (!pdfDocument) {
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={loading || pdfLoading} />
|
||||
|
||||
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FileUploadSelector
|
||||
title="Select a PDF to edit"
|
||||
subtitle="Choose a file from storage or upload a new PDF"
|
||||
sharedFiles={sharedFiles || []}
|
||||
onFileSelect={handleFileUpload}
|
||||
allowMultiple={false}
|
||||
accept={["application/pdf"]}
|
||||
loading={loading || pdfLoading}
|
||||
/>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={loading || pdfLoading} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="Enter filename"
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
<Button
|
||||
onClick={toggleSelectionMode}
|
||||
variant={selectionMode ? "filled" : "outline"}
|
||||
color={selectionMode ? "blue" : "gray"}
|
||||
styles={{
|
||||
root: {
|
||||
transition: 'all 0.2s ease',
|
||||
...(selectionMode && {
|
||||
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectionMode ? "Exit Selection" : "Select Pages"}
|
||||
</Button>
|
||||
{selectionMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{selectionMode && (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPages={selectedPages}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DragDropGrid
|
||||
items={pdfDocument.pages}
|
||||
selectedItems={selectedPages}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||
draggedItem={draggedPage}
|
||||
dropTarget={dropTarget}
|
||||
multiItemDrag={multiPageDrag}
|
||||
dragPosition={dragPosition}
|
||||
renderItem={(page, index, refs) => (
|
||||
<PageThumbnail
|
||||
page={page}
|
||||
index={index}
|
||||
totalPages={pdfDocument.pages.length}
|
||||
selectedPages={selectedPages}
|
||||
selectionMode={selectionMode}
|
||||
draggedPage={draggedPage}
|
||||
dropTarget={dropTarget}
|
||||
movingPage={movingPage}
|
||||
isAnimating={isAnimating}
|
||||
pageRefs={refs}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onTogglePage={togglePage}
|
||||
onAnimateReorder={animateReorder}
|
||||
onExecuteCommand={executeCommand}
|
||||
onSetStatus={setStatus}
|
||||
onSetMovingPage={setMovingPage}
|
||||
RotatePagesCommand={RotatePagesCommand}
|
||||
DeletePagesCommand={DeletePagesCommand}
|
||||
ToggleSplitCommand={ToggleSplitCommand}
|
||||
pdfDocument={pdfDocument}
|
||||
setPdfDocument={setPdfDocument}
|
||||
/>
|
||||
)}
|
||||
renderSplitMarker={(page, index) => (
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: '20rem',
|
||||
borderLeft: '2px dashed #3b82f6',
|
||||
backgroundColor: 'transparent',
|
||||
marginLeft: '-0.75rem',
|
||||
marginRight: '-0.75rem',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
opened={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
title="Export Preview"
|
||||
>
|
||||
{exportPreview && (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text>Pages to export:</Text>
|
||||
<Text fw={500}>{exportPreview.pageCount}</Text>
|
||||
</Group>
|
||||
|
||||
{exportPreview.splitCount > 1 && (
|
||||
<Group justify="space-between">
|
||||
<Text>Split into documents:</Text>
|
||||
<Text fw={500}>{exportPreview.splitCount}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text>Estimated size:</Text>
|
||||
<Text fw={500}>{exportPreview.estimatedSize}</Text>
|
||||
</Group>
|
||||
|
||||
{pdfDocument && pdfDocument.pages.some(p => p.splitBefore) && (
|
||||
<Alert color="blue">
|
||||
This will create multiple PDF files based on split markers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => setShowExportModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
loading={exportLoading}
|
||||
onClick={() => {
|
||||
setShowExportModal(false);
|
||||
const selectedOnly = exportPreview.pageCount < (pdfDocument?.totalPages || 0);
|
||||
handleExport(selectedOnly);
|
||||
}}
|
||||
>
|
||||
Export PDF
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<FileInput
|
||||
ref={fileInputRef}
|
||||
accept="application/pdf"
|
||||
onChange={(file) => file && handleFileUpload(file)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditor;
|
191
frontend/src/components/editor/PageEditorControls.tsx
Normal file
191
frontend/src/components/editor/PageEditorControls.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
Paper
|
||||
} from "@mantine/core";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
interface PageEditorControlsProps {
|
||||
// Close/Reset functions
|
||||
onClosePdf: () => void;
|
||||
|
||||
// Undo/Redo
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
|
||||
// Page operations
|
||||
onRotate: (direction: 'left' | 'right') => void;
|
||||
onDelete: () => void;
|
||||
onSplit: () => void;
|
||||
|
||||
// Export functions
|
||||
onExportSelected: () => void;
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPages: string[];
|
||||
}
|
||||
|
||||
const PageEditorControls = ({
|
||||
onClosePdf,
|
||||
onUndo,
|
||||
onRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onRotate,
|
||||
onDelete,
|
||||
onSplit,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages
|
||||
}: PageEditorControlsProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
bottom: '20px',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
radius="xl"
|
||||
shadow="lg"
|
||||
p={16}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderRadius: 32,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
pointerEvents: 'auto',
|
||||
minWidth: 400,
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{/* Close PDF */}
|
||||
<Tooltip label="Close PDF">
|
||||
<ActionIcon
|
||||
onClick={onClosePdf}
|
||||
color="red"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<CloseIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label="Undo">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} size="lg">
|
||||
<UndoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Redo">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} size="lg">
|
||||
<RedoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Page Operations */}
|
||||
<Tooltip label={selectionMode ? "Rotate Selected Left" : "Rotate All Left"}>
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('left')}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
size="lg"
|
||||
>
|
||||
<RotateLeftIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Rotate Selected Right" : "Rotate All Right"}>
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('right')}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
size="lg"
|
||||
>
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
|
||||
<ActionIcon
|
||||
onClick={onDelete}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
color="red"
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
size="lg"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
|
||||
<ActionIcon
|
||||
onClick={onSplit}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
size="lg"
|
||||
>
|
||||
<ContentCutIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Export Controls */}
|
||||
{selectionMode && selectedPages.length > 0 && (
|
||||
<Tooltip label="Export Selected">
|
||||
<ActionIcon
|
||||
onClick={onExportSelected}
|
||||
disabled={exportLoading}
|
||||
color="blue"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Export All">
|
||||
<ActionIcon
|
||||
onClick={onExportAll}
|
||||
disabled={exportLoading}
|
||||
color="green"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditorControls;
|
348
frontend/src/components/editor/PageThumbnail.tsx
Normal file
348
frontend/src/components/editor/PageThumbnail.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
import React from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { PDFPage } from '../../types/pageEditor';
|
||||
import styles from './PageEditor.module.css';
|
||||
|
||||
interface PageThumbnailProps {
|
||||
page: PDFPage;
|
||||
index: number;
|
||||
totalPages: number;
|
||||
selectedPages: string[];
|
||||
selectionMode: boolean;
|
||||
draggedPage: string | null;
|
||||
dropTarget: string | null;
|
||||
movingPage: string | null;
|
||||
isAnimating: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
onDragStart: (pageId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (pageId: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, pageId: string) => void;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: (pageId: string, targetIndex: number) => void;
|
||||
onExecuteCommand: (command: any) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onSetMovingPage: (pageId: string | null) => void;
|
||||
RotatePagesCommand: any;
|
||||
DeletePagesCommand: any;
|
||||
ToggleSplitCommand: any;
|
||||
pdfDocument: any;
|
||||
setPdfDocument: any;
|
||||
}
|
||||
|
||||
const PageThumbnail = ({
|
||||
page,
|
||||
index,
|
||||
totalPages,
|
||||
selectedPages,
|
||||
selectionMode,
|
||||
draggedPage,
|
||||
dropTarget,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
pageRefs,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onTogglePage,
|
||||
onAnimateReorder,
|
||||
onExecuteCommand,
|
||||
onSetStatus,
|
||||
onSetMovingPage,
|
||||
RotatePagesCommand,
|
||||
DeletePagesCommand,
|
||||
ToggleSplitCommand,
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
}: PageThumbnailProps) => {
|
||||
return (
|
||||
<div
|
||||
data-page-id={page.id}
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
cursor-grab
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[20rem]
|
||||
flex items-center justify-center
|
||||
flex-shrink-0
|
||||
shadow-sm
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${draggedPage === page.id ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.id ? 'page-moving' : ''}
|
||||
`}
|
||||
style={{
|
||||
transform: (() => {
|
||||
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) {
|
||||
return 'translateX(20px)';
|
||||
}
|
||||
return 'translateX(0)';
|
||||
})(),
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(page.id)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={() => onDragEnter(page.id)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, page.id)}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 4,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPages.includes(page.id)}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onTogglePage(page.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="page-container w-[90%] h-[90%]">
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={page.thumbnail}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
transform: `rotate(${page.rotation}deg)`,
|
||||
transition: 'transform 0.3s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
className={styles.pageNumber}
|
||||
size="sm"
|
||||
fw={500}
|
||||
c="white"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 5,
|
||||
background: 'rgba(162, 201, 255, 0.8)',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
zIndex: 2,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out'
|
||||
}}
|
||||
>
|
||||
{page.pageNumber}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
className={styles.pageHoverControls}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 20,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
zIndex: 3,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<Tooltip label="Move Left">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
disabled={index === 0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.id);
|
||||
onAnimateReorder(page.id, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 500);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowBackIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Move Right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
disabled={index === totalPages - 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.id);
|
||||
onAnimateReorder(page.id, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 500);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowForwardIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Rotate Left">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new RotatePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id],
|
||||
-90
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
||||
}}
|
||||
>
|
||||
<RotateLeftIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Rotate Right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new RotatePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id],
|
||||
90
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
||||
}}
|
||||
>
|
||||
<RotateRightIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Delete Page">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new DeletePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id]
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Deleted page ${page.pageNumber}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{index > 0 && (
|
||||
<Tooltip label="Split Here">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new ToggleSplitCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id]
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Split marker toggled for page ${page.pageNumber}`);
|
||||
}}
|
||||
>
|
||||
<ContentCutIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragIndicatorIcon
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
fontSize: 16,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageThumbnail;
|
131
frontend/src/components/editor/shared/DragDropGrid.tsx
Normal file
131
frontend/src/components/editor/shared/DragDropGrid.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import styles from '../PageEditor.module.css';
|
||||
|
||||
interface DragDropItem {
|
||||
id: string;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
interface DragDropGridProps<T extends DragDropItem> {
|
||||
items: T[];
|
||||
selectedItems: string[];
|
||||
selectionMode: boolean;
|
||||
isAnimating: boolean;
|
||||
onDragStart: (itemId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (itemId: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, targetId: string | 'end') => void;
|
||||
onEndZoneDragEnter: () => void;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
draggedItem: string | null;
|
||||
dropTarget: string | null;
|
||||
multiItemDrag: {itemIds: string[], count: number} | null;
|
||||
dragPosition: {x: number, y: number} | null;
|
||||
}
|
||||
|
||||
const DragDropGrid = <T extends DragDropItem>({
|
||||
items,
|
||||
selectedItems,
|
||||
selectionMode,
|
||||
isAnimating,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onEndZoneDragEnter,
|
||||
renderItem,
|
||||
renderSplitMarker,
|
||||
draggedItem,
|
||||
dropTarget,
|
||||
multiItemDrag,
|
||||
dragPosition,
|
||||
}: DragDropGridProps<T>) => {
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Global drag cleanup
|
||||
useEffect(() => {
|
||||
const handleGlobalDragEnd = () => {
|
||||
onDragEnd();
|
||||
};
|
||||
|
||||
const handleGlobalDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (draggedItem) {
|
||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
||||
document.addEventListener('drop', handleGlobalDrop);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
||||
document.removeEventListener('drop', handleGlobalDrop);
|
||||
};
|
||||
}, [draggedItem, onDragEnd]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '1.5rem',
|
||||
justifyContent: 'flex-start',
|
||||
paddingBottom: '100px'
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Split marker */}
|
||||
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
|
||||
|
||||
{/* Item */}
|
||||
{renderItem(item, index, itemRefs)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* End drop zone */}
|
||||
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
|
||||
<div
|
||||
data-drop-zone="end"
|
||||
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
|
||||
dropTarget === 'end'
|
||||
? 'ring-2 ring-green-500 bg-green-50'
|
||||
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
|
||||
}`}
|
||||
style={{ borderRadius: '12px' }}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={onEndZoneDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, 'end')}
|
||||
>
|
||||
<div className="text-gray-500 text-sm text-center font-medium">
|
||||
Drop here to<br />move to end
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-item drag indicator */}
|
||||
{multiItemDrag && dragPosition && (
|
||||
<div
|
||||
className={styles.multiDragIndicator}
|
||||
style={{
|
||||
left: dragPosition.x,
|
||||
top: dragPosition.y,
|
||||
}}
|
||||
>
|
||||
{multiItemDrag.count} items
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragDropGrid;
|
201
frontend/src/components/fileManagement/FileCard.tsx
Normal file
201
frontend/src/components/fileManagement/FileCard.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileWithUrl;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onView?: () => void;
|
||||
onEdit?: () => void;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect }: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
p="xs"
|
||||
style={{
|
||||
width: 225,
|
||||
minWidth: 180,
|
||||
maxWidth: 260,
|
||||
cursor: onDoubleClick ? "pointer" : undefined,
|
||||
position: 'relative',
|
||||
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Box
|
||||
style={{
|
||||
border: "2px solid #e0e0e0",
|
||||
borderRadius: 8,
|
||||
width: 90,
|
||||
height: 120,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
background: "#fafbfc",
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{/* Hover action buttons */}
|
||||
{isHovered && (onView || onEdit) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 4,
|
||||
padding: 2
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onView && (
|
||||
<Tooltip label="View in Viewer">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onView();
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Tooltip label="Open in File Editor">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{thumb ? (
|
||||
<Image
|
||||
src={thumb}
|
||||
alt="PDF thumbnail"
|
||||
height={110}
|
||||
width={80}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
/>
|
||||
) : isGenerating ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: '2px solid #ddd',
|
||||
borderTop: '2px solid #666',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginBottom: 8
|
||||
}} />
|
||||
<Text size="xs" c="dimmed">Generating...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
|
||||
size={60}
|
||||
radius="sm"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
||||
</ThemeIcon>
|
||||
{file.size > 100 * 1024 * 1024 && (
|
||||
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
||||
{file.name}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" justify="center">
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{getFileSize(file)}
|
||||
</Badge>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{getFileDate(file)}
|
||||
</Badge>
|
||||
{file.storedInIndexedDB && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
|
||||
>
|
||||
DB
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
mt={4}
|
||||
>
|
||||
{t("delete", "Remove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileCard;
|
92
frontend/src/components/fileManagement/StorageStatsCard.tsx
Normal file
92
frontend/src/components/fileManagement/StorageStatsCard.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import { StorageStats } from "../../services/fileStorage";
|
||||
import { formatFileSize } from "../../utils/fileUtils";
|
||||
import { getStorageUsagePercent } from "../../utils/storageUtils";
|
||||
import { StorageConfig } from "../../types/file";
|
||||
|
||||
interface StorageStatsCardProps {
|
||||
storageStats: StorageStats | null;
|
||||
filesCount: number;
|
||||
onClearAll: () => void;
|
||||
onReloadFiles: () => void;
|
||||
storageConfig: StorageConfig;
|
||||
}
|
||||
|
||||
const StorageStatsCard = ({
|
||||
storageStats,
|
||||
filesCount,
|
||||
onClearAll,
|
||||
onReloadFiles,
|
||||
storageConfig,
|
||||
}: StorageStatsCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!storageStats) return null;
|
||||
|
||||
const storageUsagePercent = getStorageUsagePercent(storageStats);
|
||||
const totalUsed = storageStats.totalSize || storageStats.used;
|
||||
const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
|
||||
const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
|
||||
|
||||
return (
|
||||
<Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
|
||||
<Card withBorder p="sm">
|
||||
<Group align="center" gap="md">
|
||||
<StorageIcon />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
|
||||
</Text>
|
||||
<Progress
|
||||
value={hardLimitPercent}
|
||||
color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
|
||||
size="sm"
|
||||
mt={4}
|
||||
/>
|
||||
<Group justify="space-between" mt={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{storageStats.fileCount} files • {t("storage.approximateSize", "Approximate size")}
|
||||
</Text>
|
||||
<Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
|
||||
{Math.round(hardLimitPercent)}% used
|
||||
</Text>
|
||||
</Group>
|
||||
{isNearLimit && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
{filesCount > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={onClearAll}
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t("fileManager.clearAll", "Clear All")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onReloadFiles}
|
||||
>
|
||||
{t("fileManager.reloadFiles", "Reload Files")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageStatsCard;
|
263
frontend/src/components/shared/FilePickerModal.tsx
Normal file
263
frontend/src/components/shared/FilePickerModal.tsx
Normal file
@ -0,0 +1,263 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Checkbox,
|
||||
ScrollArea,
|
||||
Box,
|
||||
Image,
|
||||
Badge,
|
||||
ThemeIcon,
|
||||
SimpleGrid
|
||||
} from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FilePickerModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
sharedFiles: any[];
|
||||
onSelectFiles: (selectedFiles: any[]) => void;
|
||||
}
|
||||
|
||||
const FilePickerModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
sharedFiles,
|
||||
onSelectFiles,
|
||||
}: FilePickerModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
// Reset selection when modal opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setSelectedFileIds([]);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const toggleFileSelection = (fileId: string) => {
|
||||
setSelectedFileIds(prev =>
|
||||
prev.includes(fileId)
|
||||
? prev.filter(id => id !== fileId)
|
||||
: [...prev, fileId]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedFileIds(sharedFiles.map(f => f.id || f.name));
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
setSelectedFileIds([]);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const selectedFiles = sharedFiles.filter(f =>
|
||||
selectedFileIds.includes(f.id || f.name)
|
||||
);
|
||||
|
||||
// Convert FileWithUrl objects to proper File objects if needed
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(async (fileItem) => {
|
||||
console.log('Converting file item:', fileItem);
|
||||
|
||||
// If it's already a File object, return as is
|
||||
if (fileItem instanceof File) {
|
||||
console.log('File is already a File object');
|
||||
return fileItem;
|
||||
}
|
||||
|
||||
// If it has a file property, use that
|
||||
if (fileItem.file && fileItem.file instanceof File) {
|
||||
console.log('Using .file property');
|
||||
return fileItem.file;
|
||||
}
|
||||
|
||||
// If it's a FileWithUrl from storage, reconstruct the File
|
||||
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
|
||||
try {
|
||||
console.log('Reconstructing file from storage:', fileItem.name, fileItem);
|
||||
const arrayBuffer = await fileItem.arrayBuffer();
|
||||
console.log('Got arrayBuffer:', arrayBuffer);
|
||||
|
||||
const blob = new Blob([arrayBuffer], { type: fileItem.type || 'application/pdf' });
|
||||
console.log('Created blob:', blob);
|
||||
|
||||
const reconstructedFile = new File([blob], fileItem.name, {
|
||||
type: fileItem.type || 'application/pdf',
|
||||
lastModified: fileItem.lastModified || Date.now()
|
||||
});
|
||||
console.log('Reconstructed file:', reconstructedFile, 'instanceof File:', reconstructedFile instanceof File);
|
||||
return reconstructedFile;
|
||||
} catch (error) {
|
||||
console.error('Error reconstructing file:', error, fileItem);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('No valid conversion method found for:', fileItem);
|
||||
return null; // Don't return invalid objects
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out any null values from failed conversions
|
||||
const validFiles = convertedFiles.filter(f => f !== null);
|
||||
|
||||
onSelectFiles(validFiles);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
|
||||
size="lg"
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{sharedFiles.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t("fileUpload.noFilesInStorage", "No files available in storage. Upload some files first.")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{/* Selection controls */}
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{sharedFiles.length} {t("fileUpload.filesAvailable", "files available")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={selectAll}>
|
||||
{t("pageEdit.selectAll", "Select All")}
|
||||
</Button>
|
||||
<Button size="xs" variant="light" onClick={selectNone}>
|
||||
{t("pageEdit.deselectAll", "Select None")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* File grid */}
|
||||
<ScrollArea.Autosize mah={400}>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{sharedFiles.map((file) => {
|
||||
const fileId = file.id || file.name;
|
||||
const isSelected = selectedFileIds.includes(fileId);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={fileId}
|
||||
p="sm"
|
||||
style={{
|
||||
border: isSelected
|
||||
? '2px solid var(--mantine-color-blue-6)'
|
||||
: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: 8,
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-blue-0)'
|
||||
: 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onClick={() => toggleFileSelection(fileId)}
|
||||
>
|
||||
<Group gap="sm" align="flex-start">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFileSelection(fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<Box
|
||||
style={{
|
||||
width: 60,
|
||||
height: 80,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{file.thumbnail ? (
|
||||
<Image
|
||||
src={file.thumbnail}
|
||||
alt="PDF thumbnail"
|
||||
height={70}
|
||||
width={50}
|
||||
fit="contain"
|
||||
/>
|
||||
) : (
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size={40}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 24 }} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* File info */}
|
||||
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} lineClamp={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{formatFileSize(file.size || (file.file?.size || 0))}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
{/* Selection summary */}
|
||||
{selectedFileIds.length > 0 && (
|
||||
<Text size="sm" c="blue" ta="center">
|
||||
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={onClose}>
|
||||
{t("close", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedFileIds.length === 0}
|
||||
>
|
||||
{selectedFileIds.length > 0
|
||||
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
|
||||
: t("fileUpload.loadFromStorage", "Load Files")
|
||||
}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePickerModal;
|
153
frontend/src/components/shared/FileUploadSelector.tsx
Normal file
153
frontend/src/components/shared/FileUploadSelector.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Stack, Button, Text, Center } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FilePickerModal from './FilePickerModal';
|
||||
|
||||
interface FileUploadSelectorProps {
|
||||
// Appearance
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showDropzone?: boolean;
|
||||
|
||||
// File handling
|
||||
sharedFiles?: any[];
|
||||
onFileSelect: (file: File) => void;
|
||||
onFilesSelect?: (files: File[]) => void;
|
||||
allowMultiple?: boolean;
|
||||
accept?: string[];
|
||||
|
||||
// Loading state
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const FileUploadSelector = ({
|
||||
title,
|
||||
subtitle,
|
||||
showDropzone = true,
|
||||
sharedFiles = [],
|
||||
onFileSelect,
|
||||
onFilesSelect,
|
||||
allowMultiple = false,
|
||||
accept = ["application/pdf"],
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: FileUploadSelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
|
||||
const handleFileUpload = useCallback((uploadedFiles: File[]) => {
|
||||
if (uploadedFiles.length === 0) return;
|
||||
|
||||
if (allowMultiple && onFilesSelect) {
|
||||
onFilesSelect(uploadedFiles);
|
||||
} else {
|
||||
onFileSelect(uploadedFiles[0]);
|
||||
}
|
||||
}, [allowMultiple, onFileSelect, onFilesSelect]);
|
||||
|
||||
const handleStorageSelection = useCallback((selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
if (allowMultiple && onFilesSelect) {
|
||||
onFilesSelect(selectedFiles);
|
||||
} else {
|
||||
onFileSelect(selectedFiles[0]);
|
||||
}
|
||||
}, [allowMultiple, onFileSelect, onFilesSelect]);
|
||||
|
||||
// Get default title and subtitle from translations if not provided
|
||||
const displayTitle = title || t(allowMultiple ? "fileUpload.selectFiles" : "fileUpload.selectFile",
|
||||
allowMultiple ? "Select files" : "Select a file");
|
||||
const displaySubtitle = subtitle || t(allowMultiple ? "fileUpload.chooseFromStorageMultiple" : "fileUpload.chooseFromStorage",
|
||||
allowMultiple ? "Choose files from storage or upload new PDFs" : "Choose a file from storage or upload a new PDF");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack align="center" gap="xl">
|
||||
{/* Title and description */}
|
||||
<Stack align="center" gap="md">
|
||||
<UploadFileIcon style={{ fontSize: 64 }} />
|
||||
<Text size="xl" fw={500}>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
<Text size="md" c="dimmed">
|
||||
{displaySubtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Action buttons */}
|
||||
<Stack align="center" gap="md" w="100%">
|
||||
<Button
|
||||
variant="filled"
|
||||
size="lg"
|
||||
onClick={() => setShowFilePickerModal(true)}
|
||||
disabled={disabled || sharedFiles.length === 0}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? "Loading..." : `Load from Storage (${sharedFiles.length} files available)`}
|
||||
</Button>
|
||||
|
||||
<Text size="md" c="dimmed">
|
||||
{t("fileUpload.or", "or")}
|
||||
</Text>
|
||||
|
||||
{showDropzone ? (
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={accept}
|
||||
multiple={allowMultiple}
|
||||
disabled={disabled || loading}
|
||||
style={{ width: '100%', minHeight: 120 }}
|
||||
>
|
||||
<Center>
|
||||
<Stack align="center" gap="sm">
|
||||
<Text size="md" fw={500}>
|
||||
{t(allowMultiple ? "fileUpload.dropFilesHere" : "fileUpload.dropFileHere",
|
||||
allowMultiple ? "Drop files here or click to upload" : "Drop file here or click to upload")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{accept.includes('application/pdf')
|
||||
? t("fileUpload.pdfFilesOnly", "PDF files only")
|
||||
: t("fileUpload.supportedFileTypes", "Supported file types")
|
||||
}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Dropzone>
|
||||
) : (
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={accept}
|
||||
multiple={allowMultiple}
|
||||
disabled={disabled || loading}
|
||||
style={{ display: 'contents' }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
>
|
||||
{t(allowMultiple ? "fileUpload.uploadFiles" : "fileUpload.uploadFile",
|
||||
allowMultiple ? "Upload Files" : "Upload File")}
|
||||
</Button>
|
||||
</Dropzone>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
sharedFiles={sharedFiles}
|
||||
onSelectFiles={handleStorageSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadSelector;
|
@ -69,3 +69,20 @@
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ripple animation */
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
219
frontend/src/components/shared/LanguageSelector.tsx
Normal file
219
frontend/src/components/shared/LanguageSelector.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '../../i18n';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import styles from './LanguageSelector.module.css';
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
|
||||
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
|
||||
|
||||
const languageOptions = Object.entries(supportedLanguages)
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([code, name]) => ({
|
||||
value: code,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
||||
// Create ripple effect at click position
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
setRippleEffect({ x, y, key: Date.now() });
|
||||
|
||||
// Start transition animation
|
||||
setIsChanging(true);
|
||||
setPendingLanguage(value);
|
||||
|
||||
// Simulate processing time for smooth transition
|
||||
setTimeout(() => {
|
||||
i18n.changeLanguage(value);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsChanging(false);
|
||||
setPendingLanguage(null);
|
||||
setOpened(false);
|
||||
|
||||
// Clear ripple effect
|
||||
setTimeout(() => setRippleEffect(null), 100);
|
||||
}, 300);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
supportedLanguages['en-GB'];
|
||||
|
||||
// Trigger animation when dropdown opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setAnimationTriggered(false);
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => setAnimationTriggered(true), 50);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes ripple-expand {
|
||||
0% {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={600}
|
||||
position="bottom-start"
|
||||
offset={8}
|
||||
transitionProps={{
|
||||
transition: 'scale-y',
|
||||
duration: 200,
|
||||
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
}}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
|
||||
styles={{
|
||||
root: {
|
||||
border: 'none',
|
||||
color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7],
|
||||
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.languageText}>
|
||||
{currentLanguage}
|
||||
</span>
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`,
|
||||
}}
|
||||
>
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={styles.languageItem}
|
||||
style={{
|
||||
opacity: animationTriggered ? 1 : 0,
|
||||
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
|
||||
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={(event) => handleLanguageChange(option.value, event)}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: '4px',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
justifyContent: 'flex-start',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
|
||||
) : 'transparent',
|
||||
color: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
|
||||
),
|
||||
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: option.value === i18n.language ? (
|
||||
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
|
||||
) : (
|
||||
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
|
||||
),
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '13px',
|
||||
fontWeight: option.value === i18n.language ? 600 : 400,
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
{/* Ripple effect */}
|
||||
{rippleEffect && pendingLanguage === option.value && (
|
||||
<div
|
||||
key={rippleEffect.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: rippleEffect.x,
|
||||
top: rippleEffect.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: theme.colors.blue[4],
|
||||
opacity: 0.6,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
70
frontend/src/components/shared/QuickAccessBar.tsx
Normal file
70
frontend/src/components/shared/QuickAccessBar.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { ActionIcon, Stack, Tooltip } from "@mantine/core";
|
||||
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import MenuBookIcon from "@mui/icons-material/MenuBook";
|
||||
import AppsIcon from "@mui/icons-material/Apps";
|
||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
|
||||
interface QuickAccessBarProps {
|
||||
onToolsClick: () => void;
|
||||
onReaderToggle: () => void;
|
||||
selectedToolKey?: string;
|
||||
toolRegistry: any;
|
||||
leftPanelView: 'toolPicker' | 'toolContent';
|
||||
readerMode: boolean;
|
||||
}
|
||||
|
||||
const QuickAccessBar = ({
|
||||
onToolsClick,
|
||||
onReaderToggle,
|
||||
selectedToolKey,
|
||||
toolRegistry,
|
||||
leftPanelView,
|
||||
readerMode,
|
||||
}: QuickAccessBarProps) => {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-screen flex flex-col w-20 ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
|
||||
style={{
|
||||
padding: '1rem 0.5rem',
|
||||
backgroundColor: 'var(--bg-muted)'
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg" align="center" className="flex-1">
|
||||
{/* All Tools Button */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<ActionIcon
|
||||
size="xl"
|
||||
variant={leftPanelView === 'toolPicker' && !readerMode ? "filled" : "subtle"}
|
||||
onClick={onToolsClick}
|
||||
>
|
||||
<AppsIcon sx={{ fontSize: 28 }} />
|
||||
</ActionIcon>
|
||||
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Tools</span>
|
||||
</div>
|
||||
|
||||
{/* Reader Mode Button */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<ActionIcon
|
||||
size="xl"
|
||||
variant={readerMode ? "filled" : "subtle"}
|
||||
onClick={onReaderToggle}
|
||||
>
|
||||
<MenuBookIcon sx={{ fontSize: 28 }} />
|
||||
</ActionIcon>
|
||||
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Read</span>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAccessBar;
|
55
frontend/src/components/shared/RainbowThemeProvider.tsx
Normal file
55
frontend/src/components/shared/RainbowThemeProvider.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: 'light' | 'dark' | 'rainbow';
|
||||
isRainbowMode: boolean;
|
||||
isToggleDisabled: boolean;
|
||||
toggleTheme: () => void;
|
||||
activateRainbow: () => void;
|
||||
deactivateRainbow: () => void;
|
||||
}
|
||||
|
||||
const RainbowThemeContext = createContext<RainbowThemeContextType | null>(null);
|
||||
|
||||
export function useRainbowThemeContext() {
|
||||
const context = useContext(RainbowThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useRainbowThemeContext must be used within RainbowThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface RainbowThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
const rainbowTheme = useRainbowTheme();
|
||||
|
||||
// Determine the Mantine color scheme
|
||||
const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColorSchemeScript defaultColorScheme={mantineColorScheme} />
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
<MantineProvider
|
||||
theme={mantineTheme}
|
||||
defaultColorScheme={mantineColorScheme}
|
||||
forceColorScheme={mantineColorScheme}
|
||||
>
|
||||
<div
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
106
frontend/src/components/shared/TopControls.tsx
Normal file
106
frontend/src/components/shared/TopControls.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React from "react";
|
||||
import { Button, SegmentedControl } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import { Group } from "@mantine/core";
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{
|
||||
label: (
|
||||
<Group gap={5}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</Group>
|
||||
),
|
||||
value: "viewer",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<EditNoteIcon fontSize="small" />
|
||||
</Group>
|
||||
),
|
||||
value: "pageEditor",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<InsertDriveFileIcon fontSize="small" />
|
||||
</Group>
|
||||
),
|
||||
value: "fileManager",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<FolderIcon fontSize="small" />
|
||||
</Group>
|
||||
),
|
||||
value: "fileEditor",
|
||||
},
|
||||
];
|
||||
|
||||
interface TopControlsProps {
|
||||
currentView: string;
|
||||
setCurrentView: (view: string) => void;
|
||||
}
|
||||
|
||||
const TopControls = ({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
}: TopControlsProps) => {
|
||||
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
|
||||
|
||||
const getThemeIcon = () => {
|
||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
||||
if (themeMode === "dark") return <LightModeIcon />;
|
||||
return <DarkModeIcon />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-auto flex gap-2 items-center">
|
||||
<Button
|
||||
onClick={toggleTheme}
|
||||
variant="subtle"
|
||||
size="md"
|
||||
aria-label="Toggle theme"
|
||||
disabled={isToggleDisabled}
|
||||
className={isRainbowMode ? rainbowStyles.rainbowButton : ''}
|
||||
title={
|
||||
isToggleDisabled
|
||||
? "Button disabled for 3 seconds..."
|
||||
: isRainbowMode
|
||||
? "Rainbow Mode Active! Click to exit"
|
||||
: "Toggle theme (click rapidly 6 times for a surprise!)"
|
||||
}
|
||||
style={isToggleDisabled ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||
>
|
||||
{getThemeIcon()}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
||||
<SegmentedControl
|
||||
data={VIEW_OPTIONS}
|
||||
value={currentView}
|
||||
onChange={setCurrentView}
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="md"
|
||||
fullWidth
|
||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopControls;
|
@ -17,7 +17,7 @@ interface ToolPickerProps {
|
||||
toolRegistry: ToolRegistry;
|
||||
}
|
||||
|
||||
const ToolPicker: React.FC<ToolPickerProps> = ({ selectedToolKey, onSelect, toolRegistry }) => {
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
|
74
frontend/src/components/tools/ToolRenderer.tsx
Normal file
74
frontend/src/components/tools/ToolRenderer.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
|
||||
interface ToolRendererProps {
|
||||
selectedToolKey: string;
|
||||
selectedTool: any;
|
||||
pdfFile: any;
|
||||
files: FileWithUrl[];
|
||||
downloadUrl: string | null;
|
||||
setDownloadUrl: (url: string | null) => void;
|
||||
toolParams: any;
|
||||
updateParams: (params: any) => void;
|
||||
}
|
||||
|
||||
const ToolRenderer = ({
|
||||
selectedToolKey,
|
||||
selectedTool,
|
||||
pdfFile,
|
||||
files,
|
||||
downloadUrl,
|
||||
setDownloadUrl,
|
||||
toolParams,
|
||||
updateParams,
|
||||
}: ToolRendererProps) => {
|
||||
if (!selectedTool || !selectedTool.component) {
|
||||
return <div>Tool not found</div>;
|
||||
}
|
||||
|
||||
const ToolComponent = selectedTool.component;
|
||||
|
||||
// Pass tool-specific props
|
||||
switch (selectedToolKey) {
|
||||
case "split":
|
||||
return (
|
||||
<ToolComponent
|
||||
file={pdfFile}
|
||||
downloadUrl={downloadUrl}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
case "compress":
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
setLoading={(loading: boolean) => {}} // TODO: Add loading state
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
case "merge":
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ToolRenderer;
|
39
frontend/src/hooks/useFileWithUrl.ts
Normal file
39
frontend/src/hooks/useFileWithUrl.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to convert a File object to { file: File; url: string } format
|
||||
* Creates blob URL on-demand and handles cleanup
|
||||
*/
|
||||
export function useFileWithUrl(file: File | null): { file: File; url: string } | null {
|
||||
return useMemo(() => {
|
||||
if (!file) return null;
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
// Return object with cleanup function
|
||||
const result = { file, url };
|
||||
|
||||
// Store cleanup function for later use
|
||||
(result as any)._cleanup = () => URL.revokeObjectURL(url);
|
||||
|
||||
return result;
|
||||
}, [file]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook variant that returns cleanup function separately
|
||||
*/
|
||||
export function useFileWithUrlAndCleanup(file: File | null): {
|
||||
fileObj: { file: File; url: string } | null;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
return useMemo(() => {
|
||||
if (!file) return { fileObj: null, cleanup: () => {} };
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const fileObj = { file, url };
|
||||
const cleanup = () => URL.revokeObjectURL(url);
|
||||
|
||||
return { fileObj, cleanup };
|
||||
}, [file]);
|
||||
}
|
92
frontend/src/hooks/usePDFProcessor.ts
Normal file
92
frontend/src/hooks/usePDFProcessor.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getDocument } from 'pdfjs-dist';
|
||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||
|
||||
export function usePDFProcessor() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const generatePageThumbnail = useCallback(async (
|
||||
file: File,
|
||||
pageNumber: number,
|
||||
scale: number = 0.5
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
|
||||
return thumbnail;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate thumbnail:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const processPDFFile = useCallback(async (file: File): Promise<PDFDocument> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
|
||||
// Generate thumbnails for all pages
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const thumbnail = await generatePageThumbnail(file, i);
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
|
||||
const document: PDFDocument = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
file,
|
||||
pages,
|
||||
totalPages
|
||||
};
|
||||
|
||||
return document;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to process PDF';
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [generatePageThumbnail]);
|
||||
|
||||
return {
|
||||
processPDFFile,
|
||||
generatePageThumbnail,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
}
|
200
frontend/src/hooks/useRainbowTheme.ts
Normal file
200
frontend/src/hooks/useRainbowTheme.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'rainbow';
|
||||
|
||||
interface RainbowThemeHook {
|
||||
themeMode: ThemeMode;
|
||||
isRainbowMode: boolean;
|
||||
isToggleDisabled: boolean;
|
||||
toggleTheme: () => void;
|
||||
activateRainbow: () => void;
|
||||
deactivateRainbow: () => void;
|
||||
}
|
||||
|
||||
export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): RainbowThemeHook {
|
||||
// Get theme from localStorage or use initial
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
|
||||
const stored = localStorage.getItem('stirling-theme');
|
||||
if (stored && ['light', 'dark', 'rainbow'].includes(stored)) {
|
||||
return stored as ThemeMode;
|
||||
}
|
||||
return initialTheme;
|
||||
});
|
||||
|
||||
// Track rapid toggles for easter egg
|
||||
const toggleCount = useRef(0);
|
||||
const lastToggleTime = useRef(Date.now());
|
||||
const [isToggleDisabled, setIsToggleDisabled] = useState(false);
|
||||
|
||||
// Save theme to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('stirling-theme', themeMode);
|
||||
|
||||
// Apply rainbow class to body if in rainbow mode
|
||||
if (themeMode === 'rainbow') {
|
||||
document.body.classList.add('rainbow-mode-active');
|
||||
|
||||
// Show easter egg notification
|
||||
showRainbowNotification();
|
||||
} else {
|
||||
document.body.classList.remove('rainbow-mode-active');
|
||||
}
|
||||
}, [themeMode]);
|
||||
|
||||
const showRainbowNotification = () => {
|
||||
// Remove any existing notification
|
||||
const existingNotification = document.getElementById('rainbow-notification');
|
||||
if (existingNotification) {
|
||||
existingNotification.remove();
|
||||
}
|
||||
|
||||
// Create and show rainbow notification
|
||||
const notification = document.createElement('div');
|
||||
notification.id = 'rainbow-notification';
|
||||
notification.innerHTML = '🌈 RAINBOW MODE ACTIVATED! 🌈<br><small>Button disabled for 3 seconds, then click to exit</small>';
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(45deg, #ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff);
|
||||
background-size: 300% 300%;
|
||||
animation: rainbowBackground 1s ease infinite;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 25px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
z-index: 1000;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.8);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove notification after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (notification) {
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const showExitNotification = () => {
|
||||
// Remove any existing notification
|
||||
const existingNotification = document.getElementById('rainbow-exit-notification');
|
||||
if (existingNotification) {
|
||||
existingNotification.remove();
|
||||
}
|
||||
|
||||
// Create and show exit notification
|
||||
const notification = document.createElement('div');
|
||||
notification.id = 'rainbow-exit-notification';
|
||||
notification.innerHTML = '🌙 Rainbow mode deactivated';
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(45deg, #333, #666);
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 25px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
z-index: 1000;
|
||||
border: 2px solid #999;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove notification after 2 seconds
|
||||
setTimeout(() => {
|
||||
if (notification) {
|
||||
notification.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
// Don't allow toggle if disabled
|
||||
if (isToggleDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Simple exit from rainbow mode with single click (after cooldown period)
|
||||
if (themeMode === 'rainbow') {
|
||||
setThemeMode('light');
|
||||
console.log('🌈 Rainbow mode deactivated. Thanks for trying it!');
|
||||
showExitNotification();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset counter if too much time has passed (2.5 seconds)
|
||||
if (currentTime - lastToggleTime.current > 2500) {
|
||||
toggleCount.current = 1;
|
||||
} else {
|
||||
toggleCount.current++;
|
||||
}
|
||||
lastToggleTime.current = currentTime;
|
||||
|
||||
// Easter egg: Activate rainbow mode after 6 rapid toggles
|
||||
if (toggleCount.current >= 6) {
|
||||
setThemeMode('rainbow');
|
||||
console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!');
|
||||
console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!');
|
||||
|
||||
// Disable toggle for 3 seconds
|
||||
setIsToggleDisabled(true);
|
||||
setTimeout(() => {
|
||||
setIsToggleDisabled(false);
|
||||
console.log('🌈 Theme toggle re-enabled! Click once to exit rainbow mode.');
|
||||
}, 3000);
|
||||
|
||||
// Reset counter
|
||||
toggleCount.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal theme switching
|
||||
setThemeMode(prevMode => prevMode === 'light' ? 'dark' : 'light');
|
||||
}, [themeMode, isToggleDisabled]);
|
||||
|
||||
const activateRainbow = useCallback(() => {
|
||||
setThemeMode('rainbow');
|
||||
console.log('🌈 Rainbow mode manually activated!');
|
||||
}, []);
|
||||
|
||||
const deactivateRainbow = useCallback(() => {
|
||||
if (themeMode === 'rainbow') {
|
||||
setThemeMode('light');
|
||||
console.log('🌈 Rainbow mode manually deactivated.');
|
||||
}
|
||||
}, [themeMode]);
|
||||
|
||||
return {
|
||||
themeMode,
|
||||
isRainbowMode: themeMode === 'rainbow',
|
||||
isToggleDisabled,
|
||||
toggleTheme,
|
||||
activateRainbow,
|
||||
deactivateRainbow,
|
||||
};
|
||||
}
|
130
frontend/src/hooks/useToolParams.ts
Normal file
130
frontend/src/hooks/useToolParams.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Tool parameter definitions (shortened URLs)
|
||||
const TOOL_PARAMS = {
|
||||
split: [
|
||||
"mode", "p", "hd", "vd", "m",
|
||||
"type", "val", "level", "meta", "dupes"
|
||||
],
|
||||
compress: [
|
||||
"level", "gray", "rmeta", "size", "agg"
|
||||
],
|
||||
merge: [
|
||||
"order", "rdupes"
|
||||
]
|
||||
};
|
||||
|
||||
// Extract params for a specific tool from URL
|
||||
function getToolParams(toolKey: string, searchParams: URLSearchParams) {
|
||||
switch (toolKey) {
|
||||
case "split":
|
||||
return {
|
||||
mode: searchParams.get("mode") || "byPages",
|
||||
pages: searchParams.get("p") || "",
|
||||
hDiv: searchParams.get("hd") || "",
|
||||
vDiv: searchParams.get("vd") || "",
|
||||
merge: searchParams.get("m") === "true",
|
||||
splitType: searchParams.get("type") || "size",
|
||||
splitValue: searchParams.get("val") || "",
|
||||
bookmarkLevel: searchParams.get("level") || "0",
|
||||
includeMetadata: searchParams.get("meta") === "true",
|
||||
allowDuplicates: searchParams.get("dupes") === "true",
|
||||
};
|
||||
case "compress":
|
||||
return {
|
||||
compressionLevel: parseInt(searchParams.get("level") || "5"),
|
||||
grayscale: searchParams.get("gray") === "true",
|
||||
removeMetadata: searchParams.get("rmeta") === "true",
|
||||
expectedSize: searchParams.get("size") || "",
|
||||
aggressive: searchParams.get("agg") === "true",
|
||||
};
|
||||
case "merge":
|
||||
return {
|
||||
order: searchParams.get("order") || "default",
|
||||
removeDuplicates: searchParams.get("rdupes") === "true",
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Update tool-specific params in URL
|
||||
function updateToolParams(toolKey: string, searchParams: URLSearchParams, setSearchParams: any, newParams: any) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Clear tool-specific params
|
||||
if (toolKey === "split") {
|
||||
["mode", "p", "hd", "vd", "m", "type", "val", "level", "meta", "dupes"].forEach((k) => params.delete(k));
|
||||
// Set new split params
|
||||
const merged = { ...getToolParams("split", searchParams), ...newParams };
|
||||
params.set("mode", merged.mode);
|
||||
if (merged.mode === "byPages") params.set("p", merged.pages);
|
||||
else if (merged.mode === "bySections") {
|
||||
params.set("hd", merged.hDiv);
|
||||
params.set("vd", merged.vDiv);
|
||||
params.set("m", String(merged.merge));
|
||||
} else if (merged.mode === "bySizeOrCount") {
|
||||
params.set("type", merged.splitType);
|
||||
params.set("val", merged.splitValue);
|
||||
} else if (merged.mode === "byChapters") {
|
||||
params.set("level", merged.bookmarkLevel);
|
||||
params.set("meta", String(merged.includeMetadata));
|
||||
params.set("dupes", String(merged.allowDuplicates));
|
||||
}
|
||||
} else if (toolKey === "compress") {
|
||||
["level", "gray", "rmeta", "size", "agg"].forEach((k) => params.delete(k));
|
||||
const merged = { ...getToolParams("compress", searchParams), ...newParams };
|
||||
params.set("level", String(merged.compressionLevel));
|
||||
params.set("gray", String(merged.grayscale));
|
||||
params.set("rmeta", String(merged.removeMetadata));
|
||||
if (merged.expectedSize) params.set("size", merged.expectedSize);
|
||||
params.set("agg", String(merged.aggressive));
|
||||
} else if (toolKey === "merge") {
|
||||
["order", "rdupes"].forEach((k) => params.delete(k));
|
||||
const merged = { ...getToolParams("merge", searchParams), ...newParams };
|
||||
params.set("order", merged.order);
|
||||
params.set("rdupes", String(merged.removeDuplicates));
|
||||
}
|
||||
|
||||
setSearchParams(params, { replace: true });
|
||||
}
|
||||
|
||||
export function useToolParams(selectedToolKey: string, currentView: string) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const toolParams = getToolParams(selectedToolKey, searchParams);
|
||||
|
||||
const updateParams = (newParams: any) =>
|
||||
updateToolParams(selectedToolKey, searchParams, setSearchParams, newParams);
|
||||
|
||||
// Update URL when core state changes
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Remove all tool-specific params except for the current tool
|
||||
Object.entries(TOOL_PARAMS).forEach(([tool, keys]) => {
|
||||
if (tool !== selectedToolKey) {
|
||||
keys.forEach((k) => params.delete(k));
|
||||
}
|
||||
});
|
||||
|
||||
// Collect all params except 'v'
|
||||
const entries = Array.from(params.entries()).filter(([key]) => key !== "v");
|
||||
|
||||
// Rebuild params with 'v' first
|
||||
const newParams = new URLSearchParams();
|
||||
newParams.set("v", currentView);
|
||||
newParams.set("t", selectedToolKey);
|
||||
entries.forEach(([key, value]) => {
|
||||
if (key !== "t") newParams.set(key, value);
|
||||
});
|
||||
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}, [selectedToolKey, currentView, setSearchParams, searchParams]);
|
||||
|
||||
return {
|
||||
toolParams,
|
||||
updateParams,
|
||||
};
|
||||
}
|
68
frontend/src/hooks/useUndoRedo.ts
Normal file
68
frontend/src/hooks/useUndoRedo.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface Command {
|
||||
execute(): void;
|
||||
undo(): void;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CommandSequence {
|
||||
commands: Command[];
|
||||
execute(): void;
|
||||
undo(): void;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function useUndoRedo() {
|
||||
const [undoStack, setUndoStack] = useState<(Command | CommandSequence)[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<(Command | CommandSequence)[]>([]);
|
||||
|
||||
const executeCommand = useCallback((command: Command | CommandSequence) => {
|
||||
command.execute();
|
||||
setUndoStack(prev => [command, ...prev]);
|
||||
setRedoStack([]); // Clear redo stack when new command is executed
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (undoStack.length === 0) return false;
|
||||
|
||||
const command = undoStack[0];
|
||||
command.undo();
|
||||
|
||||
setUndoStack(prev => prev.slice(1));
|
||||
setRedoStack(prev => [command, ...prev]);
|
||||
|
||||
return true;
|
||||
}, [undoStack]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (redoStack.length === 0) return false;
|
||||
|
||||
const command = redoStack[0];
|
||||
command.execute();
|
||||
|
||||
setRedoStack(prev => prev.slice(1));
|
||||
setUndoStack(prev => [command, ...prev]);
|
||||
|
||||
return true;
|
||||
}, [redoStack]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setUndoStack([]);
|
||||
setRedoStack([]);
|
||||
}, []);
|
||||
|
||||
const canUndo = undoStack.length > 0;
|
||||
const canRedo = redoStack.length > 0;
|
||||
|
||||
return {
|
||||
executeCommand,
|
||||
undo,
|
||||
redo,
|
||||
clear,
|
||||
canUndo,
|
||||
canRedo,
|
||||
undoStack,
|
||||
redoStack
|
||||
};
|
||||
}
|
@ -1,7 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
|
@ -1,9 +1,9 @@
|
||||
import '@mantine/core/styles.css';
|
||||
import './index.css'; // Import Tailwind CSS
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import './i18n'; // Initialize i18next
|
||||
|
||||
|
263
frontend/src/services/pdfExportService.ts
Normal file
263
frontend/src/services/pdfExportService.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
|
||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||
|
||||
export interface ExportOptions {
|
||||
selectedOnly?: boolean;
|
||||
filename?: string;
|
||||
splitDocuments?: boolean;
|
||||
}
|
||||
|
||||
export class PDFExportService {
|
||||
/**
|
||||
* Export PDF document with applied operations
|
||||
*/
|
||||
async exportPDF(
|
||||
pdfDocument: PDFDocument,
|
||||
selectedPageIds: string[] = [],
|
||||
options: ExportOptions = {}
|
||||
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
|
||||
const { selectedOnly = false, filename, splitDocuments = false } = options;
|
||||
|
||||
try {
|
||||
// Determine which pages to export
|
||||
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
if (pagesToExport.length === 0) {
|
||||
throw new Error('No pages to export');
|
||||
}
|
||||
|
||||
// Load original PDF once
|
||||
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
|
||||
const sourceDoc = await PDFLibDocument.load(originalPDFBytes);
|
||||
|
||||
if (splitDocuments) {
|
||||
return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name);
|
||||
} else {
|
||||
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
|
||||
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly);
|
||||
return { blob, filename: exportFilename };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PDF export error:', error);
|
||||
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single PDF document with all operations applied
|
||||
*/
|
||||
private async createSingleDocument(
|
||||
sourceDoc: PDFLibDocument,
|
||||
pages: PDFPage[]
|
||||
): Promise<Blob> {
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
|
||||
for (const page of pages) {
|
||||
// Get the original page from source document
|
||||
const sourcePageIndex = page.pageNumber - 1;
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
// Copy the page
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
// Apply rotation
|
||||
if (page.rotation !== 0) {
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
|
||||
// Set metadata
|
||||
newDoc.setCreator('Stirling PDF');
|
||||
newDoc.setProducer('Stirling PDF');
|
||||
newDoc.setCreationDate(new Date());
|
||||
newDoc.setModificationDate(new Date());
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple PDF documents based on split markers
|
||||
*/
|
||||
private async createSplitDocuments(
|
||||
sourceDoc: PDFLibDocument,
|
||||
pages: PDFPage[],
|
||||
baseFilename: string
|
||||
): Promise<{ blobs: Blob[]; filenames: string[] }> {
|
||||
const splitPoints: number[] = [];
|
||||
const blobs: Blob[] = [];
|
||||
const filenames: string[] = [];
|
||||
|
||||
// Find split points
|
||||
pages.forEach((page, index) => {
|
||||
if (page.splitBefore && index > 0) {
|
||||
splitPoints.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Add end point
|
||||
splitPoints.push(pages.length);
|
||||
|
||||
let startIndex = 0;
|
||||
let partNumber = 1;
|
||||
|
||||
for (const endIndex of splitPoints) {
|
||||
const segmentPages = pages.slice(startIndex, endIndex);
|
||||
|
||||
if (segmentPages.length > 0) {
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
|
||||
for (const page of segmentPages) {
|
||||
const sourcePageIndex = page.pageNumber - 1;
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
if (page.rotation !== 0) {
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
|
||||
// Set metadata
|
||||
newDoc.setCreator('Stirling PDF');
|
||||
newDoc.setProducer('Stirling PDF');
|
||||
newDoc.setTitle(`${baseFilename} - Part ${partNumber}`);
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
const filename = this.generateSplitFilename(baseFilename, partNumber);
|
||||
|
||||
blobs.push(blob);
|
||||
filenames.push(filename);
|
||||
partNumber++;
|
||||
}
|
||||
|
||||
startIndex = endIndex;
|
||||
}
|
||||
|
||||
return { blobs, filenames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate appropriate filename for export
|
||||
*/
|
||||
private generateFilename(originalName: string, selectedOnly: boolean): string {
|
||||
const baseName = originalName.replace(/\.pdf$/i, '');
|
||||
const suffix = selectedOnly ? '_selected' : '_edited';
|
||||
return `${baseName}${suffix}.pdf`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate filename for split documents
|
||||
*/
|
||||
private generateSplitFilename(baseName: string, partNumber: number): string {
|
||||
const cleanBaseName = baseName.replace(/\.pdf$/i, '');
|
||||
return `${cleanBaseName}_part_${partNumber}.pdf`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single file
|
||||
*/
|
||||
downloadFile(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up the URL after a short delay
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple files as a ZIP
|
||||
*/
|
||||
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
|
||||
// For now, download files individually
|
||||
// TODO: Implement ZIP creation when needed
|
||||
blobs.forEach((blob, index) => {
|
||||
setTimeout(() => {
|
||||
this.downloadFile(blob, filenames[index]);
|
||||
}, index * 500); // Stagger downloads
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate PDF operations before export
|
||||
*/
|
||||
validateExport(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (selectedOnly && selectedPageIds.length === 0) {
|
||||
errors.push('No pages selected for export');
|
||||
}
|
||||
|
||||
if (pdfDocument.pages.length === 0) {
|
||||
errors.push('No pages available to export');
|
||||
}
|
||||
|
||||
const pagesToExport = selectedOnly
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
if (pagesToExport.length === 0) {
|
||||
errors.push('No valid pages to export after applying filters');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export preview information
|
||||
*/
|
||||
getExportInfo(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): {
|
||||
pageCount: number;
|
||||
splitCount: number;
|
||||
estimatedSize: string;
|
||||
} {
|
||||
const pagesToExport = selectedOnly
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
const splitCount = pagesToExport.reduce((count, page, index) => {
|
||||
return count + (page.splitBefore && index > 0 ? 1 : 0);
|
||||
}, 1); // At least 1 document
|
||||
|
||||
// Rough size estimation (very approximate)
|
||||
const avgPageSize = pdfDocument.file.size / pdfDocument.totalPages;
|
||||
const estimatedBytes = avgPageSize * pagesToExport.length;
|
||||
const estimatedSize = this.formatFileSize(estimatedBytes);
|
||||
|
||||
return {
|
||||
pageCount: pagesToExport.length,
|
||||
splitCount,
|
||||
estimatedSize
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
private formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfExportService = new PDFExportService();
|
202
frontend/src/styles/rainbow.module.css
Normal file
202
frontend/src/styles/rainbow.module.css
Normal file
@ -0,0 +1,202 @@
|
||||
/* Rainbow Mode Styles - Easter Egg! */
|
||||
@keyframes rainbowBackground {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes rainbowBorder {
|
||||
0% { border-color: #ff0000; box-shadow: 0 0 15px #ff0000; }
|
||||
14% { border-color: #ff8800; box-shadow: 0 0 15px #ff8800; }
|
||||
28% { border-color: #ffff00; box-shadow: 0 0 15px #ffff00; }
|
||||
42% { border-color: #88ff00; box-shadow: 0 0 15px #88ff00; }
|
||||
57% { border-color: #00ff88; box-shadow: 0 0 15px #00ff88; }
|
||||
71% { border-color: #0088ff; box-shadow: 0 0 15px #0088ff; }
|
||||
85% { border-color: #8800ff; box-shadow: 0 0 15px #8800ff; }
|
||||
100% { border-color: #ff0000; box-shadow: 0 0 15px #ff0000; }
|
||||
}
|
||||
|
||||
@keyframes rainbowText {
|
||||
0% { color: #ff0000; text-shadow: 0 0 10px #ff0000; }
|
||||
14% { color: #ff8800; text-shadow: 0 0 10px #ff8800; }
|
||||
28% { color: #ffff00; text-shadow: 0 0 10px #ffff00; }
|
||||
42% { color: #88ff00; text-shadow: 0 0 10px #88ff00; }
|
||||
57% { color: #00ff88; text-shadow: 0 0 10px #00ff88; }
|
||||
71% { color: #0088ff; text-shadow: 0 0 10px #0088ff; }
|
||||
85% { color: #8800ff; text-shadow: 0 0 10px #8800ff; }
|
||||
100% { color: #ff0000; text-shadow: 0 0 10px #ff0000; }
|
||||
}
|
||||
|
||||
@keyframes rainbowPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
/* Main rainbow theme class */
|
||||
.rainbowMode {
|
||||
background: linear-gradient(
|
||||
-45deg,
|
||||
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088, #ff0000
|
||||
) !important;
|
||||
background-size: 400% 400% !important;
|
||||
animation: rainbowBackground 3s ease infinite !important;
|
||||
color: white !important;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Rainbow components */
|
||||
.rainbowCard {
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088
|
||||
) !important;
|
||||
background-size: 400% 400% !important;
|
||||
animation: rainbowBackground 4s ease infinite, rainbowBorder 2s linear infinite !important;
|
||||
color: white !important;
|
||||
border: 2px solid !important;
|
||||
border-radius: 15px !important;
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.rainbowButton {
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088
|
||||
) !important;
|
||||
background-size: 300% 300% !important;
|
||||
animation: rainbowBackground 2s ease infinite, rainbowBorder 1s linear infinite !important;
|
||||
border: 2px solid !important;
|
||||
color: white !important;
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
.rainbowButton:hover {
|
||||
transform: scale(1.05) !important;
|
||||
animation: rainbowBackground 1s ease infinite, rainbowBorder 0.5s linear infinite, rainbowPulse 0.5s ease infinite !important;
|
||||
box-shadow: 0 0 25px rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
|
||||
.rainbowInput {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088
|
||||
) !important;
|
||||
background-size: 300% 300% !important;
|
||||
animation: rainbowBackground 2.5s ease infinite, rainbowBorder 1.5s linear infinite !important;
|
||||
border: 2px solid !important;
|
||||
color: white !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.rainbowInput::placeholder {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
|
||||
.rainbowText {
|
||||
animation: rainbowText 3s linear infinite !important;
|
||||
font-weight: bold !important;
|
||||
text-shadow: 0 0 10px currentColor !important;
|
||||
}
|
||||
|
||||
.rainbowSegmentedControl {
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088
|
||||
) !important;
|
||||
background-size: 400% 400% !important;
|
||||
animation: rainbowBackground 3s ease infinite, rainbowBorder 2s linear infinite !important;
|
||||
border: 2px solid !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 4px !important;
|
||||
}
|
||||
|
||||
.rainbowPaper {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#00ffff, #0088ff, #8800ff, #ff0088
|
||||
) !important;
|
||||
background-size: 100% 100% !important;
|
||||
animation: rainbowBackground 3.5s ease infinite, rainbowBorder 2s linear infinite !important;
|
||||
border: 2px solid !important;
|
||||
color: white !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
/* Easter egg notification */
|
||||
.rainbowNotification {
|
||||
position: fixed !important;
|
||||
top: 20px !important;
|
||||
right: 20px !important;
|
||||
background: linear-gradient(45deg,#ffff00, #88ff00, #00ff88, #00ffff) !important;
|
||||
background-size: 300% 300% !important;
|
||||
animation: rainbowBackground 1s ease infinite, rainbowBorder 0.5s linear infinite !important;
|
||||
color: white !important;
|
||||
padding: 15px 20px !important;
|
||||
border-radius: 25px !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 16px !important;
|
||||
z-index: 10000 !important;
|
||||
border: 2px solid white !important;
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.8) !important;
|
||||
user-select: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* Specific component overrides */
|
||||
.rainbowMode [data-mantine-color-scheme] {
|
||||
background: linear-gradient(
|
||||
-45deg,
|
||||
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088, #ff0000
|
||||
) !important;
|
||||
background-size: 400% 400% !important;
|
||||
animation: rainbowBackground 3s ease infinite !important;
|
||||
}
|
||||
|
||||
/* Make all buttons rainbow in rainbow mode */
|
||||
.rainbowMode button {
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#ffff00, #88ff00, #00ff88, #00ffff
|
||||
) !important;
|
||||
background-size: 100% 100% !important;
|
||||
animation: rainbowBackground 2s ease infinite !important;
|
||||
border: 2px solid !important;
|
||||
animation: rainbowBackground 2s ease infinite, rainbowBorder 1.5s linear infinite !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
/* Make all inputs rainbow in rainbow mode */
|
||||
.rainbowMode input,
|
||||
.rainbowMode select,
|
||||
.rainbowMode textarea {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#ffff00, #88ff00, #00ff88, #00ffff
|
||||
) !important;
|
||||
background-size: 100% 100% !important;
|
||||
animation: rainbowBackground 2.5s ease infinite !important;
|
||||
border: 2px solid !important;
|
||||
animation: rainbowBackground 2.5s ease infinite, rainbowBorder 1.5s linear infinite !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Rainbow text class */
|
||||
.rainbowText {
|
||||
animation: rainbowText 3s linear infinite !important;
|
||||
font-weight: bold !important;
|
||||
text-shadow: 0 0 10px currentColor !important;
|
||||
}
|
||||
|
||||
/* Make all text rainbow */
|
||||
.rainbowMode h1,
|
||||
.rainbowMode h2,
|
||||
.rainbowMode h3,
|
||||
.rainbowMode h4,
|
||||
.rainbowMode h5,
|
||||
.rainbowMode h6 {
|
||||
animation: rainbowText 3s linear infinite !important;
|
||||
font-weight: bold !important;
|
||||
}
|
15
frontend/src/styles/tailwind.css
Normal file
15
frontend/src/styles/tailwind.css
Normal file
@ -0,0 +1,15 @@
|
||||
|
||||
/* Import minimal theme variables */
|
||||
@import './theme.css';
|
||||
|
||||
@layer base {
|
||||
@tailwind base;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@tailwind components;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@tailwind utilities;
|
||||
}
|
139
frontend/src/styles/theme.css
Normal file
139
frontend/src/styles/theme.css
Normal file
@ -0,0 +1,139 @@
|
||||
/* CSS variables for Tailwind + Mantine integration */
|
||||
|
||||
:root {
|
||||
/* Standard gray scale */
|
||||
--gray-50: 249 250 251;
|
||||
--gray-100: 243 244 246;
|
||||
--gray-200: 229 231 235;
|
||||
--gray-300: 209 213 219;
|
||||
--gray-400: 156 163 175;
|
||||
--gray-500: 107 114 128;
|
||||
--gray-600: 75 85 99;
|
||||
--gray-700: 55 65 81;
|
||||
--gray-800: 31 41 55;
|
||||
--gray-900: 17 24 39;
|
||||
|
||||
/* Semantic colors for Tailwind */
|
||||
--surface: 255 255 255;
|
||||
--background: 249 250 251;
|
||||
--border: 229 231 235;
|
||||
|
||||
/* Colors for Mantine integration */
|
||||
--color-primary-50: #eff6ff;
|
||||
--color-primary-100: #dbeafe;
|
||||
--color-primary-200: #bfdbfe;
|
||||
--color-primary-300: #93c5fd;
|
||||
--color-primary-400: #60a5fa;
|
||||
--color-primary-500: #3b82f6;
|
||||
--color-primary-600: #2563eb;
|
||||
--color-primary-700: #1d4ed8;
|
||||
--color-primary-800: #1e40af;
|
||||
--color-primary-900: #1e3a8a;
|
||||
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f3f4f6;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d1d5db;
|
||||
--color-gray-400: #9ca3af;
|
||||
--color-gray-500: #6b7280;
|
||||
--color-gray-600: #4b5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1f2937;
|
||||
--color-gray-900: #111827;
|
||||
|
||||
/* Spacing system */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
|
||||
/* Radius system */
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
|
||||
/* Shadow system */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Light theme semantic colors */
|
||||
--bg-surface: #ffffff;
|
||||
--bg-raised: #f9fafb;
|
||||
--bg-muted: #f3f4f6;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #4b5563;
|
||||
--text-muted: #6b7280;
|
||||
--border-subtle: #e5e7eb;
|
||||
--border-default: #d1d5db;
|
||||
--border-strong: #9ca3af;
|
||||
--hover-bg: #f9fafb;
|
||||
--active-bg: #f3f4f6;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
/* Dark theme gray scale (inverted) */
|
||||
--gray-50: 17 24 39;
|
||||
--gray-100: 31 41 55;
|
||||
--gray-200: 55 65 81;
|
||||
--gray-300: 75 85 99;
|
||||
--gray-400: 107 114 128;
|
||||
--gray-500: 156 163 175;
|
||||
--gray-600: 209 213 219;
|
||||
--gray-700: 229 231 235;
|
||||
--gray-800: 243 244 246;
|
||||
--gray-900: 249 250 251;
|
||||
|
||||
/* Dark semantic colors for Tailwind */
|
||||
--surface: 31 41 55;
|
||||
--background: 17 24 39;
|
||||
--border: 75 85 99;
|
||||
|
||||
/* Dark theme Mantine colors */
|
||||
--color-gray-50: #111827;
|
||||
--color-gray-100: #1f2937;
|
||||
--color-gray-200: #374151;
|
||||
--color-gray-300: #4b5563;
|
||||
--color-gray-400: #6b7280;
|
||||
--color-gray-500: #9ca3af;
|
||||
--color-gray-600: #d1d5db;
|
||||
--color-gray-700: #e5e7eb;
|
||||
--color-gray-800: #f3f4f6;
|
||||
--color-gray-900: #f9fafb;
|
||||
|
||||
/* Dark theme semantic colors */
|
||||
--bg-surface: #1f2937;
|
||||
--bg-raised: #374151;
|
||||
--bg-muted: #374151;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-muted: #9ca3af;
|
||||
--border-subtle: #374151;
|
||||
--border-default: #4b5563;
|
||||
--border-strong: #6b7280;
|
||||
--hover-bg: #374151;
|
||||
--active-bg: #4b5563;
|
||||
|
||||
/* Adjust shadows for dark mode */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme switching */
|
||||
* {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
281
frontend/src/theme/mantineTheme.ts
Normal file
281
frontend/src/theme/mantineTheme.ts
Normal file
@ -0,0 +1,281 @@
|
||||
import { createTheme, MantineColorsTuple } from '@mantine/core';
|
||||
|
||||
// Define color tuples using CSS variables
|
||||
const primary: MantineColorsTuple = [
|
||||
'var(--color-primary-50)',
|
||||
'var(--color-primary-100)',
|
||||
'var(--color-primary-200)',
|
||||
'var(--color-primary-300)',
|
||||
'var(--color-primary-400)',
|
||||
'var(--color-primary-500)',
|
||||
'var(--color-primary-600)',
|
||||
'var(--color-primary-700)',
|
||||
'var(--color-primary-800)',
|
||||
'var(--color-primary-900)',
|
||||
];
|
||||
|
||||
const gray: MantineColorsTuple = [
|
||||
'var(--color-gray-50)',
|
||||
'var(--color-gray-100)',
|
||||
'var(--color-gray-200)',
|
||||
'var(--color-gray-300)',
|
||||
'var(--color-gray-400)',
|
||||
'var(--color-gray-500)',
|
||||
'var(--color-gray-600)',
|
||||
'var(--color-gray-700)',
|
||||
'var(--color-gray-800)',
|
||||
'var(--color-gray-900)',
|
||||
];
|
||||
|
||||
export const mantineTheme = createTheme({
|
||||
// Primary color
|
||||
primaryColor: 'primary',
|
||||
|
||||
// Color palette
|
||||
colors: {
|
||||
primary,
|
||||
gray,
|
||||
},
|
||||
|
||||
// Spacing system - uses CSS variables
|
||||
spacing: {
|
||||
xs: 'var(--space-xs)',
|
||||
sm: 'var(--space-sm)',
|
||||
md: 'var(--space-md)',
|
||||
lg: 'var(--space-lg)',
|
||||
xl: 'var(--space-xl)',
|
||||
},
|
||||
|
||||
// Border radius system
|
||||
radius: {
|
||||
xs: 'var(--radius-xs)',
|
||||
sm: 'var(--radius-sm)',
|
||||
md: 'var(--radius-md)',
|
||||
lg: 'var(--radius-lg)',
|
||||
xl: 'var(--radius-xl)',
|
||||
},
|
||||
|
||||
// Shadow system
|
||||
shadows: {
|
||||
xs: 'var(--shadow-xs)',
|
||||
sm: 'var(--shadow-sm)',
|
||||
md: 'var(--shadow-md)',
|
||||
lg: 'var(--shadow-lg)',
|
||||
xl: 'var(--shadow-xl)',
|
||||
},
|
||||
|
||||
// Font weights
|
||||
fontWeights: {
|
||||
normal: 'var(--font-weight-normal)',
|
||||
medium: 'var(--font-weight-medium)',
|
||||
semibold: 'var(--font-weight-semibold)',
|
||||
bold: 'var(--font-weight-bold)',
|
||||
},
|
||||
|
||||
// Component customizations
|
||||
components: {
|
||||
Button: {
|
||||
styles: {
|
||||
root: {
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
// Custom button variant for PDF tools
|
||||
pdfTool: (theme) => ({
|
||||
root: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-default)',
|
||||
color: 'var(--text-primary)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--hover-bg)',
|
||||
borderColor: 'var(--color-primary-500)',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
Paper: {
|
||||
styles: {
|
||||
root: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Card: {
|
||||
styles: {
|
||||
root: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
TextInput: {
|
||||
styles: {
|
||||
input: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-default)',
|
||||
color: 'var(--text-primary)',
|
||||
'&:focus': {
|
||||
borderColor: 'var(--color-primary-500)',
|
||||
boxShadow: '0 0 0 1px var(--color-primary-500)',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
color: 'var(--text-secondary)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Select: {
|
||||
styles: {
|
||||
input: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-default)',
|
||||
color: 'var(--text-primary)',
|
||||
'&:focus': {
|
||||
borderColor: 'var(--color-primary-500)',
|
||||
boxShadow: '0 0 0 1px var(--color-primary-500)',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
color: 'var(--text-secondary)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
},
|
||||
dropdown: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-subtle)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
},
|
||||
option: {
|
||||
color: 'var(--text-primary)',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'var(--hover-bg)',
|
||||
},
|
||||
'&[data-selected]': {
|
||||
backgroundColor: 'var(--color-primary-100)',
|
||||
color: 'var(--color-primary-900)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
styles: {
|
||||
input: {
|
||||
borderColor: 'var(--border-default)',
|
||||
'&:checked': {
|
||||
backgroundColor: 'var(--color-primary-500)',
|
||||
borderColor: 'var(--color-primary-500)',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
color: 'var(--text-primary)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Slider: {
|
||||
styles: {
|
||||
track: {
|
||||
backgroundColor: 'var(--bg-muted)',
|
||||
},
|
||||
bar: {
|
||||
backgroundColor: 'var(--color-primary-500)',
|
||||
},
|
||||
thumb: {
|
||||
backgroundColor: 'var(--color-primary-500)',
|
||||
borderColor: 'var(--color-primary-500)',
|
||||
},
|
||||
mark: {
|
||||
borderColor: 'var(--border-default)',
|
||||
},
|
||||
markLabel: {
|
||||
color: 'var(--text-muted)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Modal: {
|
||||
styles: {
|
||||
content: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
},
|
||||
title: {
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Notification: {
|
||||
styles: {
|
||||
root: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
},
|
||||
title: {
|
||||
color: 'var(--text-primary)',
|
||||
},
|
||||
description: {
|
||||
color: 'var(--text-secondary)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
SegmentedControl: {
|
||||
styles: {
|
||||
root: {
|
||||
backgroundColor: 'var(--bg-muted)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
},
|
||||
control: {
|
||||
color: 'var(--text-secondary)',
|
||||
'[dataActive]': {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
color: 'var(--text-primary)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Global styles
|
||||
globalStyles: () => ({
|
||||
// Ensure smooth color transitions
|
||||
'*': {
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease',
|
||||
},
|
||||
|
||||
// Custom scrollbar styling
|
||||
'*::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
},
|
||||
'*::-webkit-scrollbar-track': {
|
||||
backgroundColor: 'var(--bg-muted)',
|
||||
},
|
||||
'*::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'var(--border-strong)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
},
|
||||
'*::-webkit-scrollbar-thumb:hover': {
|
||||
backgroundColor: 'var(--color-primary-500)',
|
||||
},
|
||||
}),
|
||||
});
|
@ -143,7 +143,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit} className="app-surface p-app-md rounded-app-md">
|
||||
<Stack gap="sm" mb={16}>
|
||||
<Select
|
||||
label={t("split-by-size-or-count.type.label", "Split Mode")}
|
||||
@ -240,7 +240,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
||||
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
|
||||
</Button>
|
||||
|
||||
{status && <p className="text-xs text-gray-600">{status}</p>}
|
||||
{status && <p className="text-xs text-text-muted">{status}</p>}
|
||||
|
||||
{errorMessage && (
|
||||
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)}>
|
||||
|
27
frontend/src/types/pageEditor.ts
Normal file
27
frontend/src/types/pageEditor.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export interface PDFPage {
|
||||
id: string;
|
||||
pageNumber: number;
|
||||
thumbnail: string;
|
||||
rotation: number;
|
||||
selected: boolean;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
export interface PDFDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
file: File;
|
||||
pages: PDFPage[];
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PageOperation {
|
||||
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert';
|
||||
pageIds: string[];
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface UndoRedoState {
|
||||
operations: PageOperation[];
|
||||
currentIndex: number;
|
||||
}
|
@ -1,12 +1,48 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}"
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: ['class', '[data-mantine-color-scheme="dark"]'],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
// Use standard Tailwind color system with CSS variables for theme switching
|
||||
colors: {
|
||||
// Override gray to work with both themes
|
||||
gray: {
|
||||
50: 'rgb(var(--gray-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--gray-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--gray-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--gray-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--gray-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--gray-500) / <alpha-value>)',
|
||||
600: 'rgb(var(--gray-600) / <alpha-value>)',
|
||||
700: 'rgb(var(--gray-700) / <alpha-value>)',
|
||||
800: 'rgb(var(--gray-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--gray-900) / <alpha-value>)',
|
||||
},
|
||||
// Custom semantic colors for app-specific usage
|
||||
surface: 'rgb(var(--surface) / <alpha-value>)',
|
||||
background: 'rgb(var(--background) / <alpha-value>)',
|
||||
border: 'rgb(var(--border) / <alpha-value>)',
|
||||
},
|
||||
|
||||
// Z-index scale
|
||||
zIndex: {
|
||||
'dropdown': '1000',
|
||||
'sticky': '1020',
|
||||
'fixed': '1030',
|
||||
'modal-backdrop': '1040',
|
||||
'modal': '1050',
|
||||
'popover': '1060',
|
||||
'tooltip': '1070',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
// Enable preflight for standard Tailwind functionality
|
||||
corePlugins: {
|
||||
preflight: true,
|
||||
},
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user