diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..05bfb5254 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/DeveloperGuide.md b/DeveloperGuide.md index 32d480f5c..5506ec2f9 100644 --- a/DeveloperGuide.md +++ b/DeveloperGuide.md @@ -2,21 +2,38 @@ ## 1. Introduction -Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This guide focuses on Docker-based development and testing, which is the recommended approach for working with the full version of Stirling-PDF. +Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite, replacing the legacy Thymeleaf-based UI with a modern React SPA (Single Page Application). + +This guide focuses on developing for Stirling 2.0, including both the React frontend and Spring Boot backend development workflows. ## 2. Project Overview -Stirling-PDF is built using: +**Stirling 2.0** is built using: -- Spring Boot + Thymeleaf -- PDFBox -- LibreOffice -- qpdf -- HTML, CSS, JavaScript -- Docker -- PDF.js -- PDF-LIB.js -- Lombok +**Backend:** +- Spring Boot (Java 17+, JDK 21 recommended) +- PDFBox for core PDF operations +- LibreOffice for document conversions +- qpdf for PDF optimization +- Spring Security (optional, controlled by `DOCKER_ENABLE_SECURITY`) +- Lombok for reducing boilerplate code + +**Frontend (React SPA):** +- React + TypeScript +- Vite for build tooling and development server +- Mantine UI component library +- TailwindCSS for styling +- PDF.js for client-side PDF rendering +- PDF-LIB.js for client-side PDF manipulation +- IndexedDB for client-side file storage and thumbnails +- i18next for internationalization + +**Infrastructure:** +- Docker for containerization +- Gradle for build management + +**Legacy (reference only during development):** +- Thymeleaf templates (being completely replaced in 2.0) ## 3. Development Environment Setup @@ -24,7 +41,8 @@ Stirling-PDF is built using: - Docker - Git -- Java JDK 17 or later +- Java JDK 17 or later (JDK 21 recommended) +- Node.js 18+ and npm (required for frontend development) - Gradle 7.0 or later (Included within the repo) ### Setup Steps @@ -54,17 +72,45 @@ Stirling-PDF is built using: Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment: Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE. -5. Add environment variable -For local testing, you should generally be testing the full 'Security' version of Stirling-PDF. To do this, you must add the environment flag DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step. +5. **Frontend Setup (Required for Stirling 2.0)** + Navigate to the frontend directory and install dependencies using npm. -## 4. Project Structure +## 4. Stirling 2.0 Development Workflow + +### Frontend Development (React) +The frontend is a React SPA that runs independently during development: + +1. **Start the backend**: Run the Spring Boot application (serves API endpoints on localhost:8080) +2. **Start the frontend dev server**: Navigate to the frontend directory and run the development server (serves UI on localhost:5173) +3. **Development flow**: The Vite dev server automatically proxies API calls to the backend + +### File Storage Architecture +Stirling 2.0 uses client-side file storage: +- **IndexedDB**: Stores files locally in the browser with automatic thumbnail generation +- **PDF.js**: Handles client-side PDF rendering and processing +- **URL Parameters**: Support for deep linking and tool state persistence + +### Legacy Code Reference +The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release. + +## 5. Project Structure ```bash Stirling-PDF/ ├── .github/ # GitHub-specific files (workflows, issue templates) ├── configs/ # Configuration files used by stirling at runtime (generated at runtime) -├── cucumber/ # Cucumber test files -│ ├── features/ +├── frontend/ # React SPA frontend (Stirling 2.0) +│ ├── src/ +│ │ ├── components/ # React components +│ │ ├── tools/ # Tool-specific React components +│ │ ├── hooks/ # Custom React hooks +│ │ ├── services/ # API and utility services +│ │ ├── types/ # TypeScript type definitions +│ │ └── utils/ # Utility functions +│ ├── public/ +│ │ └── locales/ # Internationalization files (JSON) +│ ├── package.json # Frontend dependencies +│ └── vite.config.ts # Vite configuration ├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files) ├── docs/ # Documentation files ├── exampleYmlFiles/ # Example YAML configuration files @@ -84,16 +130,14 @@ Stirling-PDF/ │ │ │ ├── service/ │ │ │ └── utils/ │ │ └── resources/ -│ │ ├── static/ +│ │ ├── static/ # Legacy static assets (reference only) │ │ │ ├── css/ │ │ │ ├── js/ │ │ │ └── pdfjs/ -│ │ └── templates/ +│ │ └── templates/ # Legacy Thymeleaf templates (reference only) │ └── test/ -│ └── java/ -│ └── stirling/ -│ └── software/ -│ └── SPDF/ +├── testing/ # Cucumber and integration tests +│ └── cucumber/ # Cucumber test files ├── build.gradle # Gradle build configuration ├── Dockerfile # Main Dockerfile ├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version @@ -102,7 +146,7 @@ Stirling-PDF/ └── test.sh # Test script to deploy all docker versions and run cuke tests ``` -## 5. Docker-based Development +## 6. Docker-based Development Stirling-PDF offers several Docker versions: @@ -202,7 +246,7 @@ Stirling-PDF uses different Docker images for various configurations. The build Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase -## 6. Testing +## 7. Testing ### Comprehensive Testing Script @@ -228,6 +272,15 @@ Note: The `test.sh` script will run automatically when you raise a PR. However, 2. Access the application at `http://localhost:8080` and manually test all features developed. +### Frontend Development Testing (Stirling 2.0) + +For React frontend development: + +1. Start the backend: Run the Spring Boot application to serve API endpoints on localhost:8080 +2. Start the frontend dev server: Navigate to the frontend directory and run the development server on localhost:5173 +3. The Vite dev server automatically proxies API calls to the backend +4. Test React components, UI interactions, and IndexedDB file operations using browser developer tools + ### Local Testing (Java and UI Components) For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to: @@ -258,7 +311,7 @@ Important notes: - There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!) - Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup. -## 7. Contributing +## 8. Contributing 1. Fork the repository on GitHub. 2. Create a new branch for your feature or bug fix. @@ -283,11 +336,11 @@ When you raise a PR: Address any issues that arise from these checks before finalizing your pull request. -## 8. API Documentation +## 9. API Documentation API documentation is available at `/swagger-ui/index.html` when running the application. You can also view the latest API documentation [here](https://app.swaggerhub.com/apis-docs/Stirling-Tools/Stirling-PDF/). -## 9. Customization +## 10. Customization Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include: @@ -306,7 +359,7 @@ docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full Refer to the main README for a full list of customization options. -## 10. Language Translations +## 11. Language Translations For managing language translations that affect multiple files, Stirling-PDF provides a helper script: @@ -326,7 +379,56 @@ Remember to test your changes thoroughly to ensure they don't break any existing ## Code examples -### Overview of Thymeleaf +### React Component Development (Stirling 2.0) + +For Stirling 2.0, new features are built as React components instead of Thymeleaf templates: + +#### Creating a New Tool Component + +1. **Create the React Component:** + ```typescript + // frontend/src/tools/NewTool.tsx + import { useState } from 'react'; + import { Button, FileInput, Container } from '@mantine/core'; + + interface NewToolProps { + params: Record; + updateParams: (updates: Record) => void; + } + + export default function NewTool({ params, updateParams }: NewToolProps) { + const [files, setFiles] = useState([]); + + const handleProcess = async () => { + // Process files using API or client-side logic + }; + + return ( + + + + + ); + } + ``` + +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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 22db08803..0e33392bc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", + "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -1394,6 +1395,22 @@ "node": ">= 8" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -4213,6 +4230,11 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4278,6 +4300,22 @@ "node": ">=8" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "node_modules/pdfjs-dist": { "version": "3.11.174", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2864724dc..fa7a0b5d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", + "pdf-lib": "^1.17.1", "pdfjs-dist": "^3.11.174", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index f34301dc4..081f746ee 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1622,5 +1622,48 @@ "toolPicker": { "searchPlaceholder": "Search tools...", "noToolsFound": "No tools found" + }, + "fileUpload": { + "selectFile": "Select a file", + "selectFiles": "Select files", + "selectPdfToView": "Select a PDF to view", + "selectPdfToEdit": "Select a PDF to edit", + "chooseFromStorage": "Choose a file from storage or upload a new PDF", + "chooseFromStorageMultiple": "Choose files from storage or upload new PDFs", + "loadFromStorage": "Load from Storage", + "filesAvailable": "files available", + "loading": "Loading...", + "or": "or", + "dropFileHere": "Drop file here or click to upload", + "dropFilesHere": "Drop files here or click to upload", + "pdfFilesOnly": "PDF files only", + "supportedFileTypes": "Supported file types", + "uploadFile": "Upload File", + "uploadFiles": "Upload Files", + "noFilesInStorage": "No files available in storage. Upload some files first.", + "selectFromStorage": "Select from Storage", + "backToTools": "Back to Tools" + }, + "fileManager": { + "title": "Upload PDF Files", + "subtitle": "Add files to your storage for easy access across tools", + "filesSelected": "files selected", + "clearSelection": "Clear Selection", + "openInFileEditor": "Open in File Editor", + "uploadError": "Failed to upload some files.", + "failedToOpen": "Failed to open file. It may have been removed from storage.", + "failedToLoad": "Failed to load file to active set.", + "storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.", + "clearAll": "Clear All", + "reloadFiles": "Reload Files" + }, + "storage": { + "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", + "storageLimit": "Storage limit", + "storageUsed": "Temporary Storage used", + "storageFull": "Storage is nearly full. Consider removing some files.", + "fileTooLarge": "File too large. Maximum size per file is", + "storageQuotaExceeded": "Storage quota exceeded. Please remove some files before uploading more.", + "approximateSize": "Approximate size" } -} \ No newline at end of file +} diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/frontend/public/locales/en/translation.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e75a4ff3e..8083f37fd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,15 @@ -import './index.css'; import React from 'react'; +import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; import HomePage from './pages/HomePage'; + +// Import global styles +import './styles/tailwind.css'; +import './index.css'; + export default function App() { - return ; + return ( + + + + ); } diff --git a/frontend/src/commands/pageCommands.ts b/frontend/src/commands/pageCommands.ts new file mode 100644 index 000000000..d0ecd699b --- /dev/null +++ b/frontend/src/commands/pageCommands.ts @@ -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; + + 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; + + 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; + + 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()); + } +} \ No newline at end of file diff --git a/frontend/src/components/DeepLinks.tsx b/frontend/src/components/DeepLinks.tsx deleted file mode 100644 index 53a4ab7b5..000000000 --- a/frontend/src/components/DeepLinks.tsx +++ /dev/null @@ -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 ( - - Common PDF Operations - {commonLinks.map((link, index) => ( - - - {link.description} - - ))} - - ); -}; - -export default DeepLinks; diff --git a/frontend/src/components/LanguageSelector.tsx b/frontend/src/components/LanguageSelector.tsx deleted file mode 100644 index 3f793a031..000000000 --- a/frontend/src/components/LanguageSelector.tsx +++ /dev/null @@ -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 ( - - - - - - - -
- {languageOptions.map((option) => ( -
- -
- ))} -
-
-
-
- ); -}; - -export default LanguageSelector; \ No newline at end of file diff --git a/frontend/src/components/PageEditor.tsx b/frontend/src/components/PageEditor.tsx deleted file mode 100644 index 3b8b56b13..000000000 --- a/frontend/src/components/PageEditor.tsx +++ /dev/null @@ -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 = ({ - file, - setFile, - downloadUrl, - setDownloadUrl, -}) => { - const { t } = useTranslation(); - const [selectedPages, setSelectedPages] = useState([]); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); - const [undoStack, setUndoStack] = useState([]); - const [redoStack, setRedoStack] = useState([]); - - // 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 ( - -
- {t("pageEditor.noPdfLoaded", "No PDF loaded. Please upload a PDF to edit.")} -
-
- ); - } - - return ( - - - {/* Sidebar */} - - {t("pageEditor.title", "PDF Multitool")} - - - - - - - - - - - - - {/* Main multitool area */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {pages.map((page) => ( - - togglePage(page)} - label={t("page", "Page") + ` ${page}`} - /> - - {/* Replace with real thumbnail */} -
- - {page} - -
-
-
- ))} -
-
-
-
- {status && ( - setStatus(null)}> - {status} - - )} -
- ); -}; - -export default PageEditor; diff --git a/frontend/src/components/editor/BulkSelectionPanel.tsx b/frontend/src/components/editor/BulkSelectionPanel.tsx new file mode 100644 index 000000000..e28d0c41f --- /dev/null +++ b/frontend/src/components/editor/BulkSelectionPanel.tsx @@ -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 ( + + + setCsvInput(e.target.value)} + placeholder="1,3,5-10" + label="Page Selection" + onBlur={onUpdatePagesFromCSV} + onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()} + style={{ flex: 1 }} + /> + + + {selectedPages.length > 0 && ( + + Selected: {selectedPages.length} pages + + )} + + ); +}; + +export default BulkSelectionPanel; \ No newline at end of file diff --git a/frontend/src/components/editor/FileEditor.tsx b/frontend/src/components/editor/FileEditor.tsx new file mode 100644 index 000000000..bd23b2ea0 --- /dev/null +++ b/frontend/src/components/editor/FileEditor.tsx @@ -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([]); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [csvInput, setCsvInput] = useState(''); + const [selectionMode, setSelectionMode] = useState(false); + const [draggedFile, setDraggedFile] = useState(null); + const [dropTarget, setDropTarget] = useState(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>(new Map()); + + // Convert shared files to FileEditor format + const convertToFileItem = useCallback(async (sharedFile: any): Promise => { + // 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 ( + + + + + + + {selectionMode && ( + <> + + + + )} + + {/* Load from storage and upload buttons */} + + + + + + + + {selectionMode && ( + + )} + + ( + + )} + renderSplitMarker={(file, index) => ( +
+ )} + /> + + + {/* File Picker Modal */} + setShowFilePickerModal(false)} + sharedFiles={sharedFiles || []} + onSelectFiles={handleLoadFromStorage} + /> + + {status && ( + setStatus(null)} + style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} + > + {status} + + )} + + {error && ( + setError(null)} + style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }} + > + {error} + + )} + + ); +}; + +export default FileEditor; diff --git a/frontend/src/components/editor/FileThumbnail.tsx b/frontend/src/components/editor/FileThumbnail.tsx new file mode 100644 index 000000000..46448a34c --- /dev/null +++ b/frontend/src/components/editor/FileThumbnail.tsx @@ -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>; + 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 ( +
{ + 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 && ( +
e.stopPropagation()} + onDragStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + { + event.stopPropagation(); + onToggleFile(file.id); + }} + onClick={(e) => e.stopPropagation()} + size="sm" + /> +
+ )} + + {/* File content area */} +
+ {/* Stacked file effect - multiple shadows to simulate pages */} +
+ {file.name} +
+ + {/* Page count badge */} + + {file.pageCount} pages + + + {/* File name overlay */} + + {file.name} + + + {/* Hover controls */} +
+ + { + e.stopPropagation(); + onViewFile(file.id); + onSetStatus(`Opened ${file.name}`); + }} + > + + + + + + { + e.stopPropagation(); + onMergeFromHere(file.id); + onSetStatus(`Starting merge from ${file.name}`); + }} + > + + + + + + { + e.stopPropagation(); + onSplitFile(file.id); + onSetStatus(`Opening ${file.name} in page editor`); + }} + > + + + + + + { + e.stopPropagation(); + onDeleteFile(file.id); + onSetStatus(`Deleted ${file.name}`); + }} + > + + + +
+ + +
+ + {/* File info */} +
+ + {file.name} + + + {formatFileSize(file.size)} + +
+
+ ); +}; + +export default FileThumbnail; \ No newline at end of file diff --git a/frontend/src/components/editor/PageEditor.module.css b/frontend/src/components/editor/PageEditor.module.css new file mode 100644 index 000000000..5901e80e6 --- /dev/null +++ b/frontend/src/components/editor/PageEditor.module.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/components/editor/PageEditor.tsx b/frontend/src/components/editor/PageEditor.tsx new file mode 100644 index 000000000..9a84ab6d4 --- /dev/null +++ b/frontend/src/components/editor/PageEditor.tsx @@ -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(null); + const [selectedPages, setSelectedPages] = useState([]); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [csvInput, setCsvInput] = useState(""); + const [selectionMode, setSelectionMode] = useState(false); + const [filename, setFilename] = useState(""); + const [draggedPage, setDraggedPage] = useState(null); + const [dropTarget, setDropTarget] = useState(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(null); + const [pagePositions, setPagePositions] = useState>(new Map()); + const [isAnimating, setIsAnimating] = useState(false); + const pageRefs = useRef>(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(); + 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(); + + // 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 ( + + + + + + + + ); + } + + return ( + + + + + + setFilename(e.target.value)} + placeholder="Enter filename" + style={{ minWidth: 200 }} + /> + + {selectionMode && ( + <> + + + + )} + + + {selectionMode && ( + + )} + + ( + + )} + renderSplitMarker={(page, index) => ( +
+ )} + /> + + + + + setShowExportModal(false)} + title="Export Preview" + > + {exportPreview && ( + + + Pages to export: + {exportPreview.pageCount} + + + {exportPreview.splitCount > 1 && ( + + Split into documents: + {exportPreview.splitCount} + + )} + + + Estimated size: + {exportPreview.estimatedSize} + + + {pdfDocument && pdfDocument.pages.some(p => p.splitBefore) && ( + + This will create multiple PDF files based on split markers. + + )} + + + + + + + )} + + + file && handleFileUpload(file)} + style={{ display: 'none' }} + /> + + {status && ( + setStatus(null)} + style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} + > + {status} + + )} + + + + ); +}; + +export default PageEditor; diff --git a/frontend/src/components/editor/PageEditorControls.tsx b/frontend/src/components/editor/PageEditorControls.tsx new file mode 100644 index 000000000..175a83eec --- /dev/null +++ b/frontend/src/components/editor/PageEditorControls.tsx @@ -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 ( +
+ + {/* Close PDF */} + + + + + + +
+ + {/* Undo/Redo */} + + + + + + + + + + + +
+ + {/* Page Operations */} + + onRotate('left')} + disabled={selectionMode && selectedPages.length === 0} + variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} + color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} + size="lg" + > + + + + + onRotate('right')} + disabled={selectionMode && selectedPages.length === 0} + variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} + color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} + size="lg" + > + + + + + 0 ? "light" : "default"} + size="lg" + > + + + + + 0 ? "light" : "default"} + color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} + size="lg" + > + + + + +
+ + {/* Export Controls */} + {selectionMode && selectedPages.length > 0 && ( + + + + + + )} + + + + + + +
+ ); +}; + +export default PageEditorControls; diff --git a/frontend/src/components/editor/PageThumbnail.tsx b/frontend/src/components/editor/PageThumbnail.tsx new file mode 100644 index 000000000..89d0811b1 --- /dev/null +++ b/frontend/src/components/editor/PageThumbnail.tsx @@ -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>; + 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 ( +
{ + 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 && ( +
e.stopPropagation()} + onDragStart={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + { + event.stopPropagation(); + onTogglePage(page.id); + }} + onClick={(e) => e.stopPropagation()} + size="sm" + /> +
+ )} + +
+
+ {`Page +
+ + + {page.pageNumber} + + +
+ + { + 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`); + } + }} + > + + + + + + { + 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`); + } + }} + > + + + + + + { + e.stopPropagation(); + const command = new RotatePagesCommand( + pdfDocument, + setPdfDocument, + [page.id], + -90 + ); + onExecuteCommand(command); + onSetStatus(`Rotated page ${page.pageNumber} left`); + }} + > + + + + + + { + e.stopPropagation(); + const command = new RotatePagesCommand( + pdfDocument, + setPdfDocument, + [page.id], + 90 + ); + onExecuteCommand(command); + onSetStatus(`Rotated page ${page.pageNumber} right`); + }} + > + + + + + + { + e.stopPropagation(); + const command = new DeletePagesCommand( + pdfDocument, + setPdfDocument, + [page.id] + ); + onExecuteCommand(command); + onSetStatus(`Deleted page ${page.pageNumber}`); + }} + > + + + + + {index > 0 && ( + + { + e.stopPropagation(); + const command = new ToggleSplitCommand( + pdfDocument, + setPdfDocument, + [page.id] + ); + onExecuteCommand(command); + onSetStatus(`Split marker toggled for page ${page.pageNumber}`); + }} + > + + + + )} +
+ + +
+
+ ); +}; + +export default PageThumbnail; \ No newline at end of file diff --git a/frontend/src/components/editor/shared/DragDropGrid.tsx b/frontend/src/components/editor/shared/DragDropGrid.tsx new file mode 100644 index 000000000..30bfe26bd --- /dev/null +++ b/frontend/src/components/editor/shared/DragDropGrid.tsx @@ -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 { + 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>) => 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 = ({ + items, + selectedItems, + selectionMode, + isAnimating, + onDragStart, + onDragEnd, + onDragOver, + onDragEnter, + onDragLeave, + onDrop, + onEndZoneDragEnter, + renderItem, + renderSplitMarker, + draggedItem, + dropTarget, + multiItemDrag, + dragPosition, +}: DragDropGridProps) => { + const itemRefs = useRef>(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 ( + +
+ {items.map((item, index) => ( + + {/* Split marker */} + {renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)} + + {/* Item */} + {renderItem(item, index, itemRefs)} + + ))} + + {/* End drop zone */} +
+
onDrop(e, 'end')} + > +
+ Drop here to
move to end +
+
+
+
+ + {/* Multi-item drag indicator */} + {multiItemDrag && dragPosition && ( +
+ {multiItemDrag.count} items +
+ )} +
+ ); +}; + +export default DragDropGrid; \ No newline at end of file diff --git a/frontend/src/components/fileManagement/FileCard.tsx b/frontend/src/components/fileManagement/FileCard.tsx new file mode 100644 index 000000000..6b275e556 --- /dev/null +++ b/frontend/src/components/fileManagement/FileCard.tsx @@ -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 ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onSelect} + > + + + {/* Hover action buttons */} + {isHovered && (onView || onEdit) && ( +
e.stopPropagation()} + > + {onView && ( + + { + e.stopPropagation(); + onView(); + }} + > + + + + )} + {onEdit && ( + + { + e.stopPropagation(); + onEdit(); + }} + > + + + + )} +
+ )} + {thumb ? ( + PDF thumbnail + ) : isGenerating ? ( +
+
+ Generating... +
+ ) : ( +
+ 100 * 1024 * 1024 ? "orange" : "red"} + size={60} + radius="sm" + style={{ display: "flex", alignItems: "center", justifyContent: "center" }} + > + + + {file.size > 100 * 1024 * 1024 && ( + Large File + )} +
+ )} + + + + {file.name} + + + + + {getFileSize(file)} + + + {getFileDate(file)} + + {file.storedInIndexedDB && ( + } + > + DB + + )} + + + + + + ); +}; + +export default FileCard; diff --git a/frontend/src/components/fileManagement/StorageStatsCard.tsx b/frontend/src/components/fileManagement/StorageStatsCard.tsx new file mode 100644 index 000000000..2d2488712 --- /dev/null +++ b/frontend/src/components/fileManagement/StorageStatsCard.tsx @@ -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 ( + + + + +
+ + {t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)} + + 60 ? "yellow" : "blue"} + size="sm" + mt={4} + /> + + + {storageStats.fileCount} files • {t("storage.approximateSize", "Approximate size")} + + + {Math.round(hardLimitPercent)}% used + + + {isNearLimit && ( + + {t("storage.storageFull", "Storage is nearly full. Consider removing some files.")} + + )} +
+ + {filesCount > 0 && ( + + )} + + +
+
+
+ ); +}; + +export default StorageStatsCard; diff --git a/frontend/src/components/shared/FilePickerModal.tsx b/frontend/src/components/shared/FilePickerModal.tsx new file mode 100644 index 000000000..2a5cfa76e --- /dev/null +++ b/frontend/src/components/shared/FilePickerModal.tsx @@ -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([]); + + // 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 ( + + + {sharedFiles.length === 0 ? ( + + {t("fileUpload.noFilesInStorage", "No files available in storage. Upload some files first.")} + + ) : ( + <> + {/* Selection controls */} + + + {sharedFiles.length} {t("fileUpload.filesAvailable", "files available")} + + + + + + + + {/* File grid */} + + + {sharedFiles.map((file) => { + const fileId = file.id || file.name; + const isSelected = selectedFileIds.includes(fileId); + + return ( + toggleFileSelection(fileId)} + > + + toggleFileSelection(fileId)} + onClick={(e) => e.stopPropagation()} + /> + + {/* Thumbnail */} + + {file.thumbnail ? ( + PDF thumbnail + ) : ( + + + + )} + + + {/* File info */} + + + {file.name} + + + + {formatFileSize(file.size || (file.file?.size || 0))} + + + + + + ); + })} + + + + {/* Selection summary */} + {selectedFileIds.length > 0 && ( + + {selectedFileIds.length} {t("fileManager.filesSelected", "files selected")} + + )} + + )} + + {/* Action buttons */} + + + + + + + ); +}; + +export default FilePickerModal; \ No newline at end of file diff --git a/frontend/src/components/shared/FileUploadSelector.tsx b/frontend/src/components/shared/FileUploadSelector.tsx new file mode 100644 index 000000000..c4aed1148 --- /dev/null +++ b/frontend/src/components/shared/FileUploadSelector.tsx @@ -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 ( + <> + + {/* Title and description */} + + + + {displayTitle} + + + {displaySubtitle} + + + + {/* Action buttons */} + + + + + {t("fileUpload.or", "or")} + + + {showDropzone ? ( + +
+ + + {t(allowMultiple ? "fileUpload.dropFilesHere" : "fileUpload.dropFileHere", + allowMultiple ? "Drop files here or click to upload" : "Drop file here or click to upload")} + + + {accept.includes('application/pdf') + ? t("fileUpload.pdfFilesOnly", "PDF files only") + : t("fileUpload.supportedFileTypes", "Supported file types") + } + + +
+
+ ) : ( + + + + )} +
+
+ + {/* File Picker Modal */} + setShowFilePickerModal(false)} + sharedFiles={sharedFiles} + onSelectFiles={handleStorageSelection} + /> + + ); +}; + +export default FileUploadSelector; diff --git a/frontend/src/components/LanguageSelector.module.css b/frontend/src/components/shared/LanguageSelector.module.css similarity index 87% rename from frontend/src/components/LanguageSelector.module.css rename to frontend/src/components/shared/LanguageSelector.module.css index 22053b457..09010dc4a 100644 --- a/frontend/src/components/LanguageSelector.module.css +++ b/frontend/src/components/shared/LanguageSelector.module.css @@ -68,4 +68,21 @@ .languageText { 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; + } } \ No newline at end of file diff --git a/frontend/src/components/shared/LanguageSelector.tsx b/frontend/src/components/shared/LanguageSelector.tsx new file mode 100644 index 000000000..83cecc6b0 --- /dev/null +++ b/frontend/src/components/shared/LanguageSelector.tsx @@ -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(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 ( + <> + + + + + + + + +
+ {languageOptions.map((option, index) => ( +
+ +
+ ))} +
+
+
+
+ + ); +}; + +export default LanguageSelector; \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx new file mode 100644 index 000000000..9c904cb66 --- /dev/null +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -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 ( +
+ + {/* All Tools Button */} +
+ + + + Tools +
+ + {/* Reader Mode Button */} +
+ + + + Read +
+ + {/* Spacer */} +
+ +
+ ); +}; + +export default QuickAccessBar; \ No newline at end of file diff --git a/frontend/src/components/shared/RainbowThemeProvider.tsx b/frontend/src/components/shared/RainbowThemeProvider.tsx new file mode 100644 index 000000000..27cf5101f --- /dev/null +++ b/frontend/src/components/shared/RainbowThemeProvider.tsx @@ -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(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 ( + <> + + + +
+ {children} +
+
+
+ + ); +} diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx new file mode 100644 index 000000000..13c82103f --- /dev/null +++ b/frontend/src/components/shared/TopControls.tsx @@ -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: ( + + + + ), + value: "viewer", + }, + { + label: ( + + + + ), + value: "pageEditor", + }, + { + label: ( + + + + ), + value: "fileManager", + }, + { + label: ( + + + + ), + 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 ; + if (themeMode === "dark") return ; + return ; + }; + + return ( +
+
+ + +
+
+ +
+
+ ); +}; + +export default TopControls; diff --git a/frontend/src/components/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx similarity index 94% rename from frontend/src/components/ToolPicker.tsx rename to frontend/src/components/tools/ToolPicker.tsx index 746112011..acf86dd35 100644 --- a/frontend/src/components/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -17,7 +17,7 @@ interface ToolPickerProps { toolRegistry: ToolRegistry; } -const ToolPicker: React.FC = ({ selectedToolKey, onSelect, toolRegistry }) => { +const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => { const { t } = useTranslation(); const [search, setSearch] = useState(""); diff --git a/frontend/src/components/tools/ToolRenderer.tsx b/frontend/src/components/tools/ToolRenderer.tsx new file mode 100644 index 000000000..5ce167864 --- /dev/null +++ b/frontend/src/components/tools/ToolRenderer.tsx @@ -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
Tool not found
; + } + + const ToolComponent = selectedTool.component; + + // Pass tool-specific props + switch (selectedToolKey) { + case "split": + return ( + + ); + case "compress": + return ( + {}} // TODO: Add loading state + params={toolParams} + updateParams={updateParams} + /> + ); + case "merge": + return ( + + ); + default: + return ( + + ); + } +}; + +export default ToolRenderer; \ No newline at end of file diff --git a/frontend/src/hooks/useFileWithUrl.ts b/frontend/src/hooks/useFileWithUrl.ts new file mode 100644 index 000000000..d06cb73f2 --- /dev/null +++ b/frontend/src/hooks/useFileWithUrl.ts @@ -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]); +} \ No newline at end of file diff --git a/frontend/src/hooks/usePDFProcessor.ts b/frontend/src/hooks/usePDFProcessor.ts new file mode 100644 index 000000000..7b1cc0c4b --- /dev/null +++ b/frontend/src/hooks/usePDFProcessor.ts @@ -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(null); + + const generatePageThumbnail = useCallback(async ( + file: File, + pageNumber: number, + scale: number = 0.5 + ): Promise => { + 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 => { + 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 + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useRainbowTheme.ts b/frontend/src/hooks/useRainbowTheme.ts new file mode 100644 index 000000000..a3c8f6e67 --- /dev/null +++ b/frontend/src/hooks/useRainbowTheme.ts @@ -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(() => { + 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! 🌈
Button disabled for 3 seconds, then click to exit'; + 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, + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useToolParams.ts b/frontend/src/hooks/useToolParams.ts new file mode 100644 index 000000000..be6145b3e --- /dev/null +++ b/frontend/src/hooks/useToolParams.ts @@ -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, + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useUndoRedo.ts b/frontend/src/hooks/useUndoRedo.ts new file mode 100644 index 000000000..6ce914a89 --- /dev/null +++ b/frontend/src/hooks/useUndoRedo.ts @@ -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 + }; +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 17df0e7ec..ec2585e8c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,7 +1,3 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index e9863e7e3..e8d719e24 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,9 +1,9 @@ import '@mantine/core/styles.css'; +import './index.css'; // Import Tailwind CSS import React from 'react'; import ReactDOM from 'react-dom/client'; import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core'; import { BrowserRouter } from 'react-router-dom'; -import './index.css'; import App from './App'; import './i18n'; // Initialize i18next diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts new file mode 100644 index 000000000..e26037c66 --- /dev/null +++ b/frontend/src/services/pdfExportService.ts @@ -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 { + 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 { + // 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(); \ No newline at end of file diff --git a/frontend/src/styles/rainbow.module.css b/frontend/src/styles/rainbow.module.css new file mode 100644 index 000000000..d56578a14 --- /dev/null +++ b/frontend/src/styles/rainbow.module.css @@ -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; +} diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css new file mode 100644 index 000000000..9415e80ce --- /dev/null +++ b/frontend/src/styles/tailwind.css @@ -0,0 +1,15 @@ + +/* Import minimal theme variables */ +@import './theme.css'; + +@layer base { + @tailwind base; +} + +@layer components { + @tailwind components; +} + +@layer utilities { + @tailwind utilities; +} diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css new file mode 100644 index 000000000..98f05a2f9 --- /dev/null +++ b/frontend/src/styles/theme.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts new file mode 100644 index 000000000..91670d6cf --- /dev/null +++ b/frontend/src/theme/mantineTheme.ts @@ -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)', + }, + }), +}); diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index 37b4efe8c..2e3dbbe24 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -143,7 +143,7 @@ const SplitPdfPanel: React.FC = ({ }; return ( -
+