Compare commits

..
Author SHA1 Message Date
pixeebot[bot] 7f096297af Hardening suggestions for Stirling-PDF / multiFileAsync (#3923)
I've reviewed the recently opened PR ([3922 - Support multi-file async
job results and ZIP
extraction](https://github.com/Stirling-Tools/Stirling-PDF/pull/3922))
and have identified some area(s) that could benefit from additional
hardening measures.

These changes should help prevent potential security vulnerabilities and
improve overall code quality.

Thank you for your consideration!
🧚🤖  Powered by Pixeebot  

[Feedback](https://ask.pixee.ai/feedback) |
[Community](https://pixee-community.slack.com/signup#/domain-signup) |
[Docs](https://docs.pixee.ai/)
![](https://d1zaessa2hpsmj.cloudfront.net/pixel/v1/track?writeKey=2PI43jNm7atYvAuK7rJUz3Kcd6A&event=PR_HARDENING%7CStirling-Tools%2FStirling-PDF%7C624e04a783753f7728d85d32469b6a2b5d4b113f)

Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
2025-07-10 16:36:19 +01:00
Anthony Stirling 624e04a783 tests and formatting 2025-07-10 15:22:37 +01:00
Anthony Stirling bb9f1d4f8b extra fixes 2025-07-10 14:58:11 +01:00
Anthony StirlingandCopilot 0d967aa5ea Update common/src/main/java/stirling/software/common/service/TaskManager.java
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-10 14:55:49 +01:00
Anthony Stirling f19ff6691d cleanups 2025-07-10 14:34:43 +01:00
Anthony Stirling 98b4763c96 remove "legacy" 2025-07-10 14:30:22 +01:00
Anthony Stirling 2c9537f786 multi file async 2025-07-10 14:18:35 +01:00
201 changed files with 217 additions and 142103 deletions
-310
View File
@@ -1,310 +0,0 @@
name: Auto PR V2 Deployment
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check-pr:
runs-on: ubuntu-latest
outputs:
should_deploy: ${{ steps.check-conditions.outputs.should_deploy }}
pr_number: ${{ github.event.number }}
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Check deployment conditions
id: check-conditions
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
echo "PR Title: $PR_TITLE"
echo "PR Author: $PR_AUTHOR"
echo "PR Branch: $PR_BRANCH"
# Check if author is authorized
authorized_users=(
"Frooodle"
"sf298"
"Ludy87"
"LaserKaspar"
"sbplat"
"reecebrowne"
"DarioGii"
"ConnorYoh"
)
is_authorized=false
for user in "${authorized_users[@]}"; do
if [[ "$PR_AUTHOR" == "$user" ]]; then
is_authorized=true
break
fi
done
# Check if title contains V2/version2 keywords (case insensitive)
has_v2_keyword=false
if [[ "$PR_TITLE" =~ [Vv]2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?[Tt]wo ]]; then
has_v2_keyword=true
fi
# Check if branch name contains V2 or react keywords (case insensitive)
has_branch_keyword=false
if [[ "$PR_BRANCH" =~ [Vv]2 ]] || [[ "$PR_BRANCH" =~ [Rr]eact ]]; then
has_branch_keyword=true
fi
if [[ "$is_authorized" == "true" ]] && [[ "$has_v2_keyword" == "true" || "$has_branch_keyword" == "true" ]]; then
echo "✅ Deployment conditions met"
echo "should_deploy=true" >> $GITHUB_OUTPUT
else
echo "❌ Deployment conditions not met"
echo " - Authorized user: $is_authorized"
echo " - Has V2 keyword in title: $has_v2_keyword"
echo " - Has V2/React keyword in branch: $has_branch_keyword"
echo "should_deploy=false" >> $GITHUB_OUTPUT
fi
- name: Get PR repository and ref
id: get-pr-info
if: steps.check-conditions.outputs.should_deploy == 'true'
run: |
# For forks, use the full repository name, for internal PRs use the current repo
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
repository="${{ github.event.pull_request.head.repo.full_name }}"
else
repository="${{ github.repository }}"
fi
echo "repository=$repository" >> $GITHUB_OUTPUT
echo "ref=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT
deploy-v2-pr:
needs: check-pr
runs-on: ubuntu-latest
if: needs.check-pr.outputs.should_deploy == 'true'
# Concurrency control - only one deployment per PR at a time
concurrency:
group: v2-deploy-pr-${{ needs.check-pr.outputs.pr_number }}
cancel-in-progress: true
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Add deployment started comment
id: deployment-started
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
// Delete previous V2 deployment comments to avoid clutter
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
const v2Comments = comments.filter(comment =>
comment.body.includes('🚀 **Auto-deploying V2 version**') ||
comment.body.includes('## 🚀 V2 Auto-Deployment Complete!') ||
comment.body.includes('❌ **V2 Auto-deployment failed**')
);
for (const comment of v2Comments) {
console.log(`Deleting old V2 comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
// Create new deployment started comment
const { data: newComment } = await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
});
return newComment.id;
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: ${{ needs.check-pr.outputs.pr_repository }}
ref: ${{ needs.check-pr.outputs.pr_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- name: Build backend
run: |
export DISABLE_ADDITIONAL_FEATURES=true
./gradlew clean build
env:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push V2 monolith image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/monolith/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-pr-${{ needs.check-pr.outputs.pr_number }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy V2 to VPS
id: deploy
run: |
# Use same port strategy as regular PRs - just the PR number
V2_PORT=${{ needs.check-pr.outputs.pr_number }}
# Create docker-compose for V2 monolith
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf-v2:
container_name: stirling-pdf-v2-pr-${{ needs.check-pr.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-pr-${{ needs.check-pr.outputs.pr_number }}
ports:
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
volumes:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture"
UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
restart: on-failure:5
EOF
# Deploy to VPS
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
# Create V2 PR-specific directories
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
# Move docker-compose file to correct location
mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml
# Stop any existing container and clean up
cd /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}
docker-compose down --remove-orphans 2>/dev/null || true
# Start the new container
docker-compose pull
docker-compose up -d
# Clean up unused Docker resources to save space
docker system prune -af --volumes
ENDSSH
# Set port for output
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
const v2Port = ${{ steps.deploy.outputs.v2_port }};
// Delete the "deploying..." comment since we're posting the final result
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
if (deploymentStartedId) {
console.log(`Deleting deployment started comment: ${deploymentStartedId}`);
try {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: deploymentStartedId
});
} catch (error) {
console.log(`Could not delete deployment started comment: ${error.message}`);
}
}
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` +
`🔗 **V2 Test URL:** [${deploymentUrl}](${deploymentUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
@@ -1,122 +0,0 @@
name: Frontend License Report Workflow
on:
push:
branches:
- feature/react-overhaul
paths:
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
permissions:
contents: read
jobs:
generate-frontend-license-report:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Generate frontend license report
working-directory: frontend
run: npm run generate-licenses
- name: Check for license warnings
run: |
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
fi
- name: Commit changes
run: |
git add frontend/src/assets/3rdPartyLicenses.json
if [ -f "frontend/src/assets/license-warnings.json" ]; then
git add frontend/src/assets/license-warnings.json
fi
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Prepare PR body
run: |
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
This PR updates the frontend license report based on changes to package.json dependencies."
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
PR_BODY="$PR_BODY
## ⚠️ License Compatibility Warnings
The following licenses may require review for corporate compatibility:
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
Please review these licenses to ensure they are acceptable for your use case."
fi
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Pull Request
id: cpr
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Frontend 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-frontend-3rd-party-licenses
base: feature/react-overhaul
title: "Update Frontend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,frontend
draft: false
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge
if: steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
- name: Add review required label
if: steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
-3
View File
@@ -27,7 +27,6 @@ clientWebUI/
!cucumber/exampleFiles/
!cucumber/exampleFiles/example_html.zip
exampleYmlFiles/stirling/
stirling/
/testing/file_snapshots
SwaggerDoc.json
@@ -194,8 +193,6 @@ id_ed25519.pub
.pytest_cache
.ipynb_checkpoints
**/jcef-bundle/
# node_modules
-124
View File
@@ -1,124 +0,0 @@
# 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
-692
View File
@@ -1,692 +0,0 @@
# Stirling-PDF Developer Guide
## 1. Introduction
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 2.0** is built using:
**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
### Prerequisites
- Docker
- Git
- 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
1. Clone the repository:
```bash
git clone https://github.com/Stirling-Tools/Stirling-PDF.git
cd Stirling-PDF
```
2. Install Docker and JDK17 if not already installed.
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
1. Open VS Code.
2. When prompted, install the recommended extensions.
3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run:
```sh
Extensions: Show Recommended Extensions
```
4. Install the required extensions from the list.
4. Lombok Setup
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 DISABLE_ADDITIONAL_FEATURES=false 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. 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)
├── 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
├── images/ # Image assets
├── pipeline/ # Pipeline-related files (generated at runtime)
├── scripts/ # Utility scripts
├── src/ # Source code
│ ├── main/
│ │ ├── java/
│ │ │ └── stirling/
│ │ │ └── software/
│ │ │ └── SPDF/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ ├── model/
│ │ │ ├── repository/
│ │ │ ├── service/
│ │ │ └── utils/
│ │ └── resources/
│ │ ├── static/ # Legacy static assets (reference only)
│ │ │ ├── css/
│ │ │ ├── js/
│ │ │ └── pdfjs/
│ │ └── templates/ # Legacy Thymeleaf templates (reference only)
│ └── test/
├── 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
├── Dockerfile.fat # Dockerfile for fat version
├── docker-compose.yml # Docker Compose configuration
└── test.sh # Test script to deploy all docker versions and run cuke tests
```
## 6. Docker-based Development
Stirling-PDF offers several Docker versions:
- Full: All features included
- Ultra-Lite: Basic PDF operations only
- Fat: Includes additional libraries and fonts predownloaded
### Example Docker Compose Files
Stirling-PDF provides several example Docker Compose files in the `exampleYmlFiles` directory, such as:
- `docker-compose-latest.yml`: Latest version without login and security features
- `docker-compose-latest-security.yml`: Latest version with login and security features enabled
- `docker-compose-latest-fat-security.yml`: Fat version with login and security features enabled
These files provide pre-configured setups for different scenarios. For example, here's a snippet from `docker-compose-latest-security.yml`:
```yaml
services:
stirling-pdf:
container_name: Stirling-PDF-Security
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- "8080:8080"
volumes:
- ./stirling/latest/data:/usr/share/tessdata:rw
- ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
UMASK: "022"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest with Security
UI_APPNAMENAVBAR: Stirling-PDF Latest
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SHOW_SURVEY: "true"
restart: on-failure:5
```
To use these example files, copy the desired file to your project root and rename it to `docker-compose.yml`, or specify the file explicitly when running Docker Compose:
```bash
docker-compose -f exampleYmlFiles/docker-compose-latest-security.yml up
```
### Building Docker Images
Stirling-PDF uses different Docker images for various configurations. The build process is controlled by environment variables and uses specific Dockerfile variants. Here's how to build the Docker images:
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
```
2. Build the project with Gradle:
```bash
./gradlew clean build
```
3. Build the Docker images:
For the latest version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .
```
For the ultra-lite version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .
```
For the fat version (with login and security features enabled):
```bash
export DISABLE_ADDITIONAL_FEATURES=false
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
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
## 7. Testing
### Comprehensive Testing Script
Stirling-PDF provides a `test.sh` script in the root directory. This script builds all versions of Stirling-PDF, checks that each version works, and runs Cucumber tests. It's recommended to run this script before submitting a final pull request.
To run the test script:
```bash
./test.sh
```
This script performs the following actions:
1. Builds all Docker images (full, ultra-lite, fat).
2. Runs each version to ensure it starts correctly.
3. Executes Cucumber tests against the main version and ensures feature compatibility. In the event these tests fail, your PR will not be merged.
Note: The `test.sh` script will run automatically when you raise a PR. However, it's recommended to run it locally first to save resources and catch any issues early.
### Full Testing with Docker
1. Build and run the Docker container per the above instructions:
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:
- Java backend logic
- RESTful API endpoints
- JavaScript functionality
- User interface components and styling
- Thymeleaf templates
To run Stirling-PDF locally:
1. Compile and run the project using built-in IDE methods or by running:
```bash
./gradlew bootRun
```
2. Access the application at `http://localhost:8080` in your web browser.
3. Manually test the features you're working on through the UI.
4. For API changes, use tools like Postman or curl to test endpoints directly.
Important notes:
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
- 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.
## 8. Contributing
1. Fork the repository on GitHub.
2. Create a new branch for your feature or bug fix.
3. Make your changes and commit them with clear, descriptive messages and ensure any documentation is updated related to your changes.
4. Test your changes thoroughly in the Docker environment.
5. Run the `test.sh` script to ensure all versions build correctly and pass the Cucumber tests:
```bash
./test.sh
```
6. Push your changes to your fork.
7. Submit a pull request to the main repository.
8. See additional [contributing guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md).
When you raise a PR:
- The `test.sh` script will run automatically against your PR.
- The PR checks will verify versioning and dependency updates.
- Documentation will be automatically updated for dependency changes.
- Security issues will be checked using Snyk and PixeeBot.
Address any issues that arise from these checks before finalizing your pull request.
## 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/).
## 10. Customization
Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include:
- Application name and branding
- Security settings
- UI customization
- Endpoint management
When using Docker, pass environment variables using the `-e` flag or in your `docker-compose.yml` file.
Example:
```bash
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.
## 11. Language Translations
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
```bash
/scripts/replace_translation_line.sh
```
This script helps you make consistent replacements across language files.
When contributing translations:
1. Use the helper script for multi-file changes.
2. Ensure all language files are updated consistently.
3. The PR checks will verify consistency in language file updates.
Remember to test your changes thoroughly to ensure they don't break any existing functionality.
## Code examples
### React Component Development (Stirling 2.0)
For Stirling 2.0, new features are built as React components instead of Thymeleaf templates:
#### Creating a New Tool Component
1. **Create the React Component:**
```typescript
// frontend/src/tools/NewTool.tsx
import { useState } from 'react';
import { Button, FileInput, Container } from '@mantine/core';
interface NewToolProps {
params: Record<string, any>;
updateParams: (updates: Record<string, any>) => void;
}
export default function NewTool({ params, updateParams }: NewToolProps) {
const [files, setFiles] = useState<File[]>([]);
const handleProcess = async () => {
// Process files using API or client-side logic
};
return (
<Container>
<FileInput
multiple
accept="application/pdf"
onChange={setFiles}
/>
<Button onClick={handleProcess}>Process</Button>
</Container>
);
}
```
2. **Add API Integration:**
```typescript
// Use existing API endpoints or create new ones
const response = await fetch('/api/v1/new-tool', {
method: 'POST',
body: formData
});
```
3. **Register in Tool Picker:**
Update the tool picker component to include the new tool with proper routing and URL parameter support.
### Legacy Reference: Overview of Thymeleaf
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
### Thymeleaf overview
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
Some examples of this are:
```html
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
```
or
```html
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
```
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
They can be more complex, such as:
```html
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
```
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
```java
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("exampleData", exampleData);
return "new-feature";
}
```
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
```html
<tbody>
<!-- Use th:each to iterate over the list -->
<tr th:each="person : ${exampleData}">
<td th:text="${person.id}"></td>
<td th:text="${person.name}"></td>
<td th:text="${person.age}"></td>
<td th:text="${person.email}"></td>
</tr>
</tbody>
```
This would generate n entries of tr for each person in exampleData
### Adding a New Feature to the Backend (API)
1. **Create a New Controller:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
}
}
```
2. **Define the Service Layer:** (Not required but often useful)
- Create a new service class in the `stirling-pdf/src/main/java/stirling/software/SPDF/service` directory.
- Implement the business logic for the new feature.
```java
package stirling.software.SPDF.service;
import org.springframework.stereotype.Service;
@Service
public class NewFeatureService {
public String getNewFeatureData() {
// Implement business logic here
return "New Feature Data";
}
}
```
2b. **Integrate the Service with the Controller:**
- Autowire the service class in the controller and use it to handle the API request.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.SPDF.service.NewFeatureService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return newFeatureService.getNewFeatureData();
}
}
```
### Adding a New Feature to the Frontend (UI)
1. **Create a New Thymeleaf Template:**
- Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory.
- Use Thymeleaf attributes to dynamically generate content.
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
```html
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
</div>
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
<input type="hidden" id="customMode" name="customMode" value="">
<div class="mb-3">
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
```
2. **Create a New Controller for the UI:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
```java
package stirling.software.SPDF.controller.ui;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.NewFeatureService;
@Controller
@RequestMapping("/new-feature")
public class NewFeatureUIController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
return "new-feature";
}
}
```
3. **Update the Navigation Bar:**
- Add a link to the new feature page in the navigation bar.
- Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file.
```html
<li class="nav-item">
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
</li>
```
## Adding New Translations to Existing Language Files in Stirling-PDF
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
### 1. Locate Existing Language Files
Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
- `messages_fr_FR.properties`
- `messages_de_DE.properties`
- etc.
### 2. Add New Translation Entries
Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter",
Use descriptive, hierarchical keys (e.g., `feature.element.description`)
you might add:
```properties
pdfSplitter.title=PDF Splitter
pdfSplitter.description=Split your PDF into multiple documents
pdfSplitter.button.split=Split PDF
pdfSplitter.input.pages=Enter page numbers to split
```
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
### 3. Use Translations in Thymeleaf Templates
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
```html
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
```
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
+14 -8
View File
@@ -1,4 +1,4 @@
# Backend Dockerfile - Java Spring Boot with all dependencies
# Main stage
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
@@ -9,8 +9,8 @@ COPY stirling-pdf/build/libs/*.jar app.jar
ARG VERSION_TAG
LABEL org.opencontainers.image.title="Stirling-PDF Backend"
LABEL org.opencontainers.image.description="Backend service for Stirling-PDF - Java Spring Boot with PDF processing capabilities"
LABEL org.opencontainers.image.title="Stirling-PDF"
LABEL org.opencontainers.image.description="A powerful locally hosted web-based PDF manipulation tool supporting 50+ operations including merging, splitting, conversion, OCR, watermarking, and more."
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
@@ -19,7 +19,7 @@ LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \
@@ -39,7 +39,8 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# JDK for app and all dependencies
# JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
@@ -50,7 +51,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
tini \
bash \
curl \
qpdf \
shadow \
su-exec \
openssl \
@@ -68,12 +68,16 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
unpaper \
# CV
py3-opencv \
python3 \
ocrmypdf \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
py3-pdf2image@testing \
# URW Base 35 fonts for better PDF rendering
font-urw-base35 && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
@@ -82,6 +86,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
# Configure URW Base 35 fonts
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
@@ -94,4 +100,4 @@ EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
+61
View File
@@ -0,0 +1,61 @@
# dockerfile.dev
# Basisimage: Gradle mit JDK 17 (Debian-basiert)
FROM gradle:8.14-jdk17
# Als Root-Benutzer arbeiten, um benötigte Pakete zu installieren
USER root
# Set GRADLE_HOME und füge Gradle zum PATH hinzu
ENV GRADLE_HOME=/opt/gradle
ENV PATH="$GRADLE_HOME/bin:$PATH"
# Update und Installation zusätzlicher Pakete (Debian/Ubuntu-basiert)
RUN apt-get update && apt-get install -y \
sudo \
libreoffice \
poppler-utils \
qpdf \
# settings.yml | tessdataDir: /usr/share/tesseract-ocr/5/tessdata
tesseract-ocr \
tesseract-ocr-eng \
fonts-terminus fonts-dejavu fonts-font-awesome fonts-noto fonts-noto-core fonts-noto-cjk fonts-noto-extra fonts-liberation fonts-linuxlibertine fonts-urw-base35 \
python3-uno \
python3-venv \
# ss -tln
iproute2 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Setze die Environment Variable für setuptools
ENV SETUPTOOLS_USE_DISTUTILS=local \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Installation der benötigten Python-Pakete
RUN python3 -m venv --system-site-packages /opt/venv \
&& . /opt/venv/bin/activate \
&& pip install --upgrade pip setuptools \
&& pip install --no-cache-dir WeasyPrint pdf2image pillow unoserver opencv-python-headless pre-commit
# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind
ENV PATH="/opt/venv/bin:$PATH"
COPY . /workspace
RUN mkdir -p /tmp/stirling-pdf \
&& fc-cache -f -v \
&& adduser --disabled-password --gecos '' devuser \
&& chown -R devuser:devuser /home/devuser /workspace /tmp/stirling-pdf
RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \
&& chmod 0440 /etc/sudoers.d/devuser
# Setze das Arbeitsverzeichnis (wird später per Bind-Mount überschrieben)
WORKDIR /workspace
RUN chmod +x /workspace/.devcontainer/git-init.sh
RUN sudo chmod +x /workspace/.devcontainer/init-setup.sh
# Wechsel zum NichtRoot Benutzer
USER devuser
@@ -1,4 +1,3 @@
# Backend fat Dockerfile - Java Spring Boot with all dependencies and build stage
# Build the application
FROM gradle:8.14-jdk21 AS build
@@ -53,6 +52,7 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
@@ -76,16 +76,17 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced featues)
qpdf \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
unpaper \
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine font-urw-base35 \
# CV
py3-opencv \
python3 \
ocrmypdf \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
@@ -97,6 +98,8 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
# Configure URW Base 35 fonts
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
@@ -108,4 +111,4 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
@@ -1,4 +1,4 @@
# Backend ultra-lite Dockerfile - Java Spring Boot with minimal dependencies
# use alpine
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
ARG VERSION_TAG
@@ -1,5 +1,6 @@
package stirling.software.common.service;
import io.github.pixee.security.ZipSecurity;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -20,8 +21,6 @@ import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.ZipSecurity;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
@@ -362,8 +361,7 @@ public class TaskManager {
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(
new ByteArrayInputStream(zipFile.getBytes()))) {
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(zipFile.getBytes()))) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
-67
View File
@@ -1,67 +0,0 @@
# Docker Setup for Stirling-PDF
This directory contains the organized Docker configurations for the split frontend/backend architecture.
## Directory Structure
```
docker/
├── backend/ # Backend Docker files
│ ├── Dockerfile # Standard backend
│ ├── Dockerfile.ultra-lite # Minimal backend
│ └── Dockerfile.fat # Full-featured backend
├── frontend/ # Frontend Docker files
│ ├── Dockerfile # React/Vite frontend with nginx
│ ├── nginx.conf # Nginx configuration
│ └── entrypoint.sh # Dynamic backend URL setup
├── monolith/ # Single container setup
│ ├── Dockerfile # Combined frontend + backend
│ ├── nginx-monolith.conf # Nginx config for monolith
│ └── start-monolith.sh # Startup script
└── compose/ # Docker Compose files
├── docker-compose.yml # Standard setup
├── docker-compose.ultra-lite.yml # Ultra-lite setup
├── docker-compose.fat.yml # Full-featured setup
└── docker-compose.monolith.yml # Single container setup
```
## Usage
### Separate Containers (Recommended)
From the project root directory:
```bash
# Standard version
docker-compose -f docker/compose/docker-compose.yml up --build
# Ultra-lite version
docker-compose -f docker/compose/docker-compose.ultra-lite.yml up --build
# Fat version
docker-compose -f docker/compose/docker-compose.fat.yml up --build
```
### Single Container (Monolith)
```bash
# Single container with both frontend and backend
docker-compose -f docker/compose/docker-compose.monolith.yml up --build
```
## Access Points
- **Frontend**: http://localhost:3000
- **Backend API (debugging)**: http://localhost:8080 (TODO: Remove in production)
- **Backend API (via frontend)**: http://localhost:3000/api/*
## Configuration
- **Backend URL**: Set `BACKEND_URL` environment variable for custom backend locations
- **Custom Ports**: Modify port mappings in docker-compose files
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
## Development vs Production
- **Development**: Keep backend port 8080 exposed for debugging
- **Production**: Remove backend port exposure, use only frontend proxy
-66
View File
@@ -1,66 +0,0 @@
version: '3.8'
services:
backend:
build:
context: ../..
dockerfile: docker/backend/Dockerfile.fat
container_name: stirling-pdf-backend-fat
restart: on-failure:5
deploy:
resources:
limits:
memory: 6G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- "8080:8080" # TODO: Remove in production - for debugging only
expose:
- "8080"
volumes:
- ../../stirling/latest/data:/usr/share/tessdata:rw
- ../../stirling/latest/config:/configs:rw
- ../../stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "false"
FAT_DOCKER: "true"
INSTALL_BOOK_AND_ADVANCED_HTML_OPS: "false"
LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Full-featured Stirling-PDF with all capabilities
UI_APPNAMENAVBAR: Stirling-PDF Fat
SYSTEM_MAXFILESIZE: "200"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SHOW_SURVEY: "true"
networks:
- stirling-network
frontend:
build:
context: ../../frontend
dockerfile: ../docker/frontend/Dockerfile
container_name: stirling-pdf-frontend-fat
restart: on-failure:5
ports:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
depends_on:
- backend
networks:
- stirling-network
networks:
stirling-network:
driver: bridge
volumes:
stirling-data:
stirling-config:
stirling-logs:
@@ -1,42 +0,0 @@
version: '3.8'
services:
stirling-pdf-monolith:
build:
context: ../..
dockerfile: docker/monolith/Dockerfile
container_name: stirling-pdf-monolith
restart: on-failure:5
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ && curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
interval: 10s
timeout: 15s
retries: 16
ports:
- "3000:80" # Frontend access
- "8080:8080" # Direct backend access (for debugging)
volumes:
- ../../stirling/latest/data:/usr/share/tessdata:rw
- ../../stirling/latest/config:/configs:rw
- ../../stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Single container Stirling-PDF
UI_APPNAMENAVBAR: Stirling-PDF Monolith
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SHOW_SURVEY: "true"
volumes:
stirling-data:
stirling-config:
stirling-logs:
@@ -1,56 +0,0 @@
version: '3.8'
services:
backend:
build:
context: ../..
dockerfile: docker/backend/Dockerfile.ultra-lite
container_name: stirling-pdf-backend-ultra-lite
restart: on-failure:5
deploy:
resources:
limits:
memory: 2G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- "8080:8080" # TODO: Remove in production - for debugging only
expose:
- "8080"
volumes:
- ../../stirling/latest/config:/configs:rw
- ../../stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
ENDPOINTS_GROUPS_TO_REMOVE: "CLI"
LANGS: "en_GB,en_US"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Ultra-lite version of Stirling-PDF
UI_APPNAMENAVBAR: Stirling-PDF Ultra-lite
SYSTEM_MAXFILESIZE: "50"
networks:
- stirling-network
frontend:
build:
context: ../../frontend
dockerfile: ../docker/frontend/Dockerfile
container_name: stirling-pdf-frontend-ultra-lite
restart: on-failure:5
ports:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
depends_on:
- backend
networks:
- stirling-network
networks:
stirling-network:
driver: bridge
-64
View File
@@ -1,64 +0,0 @@
version: '3.8'
services:
backend:
build:
context: ../..
dockerfile: docker/backend/Dockerfile
container_name: stirling-pdf-backend
restart: on-failure:5
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- "8080:8080" # TODO: Remove in production - for debugging only
expose:
- "8080"
volumes:
- ../../stirling/latest/data:/usr/share/tessdata:rw
- ../../stirling/latest/config:/configs:rw
- ../../stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest
UI_APPNAMENAVBAR: Stirling-PDF Latest
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SHOW_SURVEY: "true"
networks:
- stirling-network
frontend:
build:
context: ../../frontend
dockerfile: ../docker/frontend/Dockerfile
container_name: stirling-pdf-frontend
restart: on-failure:5
ports:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
depends_on:
- backend
networks:
- stirling-network
networks:
stirling-network:
driver: bridge
volumes:
stirling-data:
stirling-config:
stirling-logs:
-38
View File
@@ -1,38 +0,0 @@
# Frontend Dockerfile - React/Vite application
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration and entrypoint
COPY nginx.conf /etc/nginx/nginx.conf
COPY entrypoint.sh /entrypoint.sh
# Make entrypoint executable
RUN chmod +x /entrypoint.sh
# Expose port 80 (standard HTTP port)
EXPOSE 80
# Environment variables for flexibility
ENV BACKEND_URL=http://backend:8080
# Use custom entrypoint
ENTRYPOINT ["/entrypoint.sh"]
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
# Set default backend URL if not provided
BACKEND_URL=${BACKEND_URL:-"http://backend:8080"}
# Replace the placeholder in nginx.conf with the actual backend URL
sed -i "s|\${BACKEND_URL}|${BACKEND_URL}|g" /etc/nginx/nginx.conf
# Start nginx
exec nginx -g "daemon off;"
-49
View File
@@ -1,49 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
# Handle client-side routing - support subpaths
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API calls to backend
location /api/ {
proxy_pass ${BACKEND_URL}/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
}
-128
View File
@@ -1,128 +0,0 @@
# Monolith Dockerfile - Frontend + Backend in same container
# Build frontend
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
# Copy frontend package files
COPY frontend/package*.json ./
# Install frontend dependencies
RUN npm ci
# Copy frontend source
COPY frontend/ ./
# Build frontend
RUN npm run build
# Main stage - Backend with frontend files
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
COPY pipeline /pipeline
COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY stirling-pdf/build/libs/*.jar app.jar
# Copy built frontend files
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
ARG VERSION_TAG
LABEL org.opencontainers.image.title="Stirling-PDF Monolith"
LABEL org.opencontainers.image.description="Single container with both frontend and backend for Stirling-PDF"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, monolith, single-container"
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \
VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
UMASK=022 \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Install nginx and all dependencies
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
qpdf \
shadow \
su-exec \
openssl \
openssl-dev \
openjdk21-jre \
nginx \
# Doc conversion
gcompat \
libc6-compat \
libreoffice \
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced features)
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
# CV
py3-opencv \
python3 \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar && \
chown -R stirlingpdfuser:stirlingpdfgroup /usr/share/nginx/html
# Copy nginx configuration for monolith
COPY docker/monolith/nginx-monolith.conf /etc/nginx/nginx.conf
# Copy startup script
COPY docker/monolith/start-monolith.sh /start-monolith.sh
RUN chmod +x /start-monolith.sh
# Expose both ports
EXPOSE 80 8080
# Set user and run command
ENTRYPOINT ["tini", "--"]
CMD ["/start-monolith.sh"]
-49
View File
@@ -1,49 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
# Handle client-side routing - support subpaths
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API calls to backend running on same container
location /api/ {
proxy_pass http://localhost:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
}
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
# Start the Java backend in the background
echo "Starting Java backend..."
su-exec stirlingpdfuser:stirlingpdfgroup bash -c "
cd /home/stirlingpdfuser && \
java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar &
/opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1 &
"
# Wait for backend to start
echo "Waiting for backend to start..."
until curl -f http://localhost:8080/api/v1/info/status >/dev/null 2>&1; do
sleep 2
done
echo "Backend started, starting nginx..."
# Start nginx in the foreground
nginx -g "daemon off;"
-24
View File
@@ -1,24 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
/dist
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-70
View File
@@ -1,70 +0,0 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
-22
View File
@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using Vite"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Vite App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
-6276
View File
File diff suppressed because it is too large Load Diff
-71
View File
@@ -1,71 +0,0 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"proxy": "http://localhost:8080",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mantine/core": "^8.0.1",
"@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "^8.0.1",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@tailwindcss/postcss": "^4.1.8",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.21",
"axios": "^1.9.0",
"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",
"react-i18next": "^15.5.2",
"react-router-dom": "^7.6.0",
"tailwindcss": "^4.1.8",
"web-vitals": "^2.1.4"
},
"scripts": {
"dev": "vite",
"build": "npm run generate-licenses && vite build",
"preview": "vite preview",
"generate-licenses": "node scripts/generate-licenses.js",
"prebuild": "npm run generate-licenses"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
"license-checker": "^25.0.1",
"postcss": "^8.5.3",
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: [
require('@tailwindcss/postcss'),
require('autoprefixer'),
],
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

-25
View File
@@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
-58353
View File
File diff suppressed because one or more lines are too long
-3
View File
@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
-322
View File
@@ -1,322 +0,0 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
/**
* Generate 3rd party licenses for frontend dependencies
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
*/
const OUTPUT_FILE = path.join(__dirname, '..', 'src', 'assets', '3rdPartyLicenses.json');
const PACKAGE_JSON = path.join(__dirname, '..', 'package.json');
// Ensure the output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
console.log('🔍 Generating frontend license report...');
try {
// Install license-checker if not present
try {
require.resolve('license-checker');
} catch (e) {
console.log('📦 Installing license-checker...');
execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
}
// Generate license report using license-checker (more reliable)
const licenseReport = execSync('npx license-checker --production --json', {
encoding: 'utf8',
cwd: path.dirname(PACKAGE_JSON)
});
const licenseData = JSON.parse(licenseReport);
// Convert license-checker format to array
const licenseArray = Object.entries(licenseData).map(([key, value]) => {
let name, version;
// Handle scoped packages like @mantine/core@1.0.0
if (key.startsWith('@')) {
const parts = key.split('@');
name = `@${parts[1]}`;
version = parts[2];
} else {
// Handle regular packages like react@18.0.0
const lastAtIndex = key.lastIndexOf('@');
name = key.substring(0, lastAtIndex);
version = key.substring(lastAtIndex + 1);
}
return {
name: name,
version: version || value.version,
licenseType: value.licenses,
repository: value.repository,
url: value.url,
link: value.licenseUrl
};
});
// Transform to match Java backend format
const transformedData = {
dependencies: licenseArray.map(dep => {
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
const licenseUrl = dep.link || getLicenseUrl(licenseType);
return {
moduleName: dep.name,
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
moduleVersion: dep.version,
moduleLicense: licenseType,
moduleLicenseUrl: licenseUrl
};
})
};
// Log summary of license types found
const licenseSummary = licenseArray.reduce((acc, dep) => {
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
acc[license] = (acc[license] || 0) + 1;
return acc;
}, {});
console.log('📊 License types found:');
Object.entries(licenseSummary).forEach(([license, count]) => {
console.log(` ${license}: ${count} packages`);
});
// Check for potentially problematic licenses
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
if (problematicLicenses.length > 0) {
console.log('\n⚠️ License compatibility warnings:');
problematicLicenses.forEach(warning => {
console.log(` ${warning.message}`);
});
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json');
fs.writeFileSync(warningsFile, JSON.stringify({
warnings: problematicLicenses,
generated: new Date().toISOString()
}, null, 2));
console.log(`⚠️ License warnings saved to: ${warningsFile}`);
} else {
console.log('\n✅ All licenses appear to be corporate-friendly');
}
// Write to file
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
console.log(`✅ License report generated successfully!`);
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
} catch (error) {
console.error('❌ Error generating license report:', error.message);
process.exit(1);
}
/**
* Get standard license URLs for common licenses
*/
function getLicenseUrl(licenseType) {
if (!licenseType || licenseType === 'Unknown') return '';
const licenseUrls = {
'MIT': 'https://opensource.org/licenses/MIT',
'Apache-2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
'Apache License 2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
'BSD-3-Clause': 'https://opensource.org/licenses/BSD-3-Clause',
'BSD-2-Clause': 'https://opensource.org/licenses/BSD-2-Clause',
'BSD': 'https://opensource.org/licenses/BSD-3-Clause',
'GPL-3.0': 'https://www.gnu.org/licenses/gpl-3.0.html',
'GPL-2.0': 'https://www.gnu.org/licenses/gpl-2.0.html',
'LGPL-2.1': 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html',
'LGPL-3.0': 'https://www.gnu.org/licenses/lgpl-3.0.html',
'ISC': 'https://opensource.org/licenses/ISC',
'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/',
'Unlicense': 'https://unlicense.org/',
'MPL-2.0': 'https://www.mozilla.org/en-US/MPL/2.0/',
'WTFPL': 'http://www.wtfpl.net/',
'Zlib': 'https://opensource.org/licenses/Zlib',
'Artistic-2.0': 'https://opensource.org/licenses/Artistic-2.0',
'EPL-1.0': 'https://www.eclipse.org/legal/epl-v10.html',
'EPL-2.0': 'https://www.eclipse.org/legal/epl-2.0/',
'CDDL-1.0': 'https://opensource.org/licenses/CDDL-1.0',
'Ruby': 'https://www.ruby-lang.org/en/about/license.txt',
'Python-2.0': 'https://www.python.org/download/releases/2.0/license/',
'Public Domain': 'https://creativecommons.org/publicdomain/zero/1.0/',
'UNLICENSED': ''
};
// Try exact match first
if (licenseUrls[licenseType]) {
return licenseUrls[licenseType];
}
// Try case-insensitive match
const lowerType = licenseType.toLowerCase();
for (const [key, url] of Object.entries(licenseUrls)) {
if (key.toLowerCase() === lowerType) {
return url;
}
}
// For non-standard licenses, return empty string (will use package link if available)
return '';
}
/**
* Check for potentially problematic licenses that may not be MIT/corporate compatible
*/
function checkLicenseCompatibility(licenseSummary, licenseArray) {
const warnings = [];
// Define problematic license patterns
const problematicLicenses = {
// Copyleft licenses
'GPL-2.0': 'Strong copyleft license - requires derivative works to be GPL',
'GPL-3.0': 'Strong copyleft license - requires derivative works to be GPL',
'LGPL-2.1': 'Weak copyleft license - may require source disclosure for modifications',
'LGPL-3.0': 'Weak copyleft license - may require source disclosure for modifications',
'AGPL-3.0': 'Network copyleft license - requires source disclosure for network use',
'AGPL-1.0': 'Network copyleft license - requires source disclosure for network use',
// Other potentially problematic licenses
'WTFPL': 'Potentially problematic license - legal uncertainty',
'CC-BY-SA-4.0': 'ShareAlike license - requires derivative works to use same license',
'CC-BY-SA-3.0': 'ShareAlike license - requires derivative works to use same license',
'CC-BY-NC-4.0': 'Non-commercial license - prohibits commercial use',
'CC-BY-NC-3.0': 'Non-commercial license - prohibits commercial use',
'OSL-3.0': 'Copyleft license - requires derivative works to be OSL',
'EPL-1.0': 'Weak copyleft license - may require source disclosure',
'EPL-2.0': 'Weak copyleft license - may require source disclosure',
'CDDL-1.0': 'Weak copyleft license - may require source disclosure',
'CDDL-1.1': 'Weak copyleft license - may require source disclosure',
'CPL-1.0': 'Weak copyleft license - may require source disclosure',
'MPL-1.1': 'Weak copyleft license - may require source disclosure',
'EUPL-1.1': 'Copyleft license - requires derivative works to be EUPL',
'EUPL-1.2': 'Copyleft license - requires derivative works to be EUPL',
'UNLICENSED': 'No license specified - usage rights unclear',
'Unknown': 'License not detected - manual review required'
};
// Known good licenses (no warnings needed)
const goodLicenses = new Set([
'MIT', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE'
]);
// Check each license type
Object.entries(licenseSummary).forEach(([license, count]) => {
// Skip known good licenses
if (goodLicenses.has(license)) {
return;
}
// Check if this license only affects our own packages
const affectedPackages = licenseArray.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
});
const isOnlyOurPackages = affectedPackages.every(dep =>
dep.name === 'frontend' ||
dep.name.toLowerCase().includes('stirling-pdf') ||
dep.name.toLowerCase().includes('stirling_pdf') ||
dep.name.toLowerCase().includes('stirlingpdf')
);
if (isOnlyOurPackages && (license === 'UNLICENSED' || license.startsWith('SEE LICENSE IN'))) {
return; // Skip warnings for our own Stirling-PDF packages
}
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (license.includes('AND') || license.includes('OR')) {
// Parse compound license
const hasProblematicComponent = Object.keys(problematicLicenses).some(problematic =>
license.includes(problematic)
);
if (hasProblematicComponent) {
const affectedPackages = licenseArray
.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
})
.map(dep => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
}));
warnings.push({
message: `📋 This PR contains ${count} package${count > 1 ? 's' : ''} with compound license "${license}" - manual review recommended`,
licenseType: license,
licenseUrl: '',
reason: 'Compound license with potentially problematic components',
packageCount: count,
affectedDependencies: affectedPackages
});
}
return;
}
// Check for exact matches with problematic licenses
if (problematicLicenses[license]) {
const affectedPackages = licenseArray
.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
})
.map(dep => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
}));
const packageList = affectedPackages.map(pkg => pkg.name).slice(0, 5).join(', ') + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : '');
const licenseUrl = getLicenseUrl(license) || 'https://opensource.org/licenses';
warnings.push({
message: `⚠️ This PR contains ${count} package${count > 1 ? 's' : ''} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
licenseType: license,
licenseUrl: licenseUrl,
reason: problematicLicenses[license],
packageCount: count,
affectedDependencies: affectedPackages
});
} else {
// Unknown license type - flag for manual review
const affectedPackages = licenseArray
.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
})
.map(dep => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
}));
warnings.push({
message: `❓ This PR contains ${count} package${count > 1 ? 's' : ''} with unknown license type "${license}" - manual review required`,
licenseType: license,
licenseUrl: '',
reason: 'Unknown license type',
packageCount: count,
affectedDependencies: affectedPackages
});
}
});
return warnings;
}
-38
View File
@@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
-8
View File
@@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
-15
View File
@@ -1,15 +0,0 @@
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 (
<RainbowThemeProvider>
<HomePage />
</RainbowThemeProvider>
);
}
-334
View File
@@ -1,334 +0,0 @@
import { Command, CommandSequence } from '../hooks/useUndoRedo';
import { PDFDocument, PDFPage } from '../types/pageEditor';
// Base class for page operations
abstract class PageCommand implements Command {
protected pdfDocument: PDFDocument;
protected setPdfDocument: (doc: PDFDocument) => void;
protected previousState: PDFDocument;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void
) {
this.pdfDocument = pdfDocument;
this.setPdfDocument = setPdfDocument;
this.previousState = JSON.parse(JSON.stringify(pdfDocument)); // Deep clone
}
abstract execute(): void;
abstract description: string;
undo(): void {
this.setPdfDocument(this.previousState);
}
}
// Rotate pages command
export class RotatePagesCommand extends PageCommand {
private pageIds: string[];
private rotation: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[],
rotation: number
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.rotation = rotation;
}
execute(): void {
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
return { ...page, rotation: page.rotation + this.rotation };
}
return page;
});
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
}
get description(): string {
const direction = this.rotation > 0 ? 'right' : 'left';
return `Rotate ${this.pageIds.length} page(s) ${direction}`;
}
}
// Delete pages command
export class DeletePagesCommand extends PageCommand {
private pageIds: string[];
private deletedPages: PDFPage[];
private deletedPositions: Map<string, number>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[]
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.deletedPages = [];
this.deletedPositions = new Map();
}
execute(): void {
// Store deleted pages and their positions for undo
this.deletedPages = this.pdfDocument.pages.filter(page =>
this.pageIds.includes(page.id)
);
this.deletedPages.forEach(page => {
const index = this.pdfDocument.pages.findIndex(p => p.id === page.id);
this.deletedPositions.set(page.id, index);
});
const updatedPages = this.pdfDocument.pages
.filter(page => !this.pageIds.includes(page.id))
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
let restoredPages = [...this.pdfDocument.pages];
// Insert deleted pages back at their original positions
this.deletedPages
.sort((a, b) => (this.deletedPositions.get(a.id) || 0) - (this.deletedPositions.get(b.id) || 0))
.forEach(page => {
const originalIndex = this.deletedPositions.get(page.id) || 0;
restoredPages.splice(originalIndex, 0, page);
});
// Update page numbers
restoredPages = restoredPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: restoredPages,
totalPages: restoredPages.length
});
}
get description(): string {
return `Delete ${this.pageIds.length} page(s)`;
}
}
// Move pages command
export class MovePagesCommand extends PageCommand {
private pageIds: string[];
private targetIndex: number;
private originalIndices: Map<string, number>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[],
targetIndex: number
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.targetIndex = targetIndex;
this.originalIndices = new Map();
}
execute(): void {
// Store original positions
this.pageIds.forEach(pageId => {
const index = this.pdfDocument.pages.findIndex(p => p.id === pageId);
this.originalIndices.set(pageId, index);
});
let newPages = [...this.pdfDocument.pages];
const pagesToMove = this.pageIds
.map(id => this.pdfDocument.pages.find(p => p.id === id))
.filter((page): page is PDFPage => page !== undefined);
// Remove pages to move
newPages = newPages.filter(page => !this.pageIds.includes(page.id));
// Insert pages at target position
newPages.splice(this.targetIndex, 0, ...pagesToMove);
// Update page numbers
newPages = newPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({ ...this.pdfDocument, pages: newPages });
}
get description(): string {
return `Move ${this.pageIds.length} page(s)`;
}
}
// Reorder single page command (for drag-and-drop)
export class ReorderPageCommand extends PageCommand {
private pageId: string;
private targetIndex: number;
private originalIndex: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageId: string,
targetIndex: number
) {
super(pdfDocument, setPdfDocument);
this.pageId = pageId;
this.targetIndex = targetIndex;
this.originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId);
}
execute(): void {
const newPages = [...this.pdfDocument.pages];
const [movedPage] = newPages.splice(this.originalIndex, 1);
newPages.splice(this.targetIndex, 0, movedPage);
// Update page numbers
const updatedPages = newPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
}
get description(): string {
return `Reorder page ${this.originalIndex + 1} to position ${this.targetIndex + 1}`;
}
}
// Toggle split markers command
export class ToggleSplitCommand extends PageCommand {
private pageIds: string[];
private previousSplitStates: Map<string, boolean>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[]
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.previousSplitStates = new Map();
}
execute(): void {
// Store previous split states
this.pageIds.forEach(pageId => {
const page = this.pdfDocument.pages.find(p => p.id === pageId);
if (page) {
this.previousSplitStates.set(pageId, !!page.splitBefore);
}
});
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
return { ...page, splitBefore: !page.splitBefore };
}
return page;
});
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
}
undo(): void {
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
const previousState = this.previousSplitStates.get(page.id);
return { ...page, splitBefore: previousState };
}
return page;
});
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
}
get description(): string {
return `Toggle split markers for ${this.pageIds.length} page(s)`;
}
}
// Add pages command (for inserting new files)
export class AddPagesCommand extends PageCommand {
private newPages: PDFPage[];
private insertIndex: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
newPages: PDFPage[],
insertIndex: number = -1 // -1 means append to end
) {
super(pdfDocument, setPdfDocument);
this.newPages = newPages;
this.insertIndex = insertIndex === -1 ? pdfDocument.pages.length : insertIndex;
}
execute(): void {
const newPagesArray = [...this.pdfDocument.pages];
newPagesArray.splice(this.insertIndex, 0, ...this.newPages);
// Update page numbers for all pages
const updatedPages = newPagesArray.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
const updatedPages = this.pdfDocument.pages
.filter(page => !this.newPages.some(newPage => newPage.id === page.id))
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
return `Add ${this.newPages.length} page(s)`;
}
}
// Command sequence for bulk operations
export class PageCommandSequence implements CommandSequence {
commands: Command[];
description: string;
constructor(commands: Command[], description?: string) {
this.commands = commands;
this.description = description || `Execute ${commands.length} operations`;
}
execute(): void {
this.commands.forEach(command => command.execute());
}
undo(): void {
// Undo in reverse order
[...this.commands].reverse().forEach(command => command.undo());
}
}
@@ -1,136 +0,0 @@
import React from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import { FileWithUrl } from "../types/file";
import { getFileSize, getFileDate } from "../utils/fileUtils";
import { useIndexedDBThumbnail } from "../hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: FileWithUrl;
onRemove: () => void;
onDoubleClick?: () => void;
}
const FileCard: React.FC<FileCardProps> = ({ file, onRemove, onDoubleClick }) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
return (
<Card
shadow="xs"
radius="md"
withBorder
p="xs"
style={{
width: 225,
minWidth: 180,
maxWidth: 260,
cursor: onDoubleClick ? "pointer" : undefined
}}
onDoubleClick={onDoubleClick}
>
<Stack gap={6} align="center">
<Box
style={{
border: "2px solid #e0e0e0",
borderRadius: 8,
width: 90,
height: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
background: "#fafbfc",
}}
>
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : isGenerating ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: 8
}} />
<Text size="xs" c="dimmed">Generating...</Text>
</div>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
)}
</div>
)}
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
{file.name}
</Text>
<Group gap="xs" justify="center">
<Badge color="gray" variant="light" size="sm">
{getFileSize(file)}
</Badge>
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{file.storedInIndexedDB && (
<Badge
color="green"
variant="light"
size="sm"
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
>
DB
</Badge>
)}
</Group>
<Button
color="red"
size="xs"
variant="light"
onClick={onRemove}
mt={4}
>
{t("delete", "Remove")}
</Button>
</Stack>
</Card>
);
};
export default FileCard;
@@ -1,76 +0,0 @@
import React from "react";
import { Card, Group, Text, Button, Progress } from "@mantine/core";
import { useTranslation } from "react-i18next";
import StorageIcon from "@mui/icons-material/Storage";
import DeleteIcon from "@mui/icons-material/Delete";
import { StorageStats } from "../services/fileStorage";
import { formatFileSize } from "../utils/fileUtils";
import { getStorageUsagePercent } from "../utils/storageUtils";
interface StorageStatsCardProps {
storageStats: StorageStats | null;
filesCount: number;
onClearAll: () => void;
onReloadFiles: () => void;
}
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
storageStats,
filesCount,
onClearAll,
onReloadFiles,
}) => {
const { t } = useTranslation();
if (!storageStats) return null;
const storageUsagePercent = getStorageUsagePercent(storageStats);
return (
<Card withBorder p="sm" mb="md" style={{ width: "90%", maxWidth: 600 }}>
<Group align="center" gap="md">
<StorageIcon />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)}
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
</Text>
{storageStats.quota && (
<Progress
value={storageUsagePercent}
color={storageUsagePercent > 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"}
size="sm"
mt={4}
/>
)}
<Text size="xs" c="dimmed">
{storageStats.fileCount} {t("fileManager.filesStored", "files stored")}
</Text>
</div>
<Group gap="xs">
{filesCount > 0 && (
<Button
variant="light"
color="red"
size="xs"
onClick={onClearAll}
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
>
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
Reload Files
</Button>
</Group>
</Group>
</Card>
);
};
export default StorageStatsCard;
@@ -1,201 +0,0 @@
import React, { useState } from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { FileWithUrl } from "../../types/file";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: FileWithUrl;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
onEdit?: () => void;
isSelected?: boolean;
onSelect?: () => void;
}
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect }: FileCardProps) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
const [isHovered, setIsHovered] = useState(false);
return (
<Card
shadow="xs"
radius="md"
withBorder
p="xs"
style={{
width: 225,
minWidth: 180,
maxWidth: 260,
cursor: onDoubleClick ? "pointer" : undefined,
position: 'relative',
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined
}}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onSelect}
>
<Stack gap={6} align="center">
<Box
style={{
border: "2px solid #e0e0e0",
borderRadius: 8,
width: 90,
height: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
background: "#fafbfc",
position: 'relative'
}}
>
{/* Hover action buttons */}
{isHovered && (onView || onEdit) && (
<div
style={{
position: 'absolute',
top: 4,
right: 4,
display: 'flex',
gap: 4,
zIndex: 10,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 4,
padding: 2
}}
onClick={(e) => e.stopPropagation()}
>
{onView && (
<Tooltip label="View in Viewer">
<ActionIcon
size="sm"
variant="subtle"
color="blue"
onClick={(e) => {
e.stopPropagation();
onView();
}}
>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
)}
{onEdit && (
<Tooltip label="Open in File Editor">
<ActionIcon
size="sm"
variant="subtle"
color="orange"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<EditIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
)}
</div>
)}
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : isGenerating ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: 8
}} />
<Text size="xs" c="dimmed">Generating...</Text>
</div>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
)}
</div>
)}
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
{file.name}
</Text>
<Group gap="xs" justify="center">
<Badge color="red" variant="light" size="sm">
{getFileSize(file)}
</Badge>
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{file.storedInIndexedDB && (
<Badge
color="green"
variant="light"
size="sm"
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
>
DB
</Badge>
)}
</Group>
<Button
color="red"
size="xs"
variant="light"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
mt={4}
>
{t("delete", "Remove")}
</Button>
</Stack>
</Card>
);
};
export default FileCard;
@@ -1,440 +0,0 @@
import React, { useState, useEffect } from "react";
import { Box, Flex, Text, Notification, Button, Group } from "@mantine/core";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import { GlobalWorkerOptions } from "pdfjs-dist";
import { StorageStats } from "../../services/fileStorage";
import { FileWithUrl, defaultStorageConfig, initializeStorageConfig, StorageConfig } from "../../types/file";
// Refactored imports
import { fileOperationsService } from "../../services/fileOperationsService";
import { checkStorageWarnings } from "../../utils/storageUtils";
import StorageStatsCard from "./StorageStatsCard";
import FileCard from "./FileCard";
import FileUploadSelector from "../shared/FileUploadSelector";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
interface FileManagerProps {
files: FileWithUrl[];
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
allowMultiple?: boolean;
setCurrentView?: (view: string) => void;
onOpenFileEditor?: (selectedFiles?: FileWithUrl[]) => void;
onOpenPageEditor?: (selectedFiles?: FileWithUrl[]) => void;
onLoadFileToActive?: (file: File) => void;
}
const FileManager = ({
files = [],
setFiles,
allowMultiple = true,
setCurrentView,
onOpenFileEditor,
onOpenPageEditor,
onLoadFileToActive,
}: FileManagerProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [notification, setNotification] = useState<string | null>(null);
const [filesLoaded, setFilesLoaded] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [storageConfig, setStorageConfig] = useState<StorageConfig>(defaultStorageConfig);
// Extract operations from service for cleaner code
const {
loadStorageStats,
forceReloadFiles,
loadExistingFiles,
uploadFiles,
removeFile,
clearAllFiles,
createBlobUrlForFile,
checkForPurge,
updateStorageStatsIncremental
} = fileOperationsService;
// Add CSS for spinner animation
useEffect(() => {
if (!document.querySelector('#spinner-animation')) {
const style = document.createElement('style');
style.id = 'spinner-animation';
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
}, []);
// Load existing files from IndexedDB on mount
useEffect(() => {
if (!filesLoaded) {
handleLoadExistingFiles();
}
}, [filesLoaded]);
// Initialize storage configuration on mount
useEffect(() => {
const initStorage = async () => {
try {
const config = await initializeStorageConfig();
setStorageConfig(config);
console.log('Initialized storage config:', config);
} catch (error) {
console.warn('Failed to initialize storage config, using defaults:', error);
}
};
initStorage();
}, []);
// Load storage stats and set up periodic updates
useEffect(() => {
handleLoadStorageStats();
const interval = setInterval(async () => {
await handleLoadStorageStats();
await handleCheckForPurge();
}, 10000); // Update every 10 seconds
return () => clearInterval(interval);
}, []);
// Sync UI with IndexedDB whenever storage stats change
useEffect(() => {
const syncWithStorage = async () => {
if (storageStats && filesLoaded) {
// If file counts don't match, force reload
if (storageStats.fileCount !== files.length) {
console.warn('File count mismatch: storage has', storageStats.fileCount, 'but UI shows', files.length, '- forcing reload');
const reloadedFiles = await forceReloadFiles();
setFiles(reloadedFiles);
}
}
};
syncWithStorage();
}, [storageStats, filesLoaded, files.length]);
// Handlers using extracted operations
const handleLoadStorageStats = async () => {
const stats = await loadStorageStats();
if (stats) {
setStorageStats(stats);
// Check for storage warnings
const warning = checkStorageWarnings(stats);
if (warning) {
setNotification(warning);
}
}
};
const handleLoadExistingFiles = async () => {
try {
const loadedFiles = await loadExistingFiles(filesLoaded, files);
setFiles(loadedFiles);
setFilesLoaded(true);
} catch (error) {
console.error('Failed to load existing files:', error);
setFilesLoaded(true);
}
};
const handleCheckForPurge = async () => {
try {
const isPurged = await checkForPurge(files);
if (isPurged) {
console.warn('IndexedDB purge detected - forcing UI reload');
setNotification(t("fileManager.storageCleared", "Browser cleared storage. Files have been removed. Please re-upload."));
const reloadedFiles = await forceReloadFiles();
setFiles(reloadedFiles);
setFilesLoaded(true);
}
} catch (error) {
console.error('Error checking for purge:', error);
}
};
const validateStorageLimits = (filesToUpload: File[]): { valid: boolean; error?: string } => {
// Check individual file sizes
for (const file of filesToUpload) {
if (file.size > storageConfig.maxFileSize) {
const maxSizeMB = Math.round(storageConfig.maxFileSize / (1024 * 1024));
return {
valid: false,
error: `${t("storage.fileTooLarge", "File too large. Maximum size per file is")} ${maxSizeMB}MB`
};
}
}
// Check total storage capacity
if (storageStats) {
const totalNewSize = filesToUpload.reduce((sum, file) => sum + file.size, 0);
const projectedUsage = storageStats.totalSize + totalNewSize;
if (projectedUsage > storageConfig.maxTotalStorage) {
return {
valid: false,
error: t("storage.storageQuotaExceeded", "Storage quota exceeded. Please remove some files before uploading more.")
};
}
}
return { valid: true };
};
const handleDrop = async (uploadedFiles: File[]) => {
setLoading(true);
try {
// Validate storage limits before uploading
const validation = validateStorageLimits(uploadedFiles);
if (!validation.valid) {
setNotification(validation.error);
setLoading(false);
return;
}
const newFiles = await uploadFiles(uploadedFiles, storageConfig.useIndexedDB);
// Update files state
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...newFiles] : newFiles));
// Update storage stats incrementally
if (storageStats) {
const updatedStats = updateStorageStatsIncremental(storageStats, 'add', newFiles);
setStorageStats(updatedStats);
// Check for storage warnings
const warning = checkStorageWarnings(updatedStats);
if (warning) {
setNotification(warning);
}
}
} catch (error) {
console.error('Error handling file drop:', error);
setNotification(t("fileManager.uploadError", "Failed to upload some files."));
} finally {
setLoading(false);
}
};
const handleRemoveFile = async (index: number) => {
const file = files[index];
try {
await removeFile(file);
// Update storage stats incrementally
if (storageStats) {
const updatedStats = updateStorageStatsIncremental(storageStats, 'remove', [file]);
setStorageStats(updatedStats);
}
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
} catch (error) {
console.error('Failed to remove file:', error);
}
};
const handleClearAll = async () => {
try {
await clearAllFiles(files);
// Reset storage stats
if (storageStats) {
const clearedStats = updateStorageStatsIncremental(storageStats, 'clear');
setStorageStats(clearedStats);
}
setFiles([]);
} catch (error) {
console.error('Failed to clear all files:', error);
}
};
const handleReloadFiles = () => {
setFilesLoaded(false);
setFiles([]);
};
const handleFileDoubleClick = async (file: FileWithUrl) => {
try {
// Reconstruct File object from storage and add to active files
if (onLoadFileToActive) {
const reconstructedFile = await reconstructFileFromStorage(file);
onLoadFileToActive(reconstructedFile);
setCurrentView && setCurrentView("viewer");
}
} catch (error) {
console.error('Failed to load file to active set:', error);
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
}
};
const handleFileView = async (file: FileWithUrl) => {
try {
// Reconstruct File object from storage and add to active files
if (onLoadFileToActive) {
const reconstructedFile = await reconstructFileFromStorage(file);
onLoadFileToActive(reconstructedFile);
setCurrentView && setCurrentView("viewer");
}
} catch (error) {
console.error('Failed to load file to active set:', error);
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
}
};
const reconstructFileFromStorage = async (fileWithUrl: FileWithUrl): Promise<File> => {
// If it's already a regular file, return it
if (fileWithUrl instanceof File) {
return fileWithUrl;
}
// Reconstruct from IndexedDB
const arrayBuffer = await createBlobUrlForFile(fileWithUrl);
if (typeof arrayBuffer === 'string') {
// createBlobUrlForFile returned a blob URL, we need the actual data
const response = await fetch(arrayBuffer);
const data = await response.arrayBuffer();
return new File([data], fileWithUrl.name, {
type: fileWithUrl.type || 'application/pdf',
lastModified: fileWithUrl.lastModified || Date.now()
});
} else {
return new File([arrayBuffer], fileWithUrl.name, {
type: fileWithUrl.type || 'application/pdf',
lastModified: fileWithUrl.lastModified || Date.now()
});
}
};
const handleFileEdit = (file: FileWithUrl) => {
if (onOpenFileEditor) {
onOpenFileEditor([file]);
}
};
const toggleFileSelection = (fileId: string) => {
setSelectedFiles(prev =>
prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId]
);
};
const handleOpenSelectedInEditor = () => {
if (onOpenFileEditor && selectedFiles.length > 0) {
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
onOpenFileEditor(selected);
}
};
const handleOpenSelectedInPageEditor = () => {
if (onOpenPageEditor && selectedFiles.length > 0) {
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
onOpenPageEditor(selected);
}
};
return (
<div style={{
width: "100%",
justifyContent: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: "3rem"
}}>
{/* File upload is now handled by FileUploadSelector when no files exist */}
{/* Storage Stats Card */}
<StorageStatsCard
storageStats={storageStats}
filesCount={files.length}
onClearAll={handleClearAll}
onReloadFiles={handleReloadFiles}
storageConfig={storageConfig}
/>
{/* Multi-selection controls */}
{selectedFiles.length > 0 && (
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between">
<Text size="sm">
{selectedFiles.length} {t("fileManager.filesSelected", "files selected")}
</Text>
<Group>
<Button
size="xs"
variant="light"
onClick={() => setSelectedFiles([])}
>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
<Button
size="xs"
color="orange"
onClick={handleOpenSelectedInEditor}
disabled={selectedFiles.length === 0}
>
{t("fileManager.openInFileEditor", "Open in File Editor")}
</Button>
<Button
size="xs"
color="blue"
onClick={handleOpenSelectedInPageEditor}
disabled={selectedFiles.length === 0}
>
{t("fileManager.openInPageEditor", "Open in Page Editor")}
</Button>
</Group>
</Group>
</Box>
)}
<Flex
wrap="wrap"
gap="lg"
justify="flex-start"
style={{ width: "90%", marginTop: "1rem"}}
>
{files.map((file, idx) => (
<FileCard
key={file.id || file.name + idx}
file={file}
onRemove={() => handleRemoveFile(idx)}
onDoubleClick={() => handleFileDoubleClick(file)}
onView={() => handleFileView(file)}
onEdit={() => handleFileEdit(file)}
isSelected={selectedFiles.includes(file.id || file.name)}
onSelect={() => toggleFileSelection(file.id || file.name)}
/>
))}
</Flex>
{/* Notifications */}
{notification && (
<Notification
color="blue"
onClose={() => setNotification(null)}
style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }}
>
{notification}
</Notification>
)}
</div>
);
};
export default FileManager;
@@ -1,92 +0,0 @@
import React from "react";
import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import StorageIcon from "@mui/icons-material/Storage";
import DeleteIcon from "@mui/icons-material/Delete";
import WarningIcon from "@mui/icons-material/Warning";
import { StorageStats } from "../../services/fileStorage";
import { formatFileSize } from "../../utils/fileUtils";
import { getStorageUsagePercent } from "../../utils/storageUtils";
import { StorageConfig } from "../../types/file";
interface StorageStatsCardProps {
storageStats: StorageStats | null;
filesCount: number;
onClearAll: () => void;
onReloadFiles: () => void;
storageConfig: StorageConfig;
}
const StorageStatsCard = ({
storageStats,
filesCount,
onClearAll,
onReloadFiles,
storageConfig,
}: StorageStatsCardProps) => {
const { t } = useTranslation();
if (!storageStats) return null;
const storageUsagePercent = getStorageUsagePercent(storageStats);
const totalUsed = storageStats.totalSize || storageStats.used;
const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
return (
<Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
<Card withBorder p="sm">
<Group align="center" gap="md">
<StorageIcon />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
</Text>
<Progress
value={hardLimitPercent}
color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
size="sm"
mt={4}
/>
<Group justify="space-between" mt={2}>
<Text size="xs" c="dimmed">
{storageStats.fileCount} files {t("storage.approximateSize", "Approximate size")}
</Text>
<Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
{Math.round(hardLimitPercent)}% used
</Text>
</Group>
{isNearLimit && (
<Text size="xs" c="red" mt={4}>
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
</Text>
)}
</div>
<Group gap="xs">
{filesCount > 0 && (
<Button
variant="light"
color="red"
size="xs"
onClick={onClearAll}
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
>
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
{t("fileManager.reloadFiles", "Reload Files")}
</Button>
</Group>
</Group>
</Card>
</Stack>
);
};
export default StorageStatsCard;
@@ -1,42 +0,0 @@
import React from 'react';
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
interface BulkSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
selectedPages: string[];
onUpdatePagesFromCSV: () => void;
}
const BulkSelectionPanel = ({
csvInput,
setCsvInput,
selectedPages,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
return (
<Paper p="md" mb="md" withBorder>
<Group>
<TextInput
value={csvInput}
onChange={(e) => setCsvInput(e.target.value)}
placeholder="1,3,5-10"
label="Page Selection"
onBlur={onUpdatePagesFromCSV}
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
style={{ flex: 1 }}
/>
<Button onClick={onUpdatePagesFromCSV} mt="xl">
Apply
</Button>
</Group>
{selectedPages.length > 0 && (
<Text size="sm" c="dimmed" mt="sm">
Selected: {selectedPages.length} pages
</Text>
)}
</Paper>
);
};
export default BulkSelectionPanel;
@@ -1,131 +0,0 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Box } from '@mantine/core';
import styles from './PageEditor.module.css';
interface DragDropItem {
id: string;
splitBefore?: boolean;
}
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
selectedItems: string[];
selectionMode: boolean;
isAnimating: boolean;
onDragStart: (itemId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (itemId: string) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, targetId: string | 'end') => void;
onEndZoneDragEnter: () => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
draggedItem: string | null;
dropTarget: string | null;
multiItemDrag: {itemIds: string[], count: number} | null;
dragPosition: {x: number, y: number} | null;
}
const DragDropGrid = <T extends DragDropItem>({
items,
selectedItems,
selectionMode,
isAnimating,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onEndZoneDragEnter,
renderItem,
renderSplitMarker,
draggedItem,
dropTarget,
multiItemDrag,
dragPosition,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Global drag cleanup
useEffect(() => {
const handleGlobalDragEnd = () => {
onDragEnd();
};
const handleGlobalDrop = (e: DragEvent) => {
e.preventDefault();
};
if (draggedItem) {
document.addEventListener('dragend', handleGlobalDragEnd);
document.addEventListener('drop', handleGlobalDrop);
}
return () => {
document.removeEventListener('dragend', handleGlobalDragEnd);
document.removeEventListener('drop', handleGlobalDrop);
};
}, [draggedItem, onDragEnd]);
return (
<Box>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '1.5rem',
justifyContent: 'flex-start',
paddingBottom: '100px'
}}
>
{items.map((item, index) => (
<React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
{/* Item */}
{renderItem(item, index, itemRefs)}
</React.Fragment>
))}
{/* End drop zone */}
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
<div
data-drop-zone="end"
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
dropTarget === 'end'
? 'ring-2 ring-green-500 bg-green-50'
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
}`}
style={{ borderRadius: '12px' }}
onDragOver={onDragOver}
onDragEnter={onEndZoneDragEnter}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, 'end')}
>
<div className="text-gray-500 text-sm text-center font-medium">
Drop here to<br />move to end
</div>
</div>
</div>
</div>
{/* Multi-item drag indicator */}
{multiItemDrag && dragPosition && (
<div
className={styles.multiDragIndicator}
style={{
left: dragPosition.x,
top: dragPosition.y,
}}
>
{multiItemDrag.count} items
</div>
)}
</Box>
);
};
export default DragDropGrid;
@@ -1,561 +0,0 @@
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 './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;
activeFiles?: File[];
setActiveFiles?: (files: File[]) => void;
preSelectedFiles?: { file: File; url: string }[];
onClearPreSelection?: () => void;
}
const FileEditor = ({
onOpenPageEditor,
onMergeFiles,
activeFiles = [],
setActiveFiles,
preSelectedFiles = [],
onClearPreSelection
}: FileEditorProps) => {
const { t } = useTranslation();
const [files, setFiles] = useState<FileItem[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [csvInput, setCsvInput] = useState<string>('');
const [selectionMode, setSelectionMode] = useState(false);
const [draggedFile, setDraggedFile] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Convert shared files to FileEditor format
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
// Generate thumbnail if not already available
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
return {
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
name: (sharedFile.file?.name || sharedFile.name || 'unknown').replace(/\.pdf$/i, ''),
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now
thumbnail,
size: sharedFile.file?.size || sharedFile.size || 0,
file: sharedFile.file || sharedFile,
};
}, []);
// Convert activeFiles to FileItem format
useEffect(() => {
const convertActiveFiles = async () => {
if (activeFiles.length > 0) {
setLoading(true);
try {
const convertedFiles = await Promise.all(
activeFiles.map(async (file) => {
const thumbnail = await generateThumbnailForFile(file);
return {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name.replace(/\.pdf$/i, ''),
pageCount: Math.floor(Math.random() * 20) + 1, // Mock for now
thumbnail,
size: file.size,
file,
};
})
);
setFiles(convertedFiles);
} catch (err) {
console.error('Error converting active files:', err);
} finally {
setLoading(false);
}
} else {
setFiles([]);
}
};
convertActiveFiles();
}, [activeFiles]);
// 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)
);
if (setActiveFiles) {
const updatedActiveFiles = convertedFiles.map(fileItem => fileItem.file);
setActiveFiles(updatedActiveFiles);
}
} 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 (setActiveFiles) {
setActiveFiles(prev => [...prev, ...newFiles.map(f => f.file)]);
}
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);
}
}, [setActiveFiles]);
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 (setActiveFiles) {
// Update the local files state and sync with activeFiles
setFiles(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);
// Update activeFiles with the reordered File objects
setActiveFiles(newFiles.map(f => f.file));
return newFiles;
});
}
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
handleDragEnd();
}, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setActiveFiles]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) {
setDropTarget('end');
}
}, [draggedFile]);
// File operations
const handleDeleteFile = useCallback((fileId: string) => {
if (setActiveFiles) {
// Remove from local files and sync with activeFiles
setFiles(prev => {
const newFiles = prev.filter(f => f.id !== fileId);
setActiveFiles(newFiles.map(f => f.file));
return newFiles;
});
}
setSelectedFiles(prev => prev.filter(id => id !== fileId));
}, [setActiveFiles]);
const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
}
}, [files, onOpenPageEditor]);
const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId);
if (startIndex === -1) return;
const filesToMerge = files.slice(startIndex).map(f => f.file);
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [files, onMergeFiles]);
const handleSplitFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
}
}, [files, onOpenPageEditor]);
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
if (selectedFiles.length === 0) return;
setLoading(true);
try {
const convertedFiles = await Promise.all(
selectedFiles.map(convertToFileItem)
);
setFiles(prev => [...prev, ...convertedFiles]);
setStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error('Error loading files from storage:', err);
setError('Failed to load some files from storage');
} finally {
setLoading(false);
}
}, [convertToFileItem]);
return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading} />
<Box p="md" pt="xl">
<Group mb="md">
<Button
onClick={toggleSelectionMode}
variant={selectionMode ? "filled" : "outline"}
color={selectionMode ? "blue" : "gray"}
styles={{
root: {
transition: 'all 0.2s ease',
...(selectionMode && {
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
})
}
}}
>
{selectionMode ? "Exit Selection" : "Select Files"}
</Button>
{selectionMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
{/* Load from storage and upload buttons */}
<Button
variant="outline"
color="blue"
onClick={() => setShowFilePickerModal(true)}
>
Load from Storage
</Button>
<Dropzone
onDrop={handleFileUpload}
accept={["application/pdf"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{ display: 'contents' }}
>
<Button variant="outline" color="green">
Upload Files
</Button>
</Dropzone>
</Group>
{selectionMode && (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={selectedFiles}
onUpdatePagesFromCSV={updateFilesFromCSV}
/>
)}
<DragDropGrid
items={files}
selectedItems={selectedFiles}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedFile}
dropTarget={dropTarget}
multiItemDrag={multiFileDrag}
dragPosition={dragPosition}
renderItem={(file, index, refs) => (
<FileThumbnail
file={file}
index={index}
totalFiles={files.length}
selectedFiles={selectedFiles}
selectionMode={selectionMode}
draggedFile={draggedFile}
dropTarget={dropTarget}
isAnimating={isAnimating}
fileRefs={refs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onMergeFromHere={handleMergeFromHere}
onSplitFile={handleSplitFile}
onSetStatus={setStatus}
/>
)}
renderSplitMarker={(file, index) => (
<div
style={{
width: '2px',
height: '24rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
/>
</Box>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
allowMultiple={true}
/>
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
)}
{error && (
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
>
{error}
</Notification>
)}
</Box>
);
};
export default FileEditor;
@@ -1,327 +0,0 @@
import React from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import VisibilityIcon from '@mui/icons-material/Visibility';
import MergeIcon from '@mui/icons-material/Merge';
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import styles from './PageEditor.module.css';
interface FileItem {
id: string;
name: string;
pageCount: number;
thumbnail: string;
size: number;
splitBefore?: boolean;
}
interface FileThumbnailProps {
file: FileItem;
index: number;
totalFiles: number;
selectedFiles: string[];
selectionMode: boolean;
draggedFile: string | null;
dropTarget: string | null;
isAnimating: boolean;
fileRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (fileId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (fileId: string) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, fileId: string) => void;
onToggleFile: (fileId: string) => void;
onDeleteFile: (fileId: string) => void;
onViewFile: (fileId: string) => void;
onMergeFromHere: (fileId: string) => void;
onSplitFile: (fileId: string) => void;
onSetStatus: (status: string) => void;
}
const FileThumbnail = ({
file,
index,
totalFiles,
selectedFiles,
selectionMode,
draggedFile,
dropTarget,
isAnimating,
fileRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onToggleFile,
onDeleteFile,
onViewFile,
onMergeFromHere,
onSplitFile,
onSetStatus,
}: FileThumbnailProps) => {
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
return (
<div
ref={(el) => {
if (el) {
fileRefs.current.set(file.id, el);
} else {
fileRefs.current.delete(file.id);
}
}}
data-file-id={file.id}
className={`
${styles.pageContainer}
!rounded-lg
cursor-grab
select-none
w-[20rem]
h-[24rem]
flex flex-col items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedFile === file.id ? 'opacity-50 scale-95' : ''}
`}
style={{
transform: (() => {
if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
draggable
onDragStart={() => onDragStart(file.id)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(file.id)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, file.id)}
>
{selectionMode && (
<div
className={styles.checkboxContainer}
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 4,
backgroundColor: 'white',
borderRadius: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto'
}}
onMouseDown={(e) => e.stopPropagation()}
onDragStart={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Checkbox
checked={selectedFiles.includes(file.id)}
onChange={(event) => {
event.stopPropagation();
onToggleFile(file.id);
}}
onClick={(e) => e.stopPropagation()}
size="sm"
/>
</div>
)}
{/* File content area */}
<div className="file-container w-[90%] h-[80%] relative">
{/* Stacked file effect - multiple shadows to simulate pages */}
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
}}
>
<img
src={file.thumbnail}
alt={file.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 2,
}}
/>
</div>
{/* Page count badge */}
<Badge
size="sm"
variant="filled"
color="blue"
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 3,
}}
>
{file.pageCount} pages
</Badge>
{/* File name overlay */}
<Text
className={styles.pageNumber}
size="xs"
fw={500}
c="white"
style={{
position: 'absolute',
bottom: 5,
left: 5,
right: 5,
background: 'rgba(0, 0, 0, 0.8)',
padding: '4px 6px',
borderRadius: 4,
zIndex: 2,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap'
}}
>
{file.name}
</Text>
{/* Hover controls */}
<div
className={styles.pageHoverControls}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0, 0, 0, 0.8)',
padding: '8px 12px',
borderRadius: 20,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
zIndex: 3,
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap'
}}
>
<Tooltip label="View File">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onViewFile(file.id);
onSetStatus(`Opened ${file.name}`);
}}
>
<VisibilityIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Merge from here">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onMergeFromHere(file.id);
onSetStatus(`Starting merge from ${file.name}`);
}}
>
<MergeIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Split File">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onSplitFile(file.id);
onSetStatus(`Opening ${file.name} in page editor`);
}}
>
<SplitscreenIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete File">
<ActionIcon
size="md"
variant="subtle"
c="red"
onClick={(e) => {
e.stopPropagation();
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</div>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.3)',
fontSize: 16,
zIndex: 1
}}
/>
</div>
{/* File info */}
<div className="w-full px-4 py-2 text-center">
<Text size="sm" fw={500} truncate>
{file.name}
</Text>
<Text size="xs" c="dimmed">
{formatFileSize(file.size)}
</Text>
</div>
</div>
);
};
export default FileThumbnail;
@@ -1,63 +0,0 @@
/* 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;
}
@@ -1,966 +0,0 @@
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 './DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal';
import FileUploadSelector from '../shared/FileUploadSelector';
export interface PageEditorProps {
activeFiles: File[];
setActiveFiles: (files: File[]) => void;
downloadUrl?: string | null;
setDownloadUrl?: (url: string | null) => void;
sharedFiles?: any[]; // For FileUploadSelector when no files loaded
// Optional callbacks to expose internal functions for PageEditorControls
onFunctionsReady?: (functions: {
handleUndo: () => void;
handleRedo: () => void;
canUndo: boolean;
canRedo: boolean;
handleRotate: (direction: 'left' | 'right') => void;
handleDelete: () => void;
handleSplit: () => void;
showExportPreview: (selectedOnly: boolean) => void;
onExportSelected: () => void;
onExportAll: () => void;
exportLoading: boolean;
selectionMode: boolean;
selectedPages: string[];
closePdf: () => void;
}) => void;
}
const PageEditor = ({
activeFiles,
setActiveFiles,
downloadUrl,
setDownloadUrl,
sharedFiles = [],
onFunctionsReady,
}: PageEditorProps) => {
const { t } = useTranslation();
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
// Single merged document state
const [mergedPdfDocument, setMergedPdfDocument] = useState<PDFDocument | null>(null);
const [processedFiles, setProcessedFiles] = useState<Map<string, PDFDocument>>(new Map());
const [filename, setFilename] = useState<string>("");
// Page editor state
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [csvInput, setCsvInput] = useState<string>("");
const [selectionMode, setSelectionMode] = useState(false);
// Drag and drop state
const [draggedPage, setDraggedPage] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [multiPageDrag, setMultiPageDrag] = useState<{pageIds: string[], count: number} | null>(null);
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
// Export state
const [exportLoading, setExportLoading] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
// Animation state
const [movingPage, setMovingPage] = useState<string | null>(null);
const [pagePositions, setPagePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
const [isAnimating, setIsAnimating] = useState(false);
const pageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const fileInputRef = useRef<() => void>(null);
// Undo/Redo system
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
// Process uploaded file
const handleFileUpload = useCallback(async (uploadedFile: File | any) => {
if (!uploadedFile) {
setError('No file provided');
return;
}
let fileToProcess: File;
// Handle FileWithUrl objects from storage
if (uploadedFile.storedInIndexedDB && uploadedFile.arrayBuffer) {
try {
console.log('Converting FileWithUrl to File:', uploadedFile.name);
const arrayBuffer = await uploadedFile.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: uploadedFile.type || 'application/pdf' });
fileToProcess = new File([blob], uploadedFile.name, {
type: uploadedFile.type || 'application/pdf',
lastModified: uploadedFile.lastModified || Date.now()
});
} catch (error) {
console.error('Error converting FileWithUrl:', error);
setError('Unable to load file from storage');
return;
}
} else if (uploadedFile instanceof File) {
fileToProcess = uploadedFile;
} else {
setError('Invalid file object');
console.error('handleFileUpload received unsupported object:', uploadedFile);
return;
}
if (fileToProcess.type !== 'application/pdf') {
setError('Please upload a valid PDF file');
return;
}
const fileKey = `${fileToProcess.name}-${fileToProcess.size}`;
// Skip processing if already processed
if (processedFiles.has(fileKey)) return;
setLoading(true);
setError(null);
try {
const document = await processPDFFile(fileToProcess);
// Store processed document
setProcessedFiles(prev => new Map(prev).set(fileKey, 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);
}
}
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, activeFiles, setActiveFiles, processedFiles]);
// Process multiple uploaded files - just add them to activeFiles like FileManager does
const handleMultipleFileUpload = useCallback((uploadedFiles: File[]) => {
if (!uploadedFiles || uploadedFiles.length === 0) {
setError('No files provided');
return;
}
// Simply set the activeFiles to the selected files (same as FileManager approach)
setActiveFiles(uploadedFiles);
}, []);
// Merge multiple PDF documents into one
const mergeAllPDFs = useCallback(() => {
if (activeFiles.length === 0) {
setMergedPdfDocument(null);
return;
}
if (activeFiles.length === 1) {
// Single file - use it directly
const fileKey = `${activeFiles[0].name}-${activeFiles[0].size}`;
const pdfDoc = processedFiles.get(fileKey);
if (pdfDoc) {
setMergedPdfDocument(pdfDoc);
setFilename(activeFiles[0].name.replace(/\.pdf$/i, ''));
}
} else {
// Multiple files - merge them
const allPages: PDFPage[] = [];
let totalPages = 0;
const filenames: string[] = [];
activeFiles.forEach((file, fileIndex) => {
const fileKey = `${file.name}-${file.size}`;
const pdfDoc = processedFiles.get(fileKey);
if (pdfDoc) {
filenames.push(file.name.replace(/\.pdf$/i, ''));
pdfDoc.pages.forEach((page, pageIndex) => {
// Create new page with updated IDs and page numbers for merged document
const newPage: PDFPage = {
...page,
id: `${fileIndex}-${page.id}`, // Unique ID across all files
pageNumber: totalPages + pageIndex + 1,
sourceFile: file.name // Track which file this page came from
};
allPages.push(newPage);
});
totalPages += pdfDoc.pages.length;
}
});
const mergedDocument: PDFDocument = {
pages: allPages,
totalPages: totalPages,
title: filenames.join(' + '),
metadata: {
title: filenames.join(' + '),
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
}
};
setMergedPdfDocument(mergedDocument);
setFilename(filenames.join('_'));
}
}, [activeFiles, processedFiles]);
// Auto-process files from activeFiles
useEffect(() => {
console.log('Auto-processing effect triggered:', {
activeFilesCount: activeFiles.length,
processedFilesCount: processedFiles.size,
activeFileNames: activeFiles.map(f => f.name)
});
activeFiles.forEach(file => {
const fileKey = `${file.name}-${file.size}`;
console.log(`Checking file ${file.name}: processed =`, processedFiles.has(fileKey));
if (!processedFiles.has(fileKey)) {
console.log('Processing file:', file.name);
handleFileUpload(file);
}
});
}, [activeFiles, processedFiles, handleFileUpload]);
// Merge multiple PDF documents into one when all files are processed
useEffect(() => {
if (activeFiles.length > 0) {
const allProcessed = activeFiles.every(file => {
const fileKey = `${file.name}-${file.size}`;
return processedFiles.has(fileKey);
});
if (allProcessed && activeFiles.length > 0) {
mergeAllPDFs();
}
}
}, [activeFiles, processedFiles, mergeAllPDFs]);
// Clear selections when files change
useEffect(() => {
setSelectedPages([]);
setCsvInput("");
setSelectionMode(false);
}, [activeFiles]);
// 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 (mergedPdfDocument) {
setSelectedPages(mergedPdfDocument.pages.map(p => p.id));
}
}, [mergedPdfDocument]);
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 (!mergedPdfDocument) 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 <= mergedPdfDocument.totalPages; i++) {
if (i > 0) {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === i);
if (page) pageIds.push(page.id);
}
}
} else {
const pageNum = parseInt(range);
if (pageNum > 0 && pageNum <= mergedPdfDocument.totalPages) {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
if (page) pageIds.push(page.id);
}
}
});
return pageIds;
}, [mergedPdfDocument]);
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
}, []);
// Create setPdfDocument wrapper for merged document
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
setMergedPdfDocument(updatedDoc);
// Return the updated document for immediate use in animations
return updatedDoc;
}, []);
const animateReorder = useCallback((pageId: string, targetIndex: number) => {
if (!mergedPdfDocument || 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 = mergedPdfDocument.pages.findIndex(p => p.id === pageId);
if (originalIndex === -1 || originalIndex === targetIndex) return;
setIsAnimating(true);
// Get current positions of all pages by querying DOM directly
const currentPositions = new Map<string, { x: number; y: number }>();
const allCurrentElements = Array.from(document.querySelectorAll('[data-page-id]'));
// Capture positions from actual DOM elements
allCurrentElements.forEach((element) => {
const pageId = element.getAttribute('data-page-id');
if (pageId) {
const rect = element.getBoundingClientRect();
currentPositions.set(pageId, { 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(mergedPdfDocument, setPdfDocument, pagesToMove, targetIndex);
executeCommand(command);
} else {
// Single page move
const command = new ReorderPageCommand(mergedPdfDocument, setPdfDocument, pageId, targetIndex);
executeCommand(command);
}
// Wait for state update and DOM to update, then get new positions and animate
setTimeout(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const newPositions = new Map<string, { x: number; y: number }>();
// Re-get all page elements after state update
const allPageElements = Array.from(document.querySelectorAll('[data-page-id]'));
allPageElements.forEach((element) => {
const pageId = element.getAttribute('data-page-id');
if (pageId) {
const rect = element.getBoundingClientRect();
newPositions.set(pageId, { x: rect.left, y: rect.top });
}
});
let animationCount = 0;
// Calculate and apply animations using DOM elements directly
allPageElements.forEach((element) => {
const pageId = element.getAttribute('data-page-id');
if (!pageId) return;
const currentPos = currentPositions.get(pageId);
const newPos = newPositions.get(pageId);
if (element && currentPos && newPos) {
const deltaX = currentPos.x - newPos.x;
const deltaY = currentPos.y - newPos.y;
if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {
animationCount++;
const htmlElement = element as HTMLElement;
// Apply initial transform (from new position back to old position)
htmlElement.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
htmlElement.style.transition = 'none';
// Force reflow
htmlElement.offsetHeight;
// Animate to final position
htmlElement.style.transition = 'transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
htmlElement.style.transform = 'translate(0px, 0px)';
}
}
});
// Clean up after animation
setTimeout(() => {
const elementsToCleanup = Array.from(document.querySelectorAll('[data-page-id]'));
elementsToCleanup.forEach((element) => {
const htmlElement = element as HTMLElement;
htmlElement.style.transform = '';
htmlElement.style.transition = '';
});
setIsAnimating(false);
}, 400);
});
});
}, 10); // Small delay to allow state update
}, [mergedPdfDocument, isAnimating, executeCommand, selectionMode, selectedPages, setPdfDocument]);
const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => {
e.preventDefault();
if (!draggedPage || !mergedPdfDocument || draggedPage === targetPageId) return;
let targetIndex: number;
if (targetPageId === 'end') {
targetIndex = mergedPdfDocument.pages.length;
} else {
targetIndex = mergedPdfDocument.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, mergedPdfDocument, animateReorder, multiPageDrag]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedPage) {
setDropTarget('end');
}
}, [draggedPage]);
const handleRotate = useCallback((direction: 'left' | 'right') => {
if (!mergedPdfDocument) return;
const rotation = direction === 'left' ? -90 : 90;
const pagesToRotate = selectionMode
? selectedPages
: mergedPdfDocument.pages.map(p => p.id);
if (selectionMode && selectedPages.length === 0) return;
const command = new RotatePagesCommand(
mergedPdfDocument,
setPdfDocument,
pagesToRotate,
rotation
);
executeCommand(command);
const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length;
setStatus(`Rotated ${pageCount} pages ${direction}`);
}, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]);
const handleDelete = useCallback(() => {
if (!mergedPdfDocument) return;
const pagesToDelete = selectionMode
? selectedPages
: mergedPdfDocument.pages.map(p => p.id);
if (selectionMode && selectedPages.length === 0) return;
const command = new DeletePagesCommand(
mergedPdfDocument,
setPdfDocument,
pagesToDelete
);
executeCommand(command);
if (selectionMode) {
setSelectedPages([]);
}
const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length;
setStatus(`Deleted ${pageCount} pages`);
}, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]);
const handleSplit = useCallback(() => {
if (!mergedPdfDocument) return;
const pagesToSplit = selectionMode
? selectedPages
: mergedPdfDocument.pages.map(p => p.id);
if (selectionMode && selectedPages.length === 0) return;
const command = new ToggleSplitCommand(
mergedPdfDocument,
setPdfDocument,
pagesToSplit
);
executeCommand(command);
const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length;
setStatus(`Split markers toggled for ${pageCount} pages`);
}, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]);
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return;
const exportPageIds = selectedOnly ? selectedPages : [];
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
setExportPreview(preview);
setShowExportModal(true);
}, [mergedPdfDocument, selectedPages]);
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return;
setExportLoading(true);
try {
const exportPageIds = selectedOnly ? selectedPages : [];
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
if (errors.length > 0) {
setError(errors.join(', '));
return;
}
const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore);
if (hasSplitMarkers) {
const result = await pdfExportService.exportPDF(mergedPdfDocument, 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(mergedPdfDocument, 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);
}
}, [mergedPdfDocument, selectedPages, filename]);
const handleUndo = useCallback(() => {
if (undo()) {
setStatus('Operation undone');
}
}, [undo]);
const handleRedo = useCallback(() => {
if (redo()) {
setStatus('Operation redone');
}
}, [redo]);
const closePdf = useCallback(() => {
setActiveFiles([]);
setProcessedFiles(new Map());
setMergedPdfDocument(null);
setSelectedPages([]);
}, [setActiveFiles]);
// PageEditorControls needs onExportSelected and onExportAll
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]);
// Expose functions to parent component for PageEditorControls
useEffect(() => {
if (onFunctionsReady) {
onFunctionsReady({
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
showExportPreview,
onExportSelected,
onExportAll,
exportLoading,
selectionMode,
selectedPages,
closePdf,
});
}
}, [
onFunctionsReady,
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
showExportPreview,
onExportSelected,
onExportAll,
exportLoading,
selectionMode,
selectedPages,
closePdf
]);
if (!mergedPdfDocument) {
return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading || pdfLoading} />
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FileUploadSelector
title="Select PDFs to edit"
subtitle="Choose files from storage or upload PDFs - multiple files will be merged"
sharedFiles={sharedFiles}
onFilesSelect={handleMultipleFileUpload}
accept={["application/pdf"]}
loading={loading || pdfLoading}
/>
</Container>
</Box>
);
}
return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading || pdfLoading} />
<Box p="md" pt="xl">
<Group mb="md">
<TextInput
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="Enter filename"
style={{ minWidth: 200 }}
/>
<Button
onClick={toggleSelectionMode}
variant={selectionMode ? "filled" : "outline"}
color={selectionMode ? "blue" : "gray"}
styles={{
root: {
transition: 'all 0.2s ease',
...(selectionMode && {
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
})
}
}}
>
{selectionMode ? "Exit Selection" : "Select Pages"}
</Button>
{selectionMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
</Group>
{selectionMode && (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={selectedPages}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
)}
<DragDropGrid
items={mergedPdfDocument.pages}
selectedItems={selectedPages}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedPage}
dropTarget={dropTarget}
multiItemDrag={multiPageDrag}
dragPosition={dragPosition}
renderItem={(page, index, refs) => (
<PageThumbnail
page={page}
index={index}
totalPages={mergedPdfDocument.pages.length}
selectedPages={selectedPages}
selectionMode={selectionMode}
draggedPage={draggedPage}
dropTarget={dropTarget}
movingPage={movingPage}
isAnimating={isAnimating}
pageRefs={refs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={setStatus}
onSetMovingPage={setMovingPage}
RotatePagesCommand={RotatePagesCommand}
DeletePagesCommand={DeletePagesCommand}
ToggleSplitCommand={ToggleSplitCommand}
pdfDocument={mergedPdfDocument}
setPdfDocument={setPdfDocument}
/>
)}
renderSplitMarker={(page, index) => (
<div
style={{
width: '2px',
height: '20rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
/>
</Box>
<Modal
opened={showExportModal}
onClose={() => setShowExportModal(false)}
title="Export Preview"
>
{exportPreview && (
<Stack gap="md">
<Group justify="space-between">
<Text>Pages to export:</Text>
<Text fw={500}>{exportPreview.pageCount}</Text>
</Group>
{exportPreview.splitCount > 1 && (
<Group justify="space-between">
<Text>Split into documents:</Text>
<Text fw={500}>{exportPreview.splitCount}</Text>
</Group>
)}
<Group justify="space-between">
<Text>Estimated size:</Text>
<Text fw={500}>{exportPreview.estimatedSize}</Text>
</Group>
{mergedPdfDocument && mergedPdfDocument.pages.some(p => p.splitBefore) && (
<Alert color="blue">
This will create multiple PDF files based on split markers.
</Alert>
)}
<Group justify="flex-end" mt="md">
<Button
variant="light"
onClick={() => setShowExportModal(false)}
>
Cancel
</Button>
<Button
color="green"
loading={exportLoading}
onClick={() => {
setShowExportModal(false);
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.totalPages || 0);
handleExport(selectedOnly);
}}
>
Export PDF
</Button>
</Group>
</Stack>
)}
</Modal>
<FileInput
ref={fileInputRef}
accept="application/pdf"
onChange={(file) => file && handleFileUpload(file)}
style={{ display: 'none' }}
/>
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
)}
{error && (
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 70, right: 20, zIndex: 1000 }}
>
{error}
</Notification>
)}
</Box>
);
};
export default PageEditor;
@@ -1,191 +0,0 @@
import React from "react";
import {
Tooltip,
ActionIcon,
Paper
} from "@mantine/core";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import DownloadIcon from "@mui/icons-material/Download";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
interface PageEditorControlsProps {
// Close/Reset functions
onClosePdf: () => void;
// Undo/Redo
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
// Page operations
onRotate: (direction: 'left' | 'right') => void;
onDelete: () => void;
onSplit: () => void;
// Export functions
onExportSelected: () => void;
onExportAll: () => void;
exportLoading: boolean;
// Selection state
selectionMode: boolean;
selectedPages: string[];
}
const PageEditorControls = ({
onClosePdf,
onUndo,
onRedo,
canUndo,
canRedo,
onRotate,
onDelete,
onSplit,
onExportSelected,
onExportAll,
exportLoading,
selectionMode,
selectedPages
}: PageEditorControlsProps) => {
return (
<div
style={{
position: 'fixed',
left: '50%',
bottom: '20px',
transform: 'translateX(-50%)',
zIndex: 50,
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
background: 'transparent',
}}
>
<Paper
radius="xl"
shadow="lg"
p={16}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
borderRadius: 32,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
pointerEvents: 'auto',
minWidth: 400,
justifyContent: 'center'
}}
>
{/* Close PDF */}
<Tooltip label="Close PDF">
<ActionIcon
onClick={onClosePdf}
color="red"
variant="light"
size="lg"
>
<CloseIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Undo/Redo */}
<Tooltip label="Undo">
<ActionIcon onClick={onUndo} disabled={!canUndo} size="lg">
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={onRedo} disabled={!canRedo} size="lg">
<RedoIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Page Operations */}
<Tooltip label={selectionMode ? "Rotate Selected Left" : "Rotate All Left"}>
<ActionIcon
onClick={() => onRotate('left')}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
size="lg"
>
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Rotate Selected Right" : "Rotate All Right"}>
<ActionIcon
onClick={() => onRotate('right')}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
size="lg"
>
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
<ActionIcon
onClick={onDelete}
disabled={selectionMode && selectedPages.length === 0}
color="red"
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
size="lg"
>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
<ActionIcon
onClick={onSplit}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
size="lg"
>
<ContentCutIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Export Controls */}
{selectionMode && selectedPages.length > 0 && (
<Tooltip label="Export Selected">
<ActionIcon
onClick={onExportSelected}
disabled={exportLoading}
color="blue"
variant="light"
size="lg"
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Export All">
<ActionIcon
onClick={onExportAll}
disabled={exportLoading}
color="green"
variant="light"
size="lg"
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Paper>
</div>
);
};
export default PageEditorControls;
@@ -1,358 +0,0 @@
import React, { useCallback } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { PDFPage } from '../../../types/pageEditor';
import styles from './PageEditor.module.css';
interface PageThumbnailProps {
page: PDFPage;
index: number;
totalPages: number;
selectedPages: string[];
selectionMode: boolean;
draggedPage: string | null;
dropTarget: string | null;
movingPage: string | null;
isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (pageId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (pageId: string) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, pageId: string) => void;
onTogglePage: (pageId: string) => void;
onAnimateReorder: (pageId: string, targetIndex: number) => void;
onExecuteCommand: (command: any) => void;
onSetStatus: (status: string) => void;
onSetMovingPage: (pageId: string | null) => void;
RotatePagesCommand: any;
DeletePagesCommand: any;
ToggleSplitCommand: any;
pdfDocument: any;
setPdfDocument: any;
}
const PageThumbnail = ({
page,
index,
totalPages,
selectedPages,
selectionMode,
draggedPage,
dropTarget,
movingPage,
isAnimating,
pageRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onTogglePage,
onAnimateReorder,
onExecuteCommand,
onSetStatus,
onSetMovingPage,
RotatePagesCommand,
DeletePagesCommand,
ToggleSplitCommand,
pdfDocument,
setPdfDocument,
}: PageThumbnailProps) => {
// Register this component with pageRefs for animations
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
if (element) {
pageRefs.current.set(page.id, element);
} else {
pageRefs.current.delete(page.id);
}
}, [page.id, pageRefs]);
return (
<div
ref={pageElementRef}
data-page-id={page.id}
className={`
${styles.pageContainer}
!rounded-lg
cursor-grab
select-none
w-[20rem]
h-[20rem]
flex items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedPage === page.id ? 'opacity-50 scale-95' : ''}
${movingPage === page.id ? 'page-moving' : ''}
`}
style={{
transform: (() => {
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
draggable
onDragStart={() => onDragStart(page.id)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(page.id)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, page.id)}
>
{selectionMode && (
<div
className={styles.checkboxContainer}
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 4,
backgroundColor: 'white',
borderRadius: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto'
}}
onMouseDown={(e) => e.stopPropagation()}
onDragStart={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Checkbox
checked={selectedPages.includes(page.id)}
onChange={(event) => {
event.stopPropagation();
onTogglePage(page.id);
}}
onClick={(e) => e.stopPropagation()}
size="sm"
/>
</div>
)}
<div className="page-container w-[90%] h-[90%]">
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<img
src={page.thumbnail}
alt={`Page ${page.pageNumber}`}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 2,
transform: `rotate(${page.rotation}deg)`,
transition: 'transform 0.3s ease-in-out'
}}
/>
</div>
<Text
className={styles.pageNumber}
size="sm"
fw={500}
c="white"
style={{
position: 'absolute',
top: 5,
left: 5,
background: 'rgba(162, 201, 255, 0.8)',
padding: '6px 8px',
borderRadius: 8,
zIndex: 2,
opacity: 0,
transition: 'opacity 0.2s ease-in-out'
}}
>
{page.pageNumber}
</Text>
<div
className={styles.pageHoverControls}
style={{
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.8)',
padding: '6px 12px',
borderRadius: 20,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
zIndex: 3,
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap'
}}
>
<Tooltip label="Move Left">
<ActionIcon
size="md"
variant="subtle"
c="white"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.id);
onAnimateReorder(page.id, index - 1);
setTimeout(() => onSetMovingPage(null), 500);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
}}
>
<ArrowBackIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move Right">
<ActionIcon
size="md"
variant="subtle"
c="white"
disabled={index === totalPages - 1}
onClick={(e) => {
e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.id);
onAnimateReorder(page.id, index + 1);
setTimeout(() => onSetMovingPage(null), 500);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
}}
>
<ArrowForwardIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Left">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
-90
);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
}}
>
<RotateLeftIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Right">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
90
);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
}}
>
<RotateRightIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Page">
<ActionIcon
size="md"
variant="subtle"
c="red"
onClick={(e) => {
e.stopPropagation();
const command = new DeletePagesCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
onExecuteCommand(command);
onSetStatus(`Deleted page ${page.pageNumber}`);
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
{index > 0 && (
<Tooltip label="Split Here">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new ToggleSplitCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
onExecuteCommand(command);
onSetStatus(`Split marker toggled for page ${page.pageNumber}`);
}}
>
<ContentCutIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
)}
</div>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.3)',
fontSize: 16,
zIndex: 1
}}
/>
</div>
</div>
);
};
export default PageThumbnail;
@@ -1,140 +0,0 @@
import React from 'react';
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
import { useAppConfig } from '../../hooks/useAppConfig';
interface AppConfigModalProps {
opened: boolean;
onClose: () => void;
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const { config, loading, error, refetch } = useAppConfig();
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">{title}</Text>
<Stack gap="xs" pl="md">
{Object.entries(data).map(([key, value]) => (
<Group key={key} wrap="nowrap" align="flex-start">
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
{key}:
</Text>
{typeof value === 'boolean' ? (
<Badge color={value ? 'green' : 'red'} size="sm">
{value ? 'true' : 'false'}
</Badge>
) : typeof value === 'object' ? (
<Code block>{JSON.stringify(value, null, 2)}</Code>
) : (
String(value) || 'null'
)}
</Group>
))}
</Stack>
</Stack>
);
};
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
} : null;
const securityConfig = config ? {
enableLogin: config.enableLogin,
} : null;
const systemConfig = config ? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
} : null;
const premiumConfig = config ? {
premiumEnabled: config.premiumEnabled,
premiumKey: config.premiumKey ? '***hidden***' : null,
runningProOrHigher: config.runningProOrHigher,
runningEE: config.runningEE,
license: config.license,
} : null;
const integrationConfig = config ? {
GoogleDriveEnabled: config.GoogleDriveEnabled,
SSOAutoLogin: config.SSOAutoLogin,
} : null;
const legalConfig = config ? {
termsAndConditions: config.termsAndConditions,
privacyPolicy: config.privacyPolicy,
cookiePolicy: config.cookiePolicy,
impressum: config.impressum,
accessibilityStatement: config.accessibilityStatement,
} : null;
return (
<Modal
opened={opened}
onClose={onClose}
title="App Configuration (Testing)"
size="lg"
style={{ zIndex: 1000 }}
>
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
This modal shows the current application configuration for testing purposes only.
</Text>
<Button size="xs" variant="light" onClick={refetch}>
Refresh
</Button>
</Group>
{loading && (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading configuration...</Text>
</Stack>
)}
{error && (
<Alert color="red" title="Error">
{error}
</Alert>
)}
{config && (
<ScrollArea h={400}>
<Stack gap="lg">
{renderConfigSection('Basic Configuration', basicConfig)}
{renderConfigSection('Security Configuration', securityConfig)}
{renderConfigSection('System Configuration', systemConfig)}
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
{renderConfigSection('Integration Configuration', integrationConfig)}
{renderConfigSection('Legal Configuration', legalConfig)}
{config.error && (
<Alert color="yellow" title="Configuration Warning">
{config.error}
</Alert>
)}
<Stack gap="xs">
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
<Code block style={{ fontSize: '11px' }}>
{JSON.stringify(config, null, 2)}
</Code>
</Stack>
</Stack>
</ScrollArea>
)}
</Stack>
</Modal>
);
};
export default AppConfigModal;
@@ -1,264 +0,0 @@
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;
storedFiles: any[]; // Files from storage (FileWithUrl format)
onSelectFiles: (selectedFiles: File[]) => void;
}
const FilePickerModal = ({
opened,
onClose,
storedFiles,
onSelectFiles,
}: FilePickerModalProps) => {
const { t } = useTranslation();
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
// Reset selection when modal opens
useEffect(() => {
if (opened) {
setSelectedFileIds([]);
}
}, [opened]);
const toggleFileSelection = (fileId: string) => {
setSelectedFileIds(prev => {
return prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId];
});
};
const selectAll = () => {
setSelectedFileIds(storedFiles.map(f => f.id || f.name));
};
const selectNone = () => {
setSelectedFileIds([]);
};
const handleConfirm = async () => {
const selectedFiles = storedFiles.filter(f =>
selectedFileIds.includes(f.id || f.name)
);
// Convert stored files to File objects
const convertedFiles = await Promise.all(
selectedFiles.map(async (fileItem) => {
try {
// If it's already a File object, return as is
if (fileItem instanceof File) {
return fileItem;
}
// If it has a file property, use that
if (fileItem.file && fileItem.file instanceof File) {
return fileItem.file;
}
// If it's from IndexedDB storage, reconstruct the File
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
const arrayBuffer = await fileItem.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: fileItem.type || 'application/pdf' });
return new File([blob], fileItem.name, {
type: fileItem.type || 'application/pdf',
lastModified: fileItem.lastModified || Date.now()
});
}
// If it has data property, reconstruct the File
if (fileItem.data) {
const blob = new Blob([fileItem.data], { type: fileItem.type || 'application/pdf' });
return new File([blob], fileItem.name, {
type: fileItem.type || 'application/pdf',
lastModified: fileItem.lastModified || Date.now()
});
}
console.warn('Could not convert file item:', fileItem);
return null;
} catch (error) {
console.error('Error converting file:', error, fileItem);
return null;
}
})
);
// Filter out any null values and return valid Files
const validFiles = convertedFiles.filter((f): f is File => f !== null);
onSelectFiles(validFiles);
onClose();
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
>
<Stack gap="md">
{storedFiles.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t("fileUpload.noFilesInStorage", "No files available in storage. Upload some files first.")}
</Text>
) : (
<>
{/* Selection controls */}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{storedFiles.length} {t("fileUpload.filesAvailable", "files available")}
{selectedFileIds.length > 0 && (
<> {selectedFileIds.length} selected</>
)}
</Text>
<Group gap="xs">
<Button size="xs" variant="light" onClick={selectAll}>
{t("pageEdit.selectAll", "Select All")}
</Button>
<Button size="xs" variant="light" onClick={selectNone}>
{t("pageEdit.deselectAll", "Select None")}
</Button>
</Group>
</Group>
{/* File grid */}
<ScrollArea.Autosize mah={400}>
<SimpleGrid cols={2} spacing="md">
{storedFiles.map((file) => {
const fileId = file.id || file.name;
const isSelected = selectedFileIds.includes(fileId);
return (
<Box
key={fileId}
p="sm"
style={{
border: isSelected
? '2px solid var(--mantine-color-blue-6)'
: '1px solid var(--mantine-color-gray-3)',
borderRadius: 8,
backgroundColor: isSelected
? 'var(--mantine-color-blue-0)'
: 'transparent',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onClick={() => toggleFileSelection(fileId)}
>
<Group gap="sm" align="flex-start">
<Checkbox
checked={isSelected}
onChange={() => toggleFileSelection(fileId)}
onClick={(e) => e.stopPropagation()}
/>
{/* Thumbnail */}
<Box
style={{
width: 60,
height: 80,
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--mantine-color-gray-0)',
flexShrink: 0
}}
>
{file.thumbnail ? (
<Image
src={file.thumbnail}
alt="PDF thumbnail"
height={70}
width={50}
fit="contain"
/>
) : (
<ThemeIcon
variant="light"
color="red"
size={40}
>
<PictureAsPdfIcon style={{ fontSize: 24 }} />
</ThemeIcon>
)}
</Box>
{/* File info */}
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={2}>
{file.name}
</Text>
<Group gap="xs">
<Badge size="xs" variant="light" color="gray">
{formatFileSize(file.size || (file.file?.size || 0))}
</Badge>
</Group>
</Stack>
</Group>
</Box>
);
})}
</SimpleGrid>
</ScrollArea.Autosize>
{/* Selection summary */}
{selectedFileIds.length > 0 && (
<Text size="sm" c="blue" ta="center">
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
</Text>
)}
</>
)}
{/* Action buttons */}
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={onClose}>
{t("close", "Cancel")}
</Button>
<Button
onClick={handleConfirm}
disabled={selectedFileIds.length === 0}
>
{selectedFileIds.length > 0
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
: t("fileUpload.loadFromStorage", "Load Files")
}
</Button>
</Group>
</Stack>
</Modal>
);
};
export default FilePickerModal;
@@ -1,171 +0,0 @@
import React, { useState, useCallback, useRef } 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;
accept?: string[];
// Loading state
loading?: boolean;
disabled?: boolean;
}
const FileUploadSelector = ({
title,
subtitle,
showDropzone = true,
sharedFiles = [],
onFileSelect,
onFilesSelect,
accept = ["application/pdf"],
loading = false,
disabled = false,
}: FileUploadSelectorProps) => {
const { t } = useTranslation();
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = useCallback((uploadedFiles: File[]) => {
if (uploadedFiles.length === 0) return;
if (onFilesSelect) {
onFilesSelect(uploadedFiles);
} else if (onFileSelect) {
onFileSelect(uploadedFiles[0]);
}
}, [onFileSelect, onFilesSelect]);
const handleFileInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files && files.length > 0) {
const fileArray = Array.from(files);
console.log('File input change:', fileArray.length, 'files');
handleFileUpload(fileArray);
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [handleFileUpload]);
const openFileDialog = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleStorageSelection = useCallback((selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
if (onFilesSelect) {
onFilesSelect(selectedFiles);
} else if (onFileSelect) {
onFileSelect(selectedFiles[0]);
}
}, [onFileSelect, onFilesSelect]);
// Get default title and subtitle from translations if not provided
const displayTitle = title || t("fileUpload.selectFiles", "Select files");
const displaySubtitle = subtitle || t("fileUpload.chooseFromStorageMultiple", "Choose files from storage or upload new PDFs");
return (
<>
<Stack align="center" gap="xl">
{/* Title and description */}
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: 64 }} />
<Text size="xl" fw={500}>
{displayTitle}
</Text>
<Text size="md" c="dimmed">
{displaySubtitle}
</Text>
</Stack>
{/* Action buttons */}
<Stack align="center" gap="md" w="100%">
<Button
variant="filled"
size="lg"
onClick={() => setShowFilePickerModal(true)}
disabled={disabled || sharedFiles.length === 0}
loading={loading}
>
{loading ? "Loading..." : `Load from Storage (${sharedFiles.length} files available)`}
</Button>
<Text size="md" c="dimmed">
{t("fileUpload.or", "or")}
</Text>
{showDropzone ? (
<Dropzone
onDrop={handleFileUpload}
accept={accept}
multiple={true}
disabled={disabled || loading}
style={{ width: '100%', minHeight: 120 }}
activateOnClick={true}
>
<Center>
<Stack align="center" gap="sm">
<Text size="md" fw={500}>
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
</Text>
<Text size="sm" c="dimmed">
{accept.includes('application/pdf')
? t("fileUpload.pdfFilesOnly", "PDF files only")
: t("fileUpload.supportedFileTypes", "Supported file types")
}
</Text>
</Stack>
</Center>
</Dropzone>
) : (
<Stack align="center" gap="sm">
<Button
variant="outline"
size="lg"
disabled={disabled}
loading={loading}
onClick={openFileDialog}
>
{t("fileUpload.uploadFiles", "Upload Files")}
</Button>
{/* Manual file input as backup */}
<input
ref={fileInputRef}
type="file"
multiple={true}
accept={accept.join(',')}
onChange={handleFileInputChange}
style={{ display: 'none' }}
/>
</Stack>
)}
</Stack>
</Stack>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={sharedFiles}
onSelectFiles={handleStorageSelection}
/>
</>
);
};
export default FileUploadSelector;
@@ -1,88 +0,0 @@
/* Language selector grid responsive layout */
.languageGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
}
.languageItem {
border-right: 2px solid var(--mantine-color-gray-3);
}
.languageItem:nth-child(4n) {
border-right: none;
}
/* Responsive breakpoints */
@media (max-width: 600px) {
.languageGrid {
grid-template-columns: repeat(2, 1fr);
}
.languageItem:nth-child(4n) {
border-right: 2px solid var(--mantine-color-gray-3);
}
.languageItem:nth-child(2n) {
border-right: none;
}
}
@media (min-width: 601px) and (max-width: 900px) {
.languageGrid {
grid-template-columns: repeat(3, 1fr);
}
.languageItem:nth-child(4n) {
border-right: 2px solid var(--mantine-color-gray-3);
}
.languageItem:nth-child(3n) {
border-right: none;
}
}
/* Dark theme support */
[data-mantine-color-scheme="dark"] .languageItem {
border-right-color: var(--mantine-color-dark-4);
}
[data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) {
border-right: none;
}
[data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) {
border-right-color: var(--mantine-color-dark-4);
}
[data-mantine-color-scheme="dark"] .languageItem:nth-child(3n) {
border-right-color: var(--mantine-color-dark-4);
}
/* Responsive text visibility */
.languageText {
display: none;
}
@media (min-width: 768px) {
.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;
}
}
@@ -1,219 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n';
import LanguageIcon from '@mui/icons-material/Language';
import styles from './LanguageSelector.module.css';
const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
const [isChanging, setIsChanging] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
const languageOptions = Object.entries(supportedLanguages)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: name,
}));
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
// Create ripple effect at click position
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
setRippleEffect({ x, y, key: Date.now() });
// Start transition animation
setIsChanging(true);
setPendingLanguage(value);
// Simulate processing time for smooth transition
setTimeout(() => {
i18n.changeLanguage(value);
setTimeout(() => {
setIsChanging(false);
setPendingLanguage(null);
setOpened(false);
// Clear ripple effect
setTimeout(() => setRippleEffect(null), 100);
}, 300);
}, 200);
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB'];
// Trigger animation when dropdown opens
useEffect(() => {
if (opened) {
setAnimationTriggered(false);
// Small delay to ensure DOM is ready
setTimeout(() => setAnimationTriggered(true), 50);
}
}, [opened]);
return (
<>
<style>
{`
@keyframes ripple-expand {
0% {
width: 0;
height: 0;
opacity: 0.6;
}
50% {
opacity: 0.3;
}
100% {
width: 100px;
height: 100px;
opacity: 0;
}
}
`}
</style>
<Menu
opened={opened}
onChange={setOpened}
width={600}
position="bottom-start"
offset={8}
transitionProps={{
transition: 'scale-y',
duration: 200,
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
>
<Menu.Target>
<Button
variant="subtle"
size="sm"
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
styles={{
root: {
border: 'none',
color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7],
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
}
},
label: {
fontSize: '12px',
fontWeight: 500,
}
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
</Button>
</Menu.Target>
<Menu.Dropdown
style={{
padding: '12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`,
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => (
<div
key={option.value}
className={styles.languageItem}
style={{
opacity: animationTriggered ? 1 : 0,
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
}}
>
<Button
variant="subtle"
size="sm"
fullWidth
onClick={(event) => handleLanguageChange(option.value, event)}
styles={{
root: {
borderRadius: '4px',
minHeight: '32px',
padding: '4px 8px',
justifyContent: 'flex-start',
position: 'relative',
overflow: 'hidden',
backgroundColor: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
) : 'transparent',
color: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7]
) : (
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
),
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
) : (
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
),
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}
},
label: {
fontSize: '13px',
fontWeight: option.value === i18n.language ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
position: 'relative',
zIndex: 2,
}
}}
>
{option.label}
{/* Ripple effect */}
{rippleEffect && pendingLanguage === option.value && (
<div
key={rippleEffect.key}
style={{
position: 'absolute',
left: rippleEffect.x,
top: rippleEffect.y,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: theme.colors.blue[4],
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
zIndex: 1,
}}
/>
)}
</Button>
</div>
))}
</div>
</ScrollArea>
</Menu.Dropdown>
</Menu>
</>
);
};
export default LanguageSelector;
@@ -1,87 +0,0 @@
import React, { useState } from "react";
import { ActionIcon, Stack, Tooltip } from "@mantine/core";
import MenuBookIcon from "@mui/icons-material/MenuBook";
import AppsIcon from "@mui/icons-material/Apps";
import SettingsIcon from "@mui/icons-material/Settings";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css';
import AppConfigModal from './AppConfigModal';
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();
const [configModalOpen, setConfigModalOpen] = useState(false);
return (
<div
className={`h-screen flex flex-col w-20 ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
style={{
padding: '1rem 0.5rem',
backgroundColor: 'var(--bg-muted)'
}}
>
<Stack gap="lg" align="center" className="flex-1">
{/* All Tools Button */}
<div className="flex flex-col items-center gap-1">
<ActionIcon
size="xl"
variant={leftPanelView === 'toolPicker' && !readerMode ? "filled" : "subtle"}
onClick={onToolsClick}
>
<AppsIcon sx={{ fontSize: 28 }} />
</ActionIcon>
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Tools</span>
</div>
{/* Reader Mode Button */}
<div className="flex flex-col items-center gap-1">
<ActionIcon
size="xl"
variant={readerMode ? "filled" : "subtle"}
onClick={onReaderToggle}
>
<MenuBookIcon sx={{ fontSize: 28 }} />
</ActionIcon>
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Read</span>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Config Modal Button (for testing) */}
<div className="flex flex-col items-center gap-1">
<ActionIcon
size="lg"
variant="subtle"
onClick={() => setConfigModalOpen(true)}
>
<SettingsIcon sx={{ fontSize: 20 }} />
</ActionIcon>
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Config</span>
</div>
</Stack>
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</div>
);
};
export default QuickAccessBar;
@@ -1,55 +0,0 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
import { mantineTheme } from '../../theme/mantineTheme';
import rainbowStyles from '../../styles/rainbow.module.css';
interface RainbowThemeContextType {
themeMode: 'light' | 'dark' | 'rainbow';
isRainbowMode: boolean;
isToggleDisabled: boolean;
toggleTheme: () => void;
activateRainbow: () => void;
deactivateRainbow: () => void;
}
const RainbowThemeContext = createContext<RainbowThemeContextType | null>(null);
export function useRainbowThemeContext() {
const context = useContext(RainbowThemeContext);
if (!context) {
throw new Error('useRainbowThemeContext must be used within RainbowThemeProvider');
}
return context;
}
interface RainbowThemeProviderProps {
children: ReactNode;
}
export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
const rainbowTheme = useRainbowTheme();
// Determine the Mantine color scheme
const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode;
return (
<>
<ColorSchemeScript defaultColorScheme={mantineColorScheme} />
<RainbowThemeContext.Provider value={rainbowTheme}>
<MantineProvider
theme={mantineTheme}
defaultColorScheme={mantineColorScheme}
forceColorScheme={mantineColorScheme}
>
<div
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
style={{ minHeight: '100vh' }}
>
{children}
</div>
</MantineProvider>
</RainbowThemeContext.Provider>
</>
);
}
@@ -1,106 +0,0 @@
import React from "react";
import { Button, SegmentedControl } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector";
import rainbowStyles from '../../styles/rainbow.module.css';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
const VIEW_OPTIONS = [
{
label: (
<Group gap={5}>
<VisibilityIcon fontSize="small" />
</Group>
),
value: "viewer",
},
{
label: (
<Group gap={4}>
<EditNoteIcon fontSize="small" />
</Group>
),
value: "pageEditor",
},
{
label: (
<Group gap={4}>
<InsertDriveFileIcon fontSize="small" />
</Group>
),
value: "fileManager",
},
{
label: (
<Group gap={4}>
<FolderIcon fontSize="small" />
</Group>
),
value: "fileEditor",
},
];
interface TopControlsProps {
currentView: string;
setCurrentView: (view: string) => void;
}
const TopControls = ({
currentView,
setCurrentView,
}: TopControlsProps) => {
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
if (themeMode === "dark") return <LightModeIcon />;
return <DarkModeIcon />;
};
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-auto flex gap-2 items-center">
<Button
onClick={toggleTheme}
variant="subtle"
size="md"
aria-label="Toggle theme"
disabled={isToggleDisabled}
className={isRainbowMode ? rainbowStyles.rainbowButton : ''}
title={
isToggleDisabled
? "Button disabled for 3 seconds..."
: isRainbowMode
? "Rainbow Mode Active! Click to exit"
: "Toggle theme (click rapidly 6 times for a surprise!)"
}
style={isToggleDisabled ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{getThemeIcon()}
</Button>
<LanguageSelector />
</div>
<div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl
data={VIEW_OPTIONS}
value={currentView}
onChange={setCurrentView}
color="blue"
radius="xl"
size="md"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
/>
</div>
</div>
);
};
export default TopControls;

Some files were not shown because too many files have changed in this diff Show More