Merge pull request #3663 from reecebrowne/Stirling-2.0

Stirling 2.0
This commit is contained in:
ConnorYoh 2025-06-24 15:04:28 +01:00 committed by GitHub
commit bfc679edc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 5861 additions and 415 deletions

124
CLAUDE.md Normal file
View 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

View File

@ -2,21 +2,38 @@
## 1. Introduction ## 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 ## 2. Project Overview
Stirling-PDF is built using: **Stirling 2.0** is built using:
- Spring Boot + Thymeleaf **Backend:**
- PDFBox - Spring Boot (Java 17+, JDK 21 recommended)
- LibreOffice - PDFBox for core PDF operations
- qpdf - LibreOffice for document conversions
- HTML, CSS, JavaScript - qpdf for PDF optimization
- Docker - Spring Security (optional, controlled by `DOCKER_ENABLE_SECURITY`)
- PDF.js - Lombok for reducing boilerplate code
- PDF-LIB.js
- Lombok **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 ## 3. Development Environment Setup
@ -24,7 +41,8 @@ Stirling-PDF is built using:
- Docker - Docker
- Git - 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) - Gradle 7.0 or later (Included within the repo)
### Setup Steps ### 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: 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. Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
5. Add environment variable 5. **Frontend Setup (Required for Stirling 2.0)**
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. 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 ```bash
Stirling-PDF/ Stirling-PDF/
├── .github/ # GitHub-specific files (workflows, issue templates) ├── .github/ # GitHub-specific files (workflows, issue templates)
├── configs/ # Configuration files used by stirling at runtime (generated at runtime) ├── configs/ # Configuration files used by stirling at runtime (generated at runtime)
├── cucumber/ # Cucumber test files ├── frontend/ # React SPA frontend (Stirling 2.0)
│ ├── features/ │ ├── 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) ├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files ├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files ├── exampleYmlFiles/ # Example YAML configuration files
@ -84,16 +130,14 @@ Stirling-PDF/
│ │ │ ├── service/ │ │ │ ├── service/
│ │ │ └── utils/ │ │ │ └── utils/
│ │ └── resources/ │ │ └── resources/
│ │ ├── static/ │ │ ├── static/ # Legacy static assets (reference only)
│ │ │ ├── css/ │ │ │ ├── css/
│ │ │ ├── js/ │ │ │ ├── js/
│ │ │ └── pdfjs/ │ │ │ └── pdfjs/
│ │ └── templates/ │ │ └── templates/ # Legacy Thymeleaf templates (reference only)
│ └── test/ │ └── test/
│ └── java/ ├── testing/ # Cucumber and integration tests
│ └── stirling/ │ └── cucumber/ # Cucumber test files
│ └── software/
│ └── SPDF/
├── build.gradle # Gradle build configuration ├── build.gradle # Gradle build configuration
├── Dockerfile # Main Dockerfile ├── Dockerfile # Main Dockerfile
├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version ├── 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 └── 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: 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 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 ### 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. 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) ### 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: 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!) - 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. - 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. 1. Fork the repository on GitHub.
2. Create a new branch for your feature or bug fix. 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. 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/). 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: 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. 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: 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 ## 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. 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.

View File

@ -25,6 +25,7 @@
"i18next": "^25.2.1", "i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@ -1394,6 +1395,22 @@
"node": ">= 8" "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": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@ -4213,6 +4230,11 @@
"wrappy": "1" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -4278,6 +4300,22 @@
"node": ">=8" "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": { "node_modules/pdfjs-dist": {
"version": "3.11.174", "version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",

View File

@ -21,6 +21,7 @@
"i18next": "^25.2.1", "i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",

View File

@ -1622,5 +1622,48 @@
"toolPicker": { "toolPicker": {
"searchPlaceholder": "Search tools...", "searchPlaceholder": "Search tools...",
"noToolsFound": "No tools found" "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"
} }
} }

View File

@ -1 +0,0 @@
{}

View File

@ -1,6 +1,15 @@
import './index.css';
import React from 'react'; import React from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
// Import global styles
import './styles/tailwind.css';
import './index.css';
export default function App() { export default function App() {
return <HomePage/>; return (
<RainbowThemeProvider>
<HomePage />
</RainbowThemeProvider>
);
} }

View 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());
}
}

View File

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

View File

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

View File

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

View 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;

View 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;

View 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;

View 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;
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -68,4 +68,21 @@
.languageText { .languageText {
display: inline; 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;
}
} }

View 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;

View 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;

View 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>
</>
);
}

View 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;

View File

@ -17,7 +17,7 @@ interface ToolPickerProps {
toolRegistry: ToolRegistry; toolRegistry: ToolRegistry;
} }
const ToolPicker: React.FC<ToolPickerProps> = ({ selectedToolKey, onSelect, toolRegistry }) => { const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");

View 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;

View 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]);
}

View 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
};
}

View 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,
};
}

View 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,
};
}

View 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
};
}

View File

@ -1,7 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

View File

@ -1,9 +1,9 @@
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import './index.css'; // Import Tailwind CSS
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core'; import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import './index.css';
import App from './App'; import App from './App';
import './i18n'; // Initialize i18next import './i18n'; // Initialize i18next

View 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();

View 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;
}

View File

@ -0,0 +1,15 @@
/* Import minimal theme variables */
@import './theme.css';
@layer base {
@tailwind base;
}
@layer components {
@tailwind components;
}
@layer utilities {
@tailwind utilities;
}

View 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;
}

View 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)',
},
}),
});

View File

@ -143,7 +143,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
}; };
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} className="app-surface p-app-md rounded-app-md">
<Stack gap="sm" mb={16}> <Stack gap="sm" mb={16}>
<Select <Select
label={t("split-by-size-or-count.type.label", "Split Mode")} 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")} {isLoading ? t("loading") : t("split.submit", "Split PDF")}
</Button> </Button>
{status && <p className="text-xs text-gray-600">{status}</p>} {status && <p className="text-xs text-text-muted">{status}</p>}
{errorMessage && ( {errorMessage && (
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)}> <Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)}>

View 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;
}

View File

@ -1,12 +1,48 @@
/** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
corePlugins: {
preflight: false,
},
content: [ content: [
"./src/**/*.{js,jsx,ts,tsx}" "./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
], ],
darkMode: ['class', '[data-mantine-color-scheme="dark"]'],
theme: { 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: [], plugins: [],
} // Enable preflight for standard Tailwind functionality
corePlugins: {
preflight: true,
},
}