mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a53cce80b6 | ||
|
|
b8d582a1e3 | ||
|
|
0549c5b191 | ||
|
|
0742364a03 | ||
|
|
9af09c6ac2 | ||
|
|
117d906be3 | ||
|
|
33549c2d67 | ||
|
|
a35956d927 | ||
|
|
3fd52ed564 | ||
|
|
2f977ff250 | ||
|
|
922bbc9076 | ||
|
|
584e2ecee7 | ||
|
|
80e425926e | ||
|
|
58d53c7a6b | ||
|
|
89cda87490 | ||
|
|
af8e065e87 | ||
|
|
21210850ec | ||
|
|
50d745225c | ||
|
|
f446fcd50b | ||
|
|
618485576d | ||
|
|
e75213a206 | ||
|
|
248a14c571 | ||
|
|
62fb5025a9 | ||
|
|
3444aa6525 | ||
|
|
5786421616 | ||
|
|
0090d9f483 | ||
|
|
29916d85b1 | ||
|
|
ffe5b9577b | ||
|
|
2f90220b7b | ||
|
|
bfc679edc5 | ||
|
|
27dd11ba8a | ||
|
|
a51960b199 | ||
|
|
16e56e3208 | ||
|
|
25e9db2570 | ||
|
|
cbc5616a39 | ||
|
|
215bb86a8e | ||
|
|
3ebf75ae6f | ||
|
|
9c410865f9 | ||
|
|
868969192b | ||
|
|
d981968e0f | ||
|
|
ae508730c3 | ||
|
|
bf6a74210f | ||
|
|
e7995a0256 | ||
|
|
7fc850b138 | ||
|
|
ac3da9b7c2 | ||
|
|
5298cb1607 | ||
|
|
41efd80dd5 | ||
|
|
342345cbb9 | ||
|
|
6bf41a4cbe | ||
|
|
1e0949ff2b | ||
|
|
b1584151ba | ||
|
|
0c76bf2c8b | ||
|
|
b5d84db3c8 | ||
|
|
7dd79aa2a1 | ||
|
|
61eac67dc1 | ||
|
|
16f150a203 | ||
|
|
a3c4f1a305 | ||
|
|
0650a6a877 | ||
|
|
09f05ac8d0 | ||
|
|
5f862f55d9 | ||
|
|
15761ae743 | ||
|
|
d216811317 | ||
|
|
41c82b15da | ||
|
|
5f7a4e1664 | ||
|
|
b5e7995554 | ||
|
|
f789533c83 | ||
|
|
d669964975 | ||
|
|
b567f4b110 |
@@ -5,7 +5,8 @@
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(./gradlew:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(cat:*)"
|
||||
"Bash(cat:*)",
|
||||
"Bash(find:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
name: Auto PR V2 Deployment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-pr:
|
||||
if: github.event.action != 'closed'
|
||||
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"
|
||||
echo "PR Base Branch: ${{ github.event.pull_request.base.ref }}"
|
||||
|
||||
# Define authorized users
|
||||
authorized_users=(
|
||||
"Frooodle"
|
||||
"sf298"
|
||||
"Ludy87"
|
||||
"LaserKaspar"
|
||||
"sbplat"
|
||||
"reecebrowne"
|
||||
"DarioGii"
|
||||
"ConnorYoh"
|
||||
"EthanHealy01"
|
||||
)
|
||||
|
||||
# Check if author is in the authorized list
|
||||
is_authorized=false
|
||||
for user in "${authorized_users[@]}"; do
|
||||
if [[ "$PR_AUTHOR" == "$user" ]]; then
|
||||
is_authorized=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# If PR is targeting V2 and user is authorized, deploy unconditionally
|
||||
PR_BASE_BRANCH="${{ github.event.pull_request.base.ref }}"
|
||||
if [[ "$PR_BASE_BRANCH" == "V2" && "$is_authorized" == "true" ]]; then
|
||||
echo "✅ Deployment forced: PR targets V2 and author is authorized."
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise, continue with original keyword checks
|
||||
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
|
||||
|
||||
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: Checkout main repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: main
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
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.setup-bot.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 }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
|
||||
- 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: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
run: |
|
||||
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
|
||||
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check if frontend image exists
|
||||
id: check-frontend
|
||||
run: |
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Frontend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Frontend image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Check if backend image exists
|
||||
id: check-backend
|
||||
run: |
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Backend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Backend image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Build and push V2 frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
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 }}
|
||||
BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts
|
||||
|
||||
# Create docker-compose for V2 with separate frontend and backend
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
stirling-pdf-v2-backend:
|
||||
container_name: stirling-pdf-v2-backend-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
ports:
|
||||
- "${BACKEND_PORT}:8080" # Backend API port
|
||||
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
|
||||
|
||||
stirling-pdf-v2-frontend:
|
||||
container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
ports:
|
||||
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
|
||||
depends_on:
|
||||
- stirling-pdf-v2-backend
|
||||
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
|
||||
|
||||
# Clean up old backend/frontend images (older than 2 weeks)
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true"
|
||||
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.setup-bot.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
|
||||
});
|
||||
|
||||
cleanup-v2-deployment:
|
||||
if: github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ github.event.pull_request.number }};
|
||||
|
||||
// Find and delete V2 deployment comments
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
const v2Comments = comments.filter(c =>
|
||||
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
|
||||
c.user?.type === "Bot"
|
||||
);
|
||||
|
||||
for (const comment of v2Comments) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
console.log(`Deleted V2 deployment comment (ID: ${comment.id})`);
|
||||
}
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup V2 deployment
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found V2 PR directory, proceeding with cleanup..."
|
||||
|
||||
# Stop and remove V2 containers
|
||||
cd /stirling/V2-PR-${{ github.event.pull_request.number }}
|
||||
docker-compose down || true
|
||||
|
||||
# Go back to root before removal
|
||||
cd /
|
||||
|
||||
# Remove V2 PR-specific directories
|
||||
rm -rf /stirling/V2-PR-${{ github.event.pull_request.number }}
|
||||
|
||||
# Clean up V2 containers by name (in case compose cleanup missed them)
|
||||
docker rm -f stirling-pdf-v2-frontend-pr-${{ github.event.pull_request.number }} || true
|
||||
docker rm -f stirling-pdf-v2-backend-pr-${{ github.event.pull_request.number }} || true
|
||||
|
||||
echo "V2 cleanup completed"
|
||||
else
|
||||
echo "V2 PR directory not found, nothing to clean up"
|
||||
fi
|
||||
|
||||
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true"
|
||||
|
||||
# Note: We don't remove the commit-based images since they can be reused across PRs
|
||||
# Only remove PR-specific containers and directories
|
||||
ENDSSH
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ../private.key
|
||||
continue-on-error: true
|
||||
|
||||
@@ -2,9 +2,9 @@ name: Build repo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ["main", "V2", "V2-gha"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
branches: ["main", "V2", "V2-gha"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -114,6 +114,46 @@ jobs:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
|
||||
frontend-validation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
- name: Run frontend tests (if available)
|
||||
run: |
|
||||
cd frontend
|
||||
npm test --passWithNoTests --watchAll=false || true
|
||||
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
retention-days: 3
|
||||
|
||||
check-licence:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
name: Frontend License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
pull_request:
|
||||
branches:
|
||||
- V2
|
||||
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
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
// Get all comments on the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter for license check comments
|
||||
const licenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Frontend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Frontend License Check Failed')
|
||||
);
|
||||
|
||||
// Delete old license check comments
|
||||
for (const comment of licenseComments) {
|
||||
console.log(`Deleting old license check comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
|
||||
let commentBody;
|
||||
|
||||
if (hasWarnings) {
|
||||
// Read warnings file to get specific issues
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
try {
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
|
||||
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to read warning details';
|
||||
}
|
||||
|
||||
commentBody = `## ❌ Frontend License Check Failed
|
||||
|
||||
The frontend license check has detected compatibility warnings that require review:
|
||||
|
||||
${warningDetails}
|
||||
|
||||
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Frontend License Check Passed
|
||||
|
||||
All frontend licenses have been validated and no compatibility warnings were detected.
|
||||
|
||||
The frontend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ License warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
# Push Event: Commit license files and create PR
|
||||
- name: Commit changes (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git add frontend/src/assets/3rdPartyLicenses.json
|
||||
# Note: Do NOT commit license-warnings.json - it's only for PR review
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (Push only)
|
||||
if: github.event_name == 'push'
|
||||
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 (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && 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: V2
|
||||
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 (Push only)
|
||||
if: github.event_name == 'push' && 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 (Push only)
|
||||
if: github.event_name == 'push' && 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 }}
|
||||
@@ -27,6 +27,7 @@ clientWebUI/
|
||||
!cucumber/exampleFiles/
|
||||
!cucumber/exampleFiles/example_html.zip
|
||||
exampleYmlFiles/stirling/
|
||||
stirling/
|
||||
/testing/file_snapshots
|
||||
SwaggerDoc.json
|
||||
|
||||
@@ -196,6 +197,8 @@ id_ed25519.pub
|
||||
.pytest_cache
|
||||
.ipynb_checkpoints
|
||||
|
||||
|
||||
|
||||
**/jcef-bundle/
|
||||
|
||||
# node_modules
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# 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
|
||||
- **Deployment Options**:
|
||||
- **Desktop App**: `npm run tauri-build` (native desktop application)
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `src/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
#### Memory Management Strategy
|
||||
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
|
||||
- PDF.js documents that need explicit .destroy() calls
|
||||
- Blob URLs from tool outputs that need revocation
|
||||
- Web Workers that need termination
|
||||
Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
#### Tool Development
|
||||
- **Pattern**: Follow `src/tools/Split.tsx` as reference implementation
|
||||
- **File Access**: Tools receive `selectedFiles` prop (computed from activeFiles based on user selection)
|
||||
- **File Selection**: Users select files in FileEditor (tool mode) → stored as IDs → computed to File objects for tools
|
||||
- **Integration**: All files are part of FileContext ecosystem - automatic memory management and operation tracking
|
||||
- **Parameters**: Tool parameter handling patterns still being standardized
|
||||
- **Preview Integration**: Tools can implement preview functionality (see Split tool's thumbnail preview)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application with Thymeleaf templating
|
||||
- **Frontend**: React-based SPA in `/frontend` directory (Thymeleaf templates fully replaced)
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **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)
|
||||
|
||||
### Component Architecture
|
||||
- **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 Architecture Status
|
||||
|
||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||
- **State Management**: FileContext handles all file operations and tool navigation
|
||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||
- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## 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
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow Split tool pattern (`src/tools/Split.tsx`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,692 @@
|
||||
# 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.
|
||||
@@ -1,61 +0,0 @@
|
||||
# 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 Nicht‑Root Benutzer
|
||||
USER devuser
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Config", description = "Configuration APIs")
|
||||
@RequestMapping("/api/v1/config")
|
||||
@RequiredArgsConstructor
|
||||
@Hidden
|
||||
public class ConfigController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@GetMapping("/app-config")
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
||||
Map<String, Object> configData = new HashMap<>();
|
||||
|
||||
try {
|
||||
// Get AppConfig bean
|
||||
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
|
||||
|
||||
// Extract key configuration values from AppConfig
|
||||
configData.put("baseUrl", appConfig.getBaseUrl());
|
||||
configData.put("contextPath", appConfig.getContextPath());
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appName", applicationProperties.getUi().getAppName());
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("homeDescription", applicationProperties.getUi().getHomeDescription());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
|
||||
// Security settings
|
||||
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
|
||||
|
||||
// System settings
|
||||
configData.put(
|
||||
"enableAlphaFunctionality",
|
||||
applicationProperties.getSystem().getEnableAlphaFunctionality());
|
||||
configData.put(
|
||||
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
||||
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// Legal settings
|
||||
configData.put(
|
||||
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
|
||||
configData.put("privacyPolicy", applicationProperties.getLegal().getPrivacyPolicy());
|
||||
configData.put("cookiePolicy", applicationProperties.getLegal().getCookiePolicy());
|
||||
configData.put("impressum", applicationProperties.getLegal().getImpressum());
|
||||
configData.put(
|
||||
"accessibilityStatement",
|
||||
applicationProperties.getLegal().getAccessibilityStatement());
|
||||
|
||||
// Try to get EEAppConfig values if available
|
||||
try {
|
||||
if (applicationContext.containsBean("runningProOrHigher")) {
|
||||
configData.put(
|
||||
"runningProOrHigher",
|
||||
applicationContext.getBean("runningProOrHigher", Boolean.class));
|
||||
}
|
||||
if (applicationContext.containsBean("runningEE")) {
|
||||
configData.put(
|
||||
"runningEE", applicationContext.getBean("runningEE", Boolean.class));
|
||||
}
|
||||
if (applicationContext.containsBean("license")) {
|
||||
configData.put("license", applicationContext.getBean("license", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("GoogleDriveEnabled")) {
|
||||
configData.put(
|
||||
"GoogleDriveEnabled",
|
||||
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
|
||||
}
|
||||
if (applicationContext.containsBean("SSOAutoLogin")) {
|
||||
configData.put(
|
||||
"SSOAutoLogin",
|
||||
applicationContext.getBean("SSOAutoLogin", Boolean.class));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// EE features not available, continue without them
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(configData);
|
||||
|
||||
} catch (Exception e) {
|
||||
// Return basic config if there are any issues
|
||||
configData.put("error", "Unable to retrieve full configuration");
|
||||
return ResponseEntity.ok(configData);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/endpoint-enabled")
|
||||
public ResponseEntity<Boolean> isEndpointEnabled(@RequestParam String endpoint) {
|
||||
boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint);
|
||||
return ResponseEntity.ok(enabled);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoints-enabled")
|
||||
public ResponseEntity<Map<String, Boolean>> areEndpointsEnabled(
|
||||
@RequestParam String endpoints) {
|
||||
Map<String, Boolean> result = new HashMap<>();
|
||||
String[] endpointArray = endpoints.split(",");
|
||||
for (String endpoint : endpointArray) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(trimmedEndpoint, endpointConfiguration.isEndpointEnabled(trimmedEndpoint));
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
|
||||
public String forwardRootPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public String forwardNestedPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
}
|
||||
@@ -1811,6 +1811,70 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
|
||||
cookieBanner.preferencesModal.analytics.title=Analytics
|
||||
cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.
|
||||
|
||||
####################
|
||||
# React Frontend #
|
||||
####################
|
||||
# Common UI elements
|
||||
removeMetadata.submit=Remove Metadata
|
||||
sidebar.toggle=Toggle Sidebar
|
||||
theme.toggle=Toggle Theme
|
||||
view.viewer=Viewer
|
||||
view.pageEditor=Page Editor
|
||||
view.fileManager=File Manager
|
||||
|
||||
# File management
|
||||
fileManager.dragDrop=Drag & Drop files here
|
||||
fileManager.clickToUpload=Click to upload files
|
||||
fileManager.selectedFiles=Selected Files
|
||||
fileManager.clearAll=Clear All
|
||||
fileManager.storage=Storage
|
||||
fileManager.filesStored=files stored
|
||||
fileManager.storageError=Storage error occurred
|
||||
fileManager.storageLow=Storage is running low. Consider removing old files.
|
||||
fileManager.uploadError=Failed to upload some files.
|
||||
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
|
||||
|
||||
# Page Editor
|
||||
pageEditor.title=Page Editor
|
||||
pageEditor.save=Save Changes
|
||||
pageEditor.noPdfLoaded=No PDF loaded. Please upload a PDF to edit.
|
||||
pageEditor.rotatedLeft=Rotated left:
|
||||
pageEditor.rotatedRight=Rotated right:
|
||||
pageEditor.deleted=Deleted:
|
||||
pageEditor.movedLeft=Moved left:
|
||||
pageEditor.movedRight=Moved right:
|
||||
pageEditor.splitAt=Split at:
|
||||
pageEditor.insertedPageBreak=Inserted page break at:
|
||||
pageEditor.addFileNotImplemented=Add file not implemented in demo
|
||||
pageEditor.closePdf=Close PDF
|
||||
|
||||
# Viewer
|
||||
viewer.noPdfLoaded=No PDF loaded. Click to upload a PDF.
|
||||
viewer.choosePdf=Choose PDF
|
||||
viewer.noPagesToDisplay=No pages to display.
|
||||
viewer.singlePageView=Single Page View
|
||||
viewer.dualPageView=Dual Page View
|
||||
viewer.hideSidebars=Hide Sidebars
|
||||
viewer.showSidebars=Show Sidebars
|
||||
viewer.zoomOut=Zoom out
|
||||
viewer.zoomIn=Zoom in
|
||||
|
||||
# Tool Picker
|
||||
toolPicker.searchPlaceholder=Search tools...
|
||||
toolPicker.noToolsFound=No tools found
|
||||
pageEditor.reset=Reset Changes
|
||||
pageEditor.zoomIn=Zoom In
|
||||
pageEditor.zoomOut=Zoom Out
|
||||
pageEditor.fitToWidth=Fit to Width
|
||||
pageEditor.actualSize=Actual Size
|
||||
|
||||
# Viewer
|
||||
viewer.previousPage=Previous Page
|
||||
viewer.nextPage=Next Page
|
||||
viewer.pageNavigation=Page Navigation
|
||||
viewer.currentPage=Current Page
|
||||
viewer.totalPages=Total Pages
|
||||
|
||||
#fakeScan
|
||||
fakeScan.title=Fake Scan
|
||||
fakeScan.header=Fake Scan
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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
|
||||
└── compose/ # Docker Compose files
|
||||
├── docker-compose.yml # Standard setup
|
||||
├── docker-compose.ultra-lite.yml # Ultra-lite setup
|
||||
└── docker-compose.fat.yml # Full-featured 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
|
||||
```
|
||||
|
||||
|
||||
## 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 `VITE_API_BASE_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
|
||||
@@ -1,3 +1,27 @@
|
||||
# Backend Dockerfile - Java Spring Boot with all dependencies and build stage
|
||||
# Build the application
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Main stage
|
||||
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
|
||||
|
||||
@@ -5,12 +29,13 @@ FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be02
|
||||
COPY scripts /scripts
|
||||
COPY pipeline /pipeline
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
COPY app/core/build/libs/*.jar app.jar
|
||||
# first /app directory is for the build stage, second is for the final image
|
||||
COPY --from=build /app/app/core/build/libs/*.jar app.jar
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
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.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.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
@@ -19,10 +44,10 @@ 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, merge, split, convert, OCR, watermark"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
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="" \
|
||||
@@ -39,8 +64,7 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
|
||||
|
||||
# JDK for app
|
||||
# JDK for app 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 && \
|
||||
@@ -63,21 +87,19 @@ 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 features)
|
||||
unpaper \
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
unpaper \
|
||||
ocrmypdf \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
ocrmypdf \
|
||||
py3-pip \
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing \
|
||||
# URW Base 35 fonts for better PDF rendering
|
||||
font-urw-base35 && \
|
||||
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 && \
|
||||
@@ -86,8 +108,6 @@ 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 && \
|
||||
@@ -100,4 +120,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"]
|
||||
@@ -1,3 +1,4 @@
|
||||
# Backend fat Dockerfile - Java Spring Boot with all dependencies and build stage
|
||||
# Build the application
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
@@ -53,7 +54,6 @@ 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 && \
|
||||
@@ -77,17 +77,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)
|
||||
unpaper \
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
unpaper \
|
||||
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine font-urw-base35 \
|
||||
ocrmypdf \
|
||||
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
ocrmypdf \
|
||||
py3-pip \
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing && \
|
||||
@@ -99,8 +99,6 @@ 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 && \
|
||||
@@ -112,4 +110,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,28 @@
|
||||
# use alpine
|
||||
# Backend ultra-lite Dockerfile - Java Spring Boot with minimal dependencies and build stage
|
||||
# Build the application
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=true
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Main stage
|
||||
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
|
||||
|
||||
ARG VERSION_TAG
|
||||
@@ -18,11 +42,10 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
TMP=/tmp/stirling-pdf
|
||||
|
||||
# Copy necessary files
|
||||
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
|
||||
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
||||
COPY scripts/installFonts.sh /scripts/installFonts.sh
|
||||
COPY pipeline /pipeline
|
||||
COPY app/core/build/libs/*.jar app.jar
|
||||
COPY --from=build /app/app/core/build/libs/*.jar app.jar
|
||||
|
||||
# Set up necessary directories and permissions
|
||||
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
@@ -0,0 +1,64 @@
|
||||
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: ../..
|
||||
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:
|
||||
@@ -0,0 +1,54 @@
|
||||
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: ../..
|
||||
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
|
||||
@@ -0,0 +1,62 @@
|
||||
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: ../..
|
||||
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:
|
||||
@@ -0,0 +1,38 @@
|
||||
# Frontend Dockerfile - React/Vite application
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY frontend .
|
||||
|
||||
# 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 docker/frontend/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/frontend/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 VITE_API_BASE_URL=http://backend:8080
|
||||
|
||||
# Use custom entrypoint
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Set default backend URL if not provided
|
||||
VITE_API_BASE_URL=${VITE_API_BASE_URL:-"http://backend:8080"}
|
||||
|
||||
# Replace the placeholder in nginx.conf with the actual backend URL
|
||||
sed -i "s|\${VITE_API_BASE_URL}|${VITE_API_BASE_URL}|g" /etc/nginx/nginx.conf
|
||||
|
||||
# Start nginx
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -0,0 +1,67 @@
|
||||
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;
|
||||
|
||||
# Global settings for file uploads
|
||||
client_max_body_size 100m;
|
||||
|
||||
# Handle client-side routing - support subpaths
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API calls to backend
|
||||
location /api/ {
|
||||
proxy_pass ${VITE_API_BASE_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;
|
||||
|
||||
# Additional headers for proper API proxying
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# Timeout settings for large file uploads
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Request size limits for file uploads
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
|
||||
services:
|
||||
stirling-pdf:
|
||||
container_name: Stirling-PDF-Fat-Disable-Endpoints
|
||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest-fat
|
||||
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
|
||||
volumes:
|
||||
- ./stirling/latest/data:/usr/share/tessdata:rw
|
||||
- ./stirling/latest/config:/configs:rw
|
||||
- ./stirling/latest/logs:/logs:rw
|
||||
- ../testing/allEndpointsRemovedSettings.yml:/configs/settings.yml:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
PUID: 1002
|
||||
PGID: 1002
|
||||
UMASK: "022"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: Stirling-PDF
|
||||
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest-fat with all Endpoints Disabled
|
||||
UI_APPNAMENAVBAR: Stirling-PDF Latest-fat
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "true"
|
||||
SHOW_SURVEY: "true"
|
||||
restart: on-failure:5
|
||||
@@ -1,64 +0,0 @@
|
||||
services:
|
||||
stirling-pdf:
|
||||
container_name: Stirling-PDF-Security-Fat-Postgres
|
||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest-fat-postgres
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
depends_on:
|
||||
- db
|
||||
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
|
||||
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"
|
||||
PUID: 1002
|
||||
PGID: 1002
|
||||
UMASK: "022"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: Stirling-PDF
|
||||
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest-fat with Security and PostgreSQL
|
||||
UI_APPNAMENAVBAR: Stirling-PDF Latest-fat-PostgreSQL
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "true"
|
||||
SYSTEM_DATASOURCE_ENABLECUSTOMDATABASE: "true"
|
||||
SYSTEM_DATASOURCE_CUSTOMDATABASEURL: "jdbc:postgresql://db:5432/stirling_pdf"
|
||||
SYSTEM_DATASOURCE_USERNAME: "admin"
|
||||
SYSTEM_DATASOURCE_PASSWORD: "stirling"
|
||||
SHOW_SURVEY: "true"
|
||||
restart: on-failure:5
|
||||
|
||||
db:
|
||||
image: 'postgres:17.2-alpine'
|
||||
restart: on-failure:5
|
||||
container_name: db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: "stirling_pdf"
|
||||
POSTGRES_USER: "admin"
|
||||
POSTGRES_PASSWORD: "stirling"
|
||||
shm_size: "512mb"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
cpus: "0.5"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U admin stirling_pdf" ]
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- ./stirling/latest/data:/pgdata
|
||||
@@ -1,34 +0,0 @@
|
||||
services:
|
||||
stirling-pdf:
|
||||
container_name: Stirling-PDF-Security-Fat
|
||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest-fat
|
||||
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
|
||||
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"
|
||||
PUID: 1002
|
||||
PGID: 1002
|
||||
UMASK: "022"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: Stirling-PDF
|
||||
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest-fat with Security
|
||||
UI_APPNAMENAVBAR: Stirling-PDF Latest-fat
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "true"
|
||||
SHOW_SURVEY: "true"
|
||||
restart: on-failure:5
|
||||
@@ -1,42 +0,0 @@
|
||||
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"
|
||||
SECURITY_OAUTH2_ENABLED: "true"
|
||||
SECURITY_OAUTH2_AUTOCREATEUSER: "true" # This is set to true to allow auto-creation of non-existing users in Stirling-PDF
|
||||
SECURITY_OAUTH2_ISSUER: "https://accounts.google.com" # Change with any other provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) end-point
|
||||
SECURITY_OAUTH2_CLIENTID: "<YOUR CLIENT ID>.apps.googleusercontent.com" # Client ID from your provider
|
||||
SECURITY_OAUTH2_CLIENTSECRET: "<YOUR CLIENT SECRET>" # Client Secret from your provider
|
||||
SECURITY_OAUTH2_SCOPES: "openid,profile,email" # Expected OAuth2 Scope
|
||||
SECURITY_OAUTH2_USEASUSERNAME: "email" # Default is 'email'; custom fields can be used as the username
|
||||
SECURITY_OAUTH2_PROVIDER: "google" # Set this to your OAuth provider's name, e.g., 'google' or 'keycloak'
|
||||
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
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
services:
|
||||
stirling-pdf:
|
||||
container_name: Stirling-PDF-Ultra-Lite-Security
|
||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest-ultra-lite
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
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"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
UI_APPNAME: Stirling-PDF-Lite
|
||||
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF-Lite Latest with Security
|
||||
UI_APPNAMENAVBAR: Stirling-PDF-Lite Latest
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "true"
|
||||
SHOW_SURVEY: "true"
|
||||
restart: on-failure:5
|
||||
@@ -1,31 +0,0 @@
|
||||
services:
|
||||
stirling-pdf:
|
||||
container_name: Stirling-PDF
|
||||
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 -qv '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:
|
||||
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"
|
||||
restart: on-failure:5
|
||||
@@ -1,34 +0,0 @@
|
||||
services:
|
||||
stirling-pdf:
|
||||
container_name: Stirling-PDF-Security-Fat-with-login
|
||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest-fat
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f -H 'X-API-KEY: 123456789' http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
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-fat with Security
|
||||
UI_APPNAMENAVBAR: Stirling-PDF Latest-fat
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "true"
|
||||
SECURITY_CUSTOMGLOBALAPIKEY: "123456789"
|
||||
restart: on-failure:5
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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*
|
||||
@@ -0,0 +1,74 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Docker Setup
|
||||
|
||||
For Docker deployments and configuration, see the [Docker README](../docker/README.md).
|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,700,0,0" />
|
||||
<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>
|
||||
Generated
+6346
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"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",
|
||||
"jszip": "^3.10.1",
|
||||
"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": "vite build",
|
||||
"preview": "vite preview",
|
||||
"generate-licenses": "node scripts/generate-licenses.js"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('@tailwindcss/postcss'),
|
||||
require('autoprefixer'),
|
||||
],
|
||||
};
|
||||
Binary file not shown.
|
After 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.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Vendored
+58353
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,157 @@
|
||||
// Web Worker for parallel thumbnail generation
|
||||
console.log('🔧 Thumbnail worker starting up...');
|
||||
|
||||
let pdfJsLoaded = false;
|
||||
|
||||
// Import PDF.js properly for worker context
|
||||
try {
|
||||
console.log('📦 Loading PDF.js locally...');
|
||||
importScripts('/pdf.js');
|
||||
|
||||
// PDF.js exports to globalThis, check both self and globalThis
|
||||
const pdfjsLib = self.pdfjsLib || globalThis.pdfjsLib;
|
||||
|
||||
if (pdfjsLib) {
|
||||
// Make it available on self for consistency
|
||||
self.pdfjsLib = pdfjsLib;
|
||||
|
||||
// Set up PDF.js worker
|
||||
self.pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
pdfJsLoaded = true;
|
||||
console.log('✓ PDF.js loaded successfully from local files');
|
||||
console.log('✓ PDF.js version:', self.pdfjsLib.version || 'unknown');
|
||||
} else {
|
||||
throw new Error('pdfjsLib not available after import - neither self.pdfjsLib nor globalThis.pdfjsLib found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('✗ Failed to load local PDF.js:', error.message || error);
|
||||
console.error('✗ Available globals:', Object.keys(self).filter(key => key.includes('pdf')));
|
||||
pdfJsLoaded = false;
|
||||
}
|
||||
|
||||
// Log the final status
|
||||
if (pdfJsLoaded) {
|
||||
console.log('✅ Thumbnail worker ready for PDF processing');
|
||||
} else {
|
||||
console.log('❌ Thumbnail worker failed to initialize - PDF.js not available');
|
||||
}
|
||||
|
||||
self.onmessage = async function(e) {
|
||||
const { type, data, jobId } = e.data;
|
||||
|
||||
try {
|
||||
// Handle PING for worker health check
|
||||
if (type === 'PING') {
|
||||
console.log('🏓 Worker PING received, checking PDF.js status...');
|
||||
|
||||
// Check if PDF.js is loaded before responding
|
||||
if (pdfJsLoaded && self.pdfjsLib) {
|
||||
console.log('✓ Worker PONG - PDF.js ready');
|
||||
self.postMessage({ type: 'PONG', jobId });
|
||||
} else {
|
||||
console.error('✗ PDF.js not loaded - worker not ready');
|
||||
console.error('✗ pdfJsLoaded:', pdfJsLoaded);
|
||||
console.error('✗ self.pdfjsLib:', !!self.pdfjsLib);
|
||||
self.postMessage({
|
||||
type: 'ERROR',
|
||||
jobId,
|
||||
data: { error: 'PDF.js not loaded in worker' }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'GENERATE_THUMBNAILS') {
|
||||
console.log('🖼️ Starting thumbnail generation for', data.pageNumbers.length, 'pages');
|
||||
|
||||
if (!pdfJsLoaded || !self.pdfjsLib) {
|
||||
const error = 'PDF.js not available in worker';
|
||||
console.error('✗', error);
|
||||
throw new Error(error);
|
||||
}
|
||||
const { pdfArrayBuffer, pageNumbers, scale = 0.2, quality = 0.8 } = data;
|
||||
|
||||
console.log('📄 Loading PDF document, size:', pdfArrayBuffer.byteLength, 'bytes');
|
||||
// Load PDF in worker using imported PDF.js
|
||||
const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise;
|
||||
console.log('✓ PDF loaded, total pages:', pdf.numPages);
|
||||
|
||||
const thumbnails = [];
|
||||
|
||||
// Process pages in smaller batches for smoother UI
|
||||
const batchSize = 3; // Process 3 pages at once for smoother UI
|
||||
for (let i = 0; i < pageNumbers.length; i += batchSize) {
|
||||
const batch = pageNumbers.slice(i, i + batchSize);
|
||||
|
||||
const batchPromises = batch.map(async (pageNumber) => {
|
||||
try {
|
||||
console.log(`🎯 Processing page ${pageNumber}...`);
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
console.log(`📐 Page ${pageNumber} viewport:`, viewport.width, 'x', viewport.height);
|
||||
|
||||
// Create OffscreenCanvas for better performance
|
||||
const canvas = new OffscreenCanvas(viewport.width, viewport.height);
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Failed to get 2D context from OffscreenCanvas');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
console.log(`✓ Page ${pageNumber} rendered`);
|
||||
|
||||
// Convert to blob then to base64 (more efficient than toDataURL)
|
||||
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
const thumbnail = `data:image/jpeg;base64,${base64}`;
|
||||
console.log(`✓ Page ${pageNumber} thumbnail generated (${base64.length} chars)`);
|
||||
|
||||
return { pageNumber, thumbnail, success: true };
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to generate thumbnail for page ${pageNumber}:`, error.message || error);
|
||||
return { pageNumber, error: error.message || String(error), success: false };
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
thumbnails.push(...batchResults);
|
||||
|
||||
// Send progress update
|
||||
console.log(`📊 Worker: Sending progress update - ${thumbnails.length}/${pageNumbers.length} completed, ${batchResults.filter(r => r.success).length} new thumbnails`);
|
||||
self.postMessage({
|
||||
type: 'PROGRESS',
|
||||
jobId,
|
||||
data: {
|
||||
completed: thumbnails.length,
|
||||
total: pageNumbers.length,
|
||||
thumbnails: batchResults.filter(r => r.success)
|
||||
}
|
||||
});
|
||||
|
||||
// Small delay between batches to keep UI smooth
|
||||
if (i + batchSize < pageNumbers.length) {
|
||||
console.log(`⏸️ Worker: Pausing 100ms before next batch (${i + batchSize}/${pageNumbers.length})`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // Increased to 100ms pause between batches for smoother scrolling
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
|
||||
self.postMessage({
|
||||
type: 'COMPLETE',
|
||||
jobId,
|
||||
data: { thumbnails: thumbnails.filter(r => r.success) }
|
||||
});
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: 'ERROR',
|
||||
jobId,
|
||||
data: { error: error.message }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,415 @@
|
||||
#!/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)
|
||||
});
|
||||
|
||||
let licenseData;
|
||||
try {
|
||||
licenseData = JSON.parse(licenseReport);
|
||||
} catch (parseError) {
|
||||
console.error('❌ Failed to parse license data:', parseError.message);
|
||||
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!licenseData || typeof licenseData !== 'object') {
|
||||
console.error('❌ Invalid license data structure');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Normalize license types for edge cases
|
||||
let licenseType = value.licenses;
|
||||
|
||||
// Handle missing or null licenses
|
||||
if (!licenseType || licenseType === null || licenseType === undefined) {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
// Handle empty string licenses
|
||||
if (licenseType === '') {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
// Handle array licenses (rare but possible)
|
||||
if (Array.isArray(licenseType)) {
|
||||
licenseType = licenseType.join(' AND ');
|
||||
}
|
||||
|
||||
// Handle object licenses (fallback)
|
||||
if (typeof licenseType === 'object' && licenseType !== null) {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
version: version || value.version || 'unknown',
|
||||
licenseType: licenseType,
|
||||
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`);
|
||||
});
|
||||
|
||||
// Log any complex or unusual license formats for debugging
|
||||
const complexLicenses = licenseArray.filter(dep =>
|
||||
dep.licenseType && (
|
||||
dep.licenseType.includes('AND') ||
|
||||
dep.licenseType.includes('OR') ||
|
||||
dep.licenseType === 'Unknown' ||
|
||||
dep.licenseType.includes('SEE LICENSE')
|
||||
)
|
||||
);
|
||||
|
||||
if (complexLicenses.length > 0) {
|
||||
console.log('\n🔍 Complex/Edge case licenses detected:');
|
||||
complexLicenses.forEach(dep => {
|
||||
console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`);
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (licenseType.includes('AND') || licenseType.includes('OR')) {
|
||||
// Extract the first license from compound expressions for URL
|
||||
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-\.]+)/);
|
||||
if (match && licenseUrls[match[1]]) {
|
||||
return licenseUrls[match[1]];
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
]);
|
||||
|
||||
// Helper function to normalize license names for comparison
|
||||
function normalizeLicense(license) {
|
||||
return license
|
||||
.replace(/-or-later$/, '') // Remove -or-later suffix
|
||||
.replace(/\+$/, '') // Remove + suffix
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 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')) {
|
||||
// For OR licenses, check if there's at least one acceptable license option
|
||||
if (license.includes('OR')) {
|
||||
// Extract license components from OR expression
|
||||
const orComponents = license
|
||||
.replace(/[()]/g, '') // Remove parentheses
|
||||
.split(' OR ')
|
||||
.map(component => component.trim());
|
||||
|
||||
// Check if any component is in the goodLicenses set (with normalization)
|
||||
const hasGoodLicense = orComponents.some(component => {
|
||||
const normalized = normalizeLicense(component);
|
||||
return goodLicenses.has(component) || goodLicenses.has(normalized);
|
||||
});
|
||||
|
||||
if (hasGoodLicense) {
|
||||
return; // Skip warning - can use the good license option
|
||||
}
|
||||
}
|
||||
|
||||
// For AND licenses or OR licenses with no good options, check for problematic components
|
||||
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}`
|
||||
}));
|
||||
|
||||
const licenseType = license.includes('AND') ? 'AND' : 'OR';
|
||||
const reason = licenseType === 'AND'
|
||||
? 'Compound license with AND requirement - all components must be compatible'
|
||||
: 'Compound license with potentially problematic components and no good fallback options';
|
||||
|
||||
warnings.push({
|
||||
message: `📋 This PR contains ${count} package${count > 1 ? 's' : ''} with compound license "${license}" - manual review recommended`,
|
||||
licenseType: license,
|
||||
licenseUrl: '',
|
||||
reason: reason,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
|
||||
import { FileContextProvider } from './contexts/FileContext';
|
||||
import HomePage from './pages/HomePage';
|
||||
|
||||
// Import global styles
|
||||
import './styles/tailwind.css';
|
||||
import './index.css';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<RainbowThemeProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<HomePage />
|
||||
</FileContextProvider>
|
||||
</RainbowThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
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 {
|
||||
// Simply restore to the previous state (before deletion)
|
||||
this.setPdfDocument(this.previousState);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
@@ -0,0 +1,858 @@
|
||||
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 { useFileContext } from '../../contexts/FileContext';
|
||||
import { FileOperation } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import styles from '../pageEditor/PageEditor.module.css';
|
||||
import FileThumbnail from '../pageEditor/FileThumbnail';
|
||||
import DragDropGrid from '../pageEditor/DragDropGrid';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
|
||||
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;
|
||||
toolMode?: boolean;
|
||||
multiSelect?: boolean;
|
||||
showUpload?: boolean;
|
||||
showBulkActions?: boolean;
|
||||
onFileSelect?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
onOpenPageEditor,
|
||||
onMergeFiles,
|
||||
toolMode = false,
|
||||
multiSelect = true,
|
||||
showUpload = true,
|
||||
showBulkActions = true,
|
||||
onFileSelect
|
||||
}: FileEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get file context
|
||||
const fileContext = useFileContext();
|
||||
const {
|
||||
activeFiles,
|
||||
processedFiles,
|
||||
selectedFileIds,
|
||||
setSelectedFiles: setContextSelectedFiles,
|
||||
isProcessing,
|
||||
addFiles,
|
||||
removeFiles,
|
||||
setCurrentView,
|
||||
recordOperation,
|
||||
markOperationApplied
|
||||
} = fileContext;
|
||||
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [localLoading, setLocalLoading] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
React.useEffect(() => {
|
||||
if (toolMode) {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}, [toolMode]);
|
||||
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 [conversionProgress, setConversionProgress] = useState(0);
|
||||
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
|
||||
isExtracting: boolean;
|
||||
currentFile: string;
|
||||
progress: number;
|
||||
extractedCount: number;
|
||||
totalFiles: number;
|
||||
}>({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const lastActiveFilesRef = useRef<string[]>([]);
|
||||
const lastProcessedFilesRef = useRef<number>(0);
|
||||
|
||||
// Map context selected file names to local file IDs
|
||||
// Defensive programming: ensure selectedFileIds is always an array
|
||||
const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
|
||||
const localSelectedFiles = files
|
||||
.filter(file => {
|
||||
const fileId = (file.file as any).id || file.name;
|
||||
return safeSelectedFileIds.includes(fileId);
|
||||
})
|
||||
.map(file => file.id);
|
||||
|
||||
// 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'),
|
||||
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 using context (async to avoid blocking)
|
||||
useEffect(() => {
|
||||
// Check if the actual content has changed, not just references
|
||||
const currentActiveFileNames = activeFiles.map(f => f.name);
|
||||
const currentProcessedFilesSize = processedFiles.size;
|
||||
|
||||
const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current);
|
||||
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
|
||||
|
||||
if (!activeFilesChanged && !processedFilesChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update refs
|
||||
lastActiveFilesRef.current = currentActiveFileNames;
|
||||
lastProcessedFilesRef.current = currentProcessedFilesSize;
|
||||
|
||||
const convertActiveFiles = async () => {
|
||||
|
||||
if (activeFiles.length > 0) {
|
||||
setLocalLoading(true);
|
||||
try {
|
||||
// Process files in chunks to avoid blocking UI
|
||||
const convertedFiles: FileItem[] = [];
|
||||
|
||||
for (let i = 0; i < activeFiles.length; i++) {
|
||||
const file = activeFiles[i];
|
||||
|
||||
// Try to get thumbnail from processed file first
|
||||
const processedFile = processedFiles.get(file);
|
||||
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
|
||||
|
||||
// If no thumbnail from processed file, try to generate one
|
||||
if (!thumbnail) {
|
||||
try {
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
||||
thumbnail = undefined; // Use placeholder
|
||||
}
|
||||
}
|
||||
|
||||
const convertedFile = {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
name: file.name,
|
||||
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
|
||||
thumbnail,
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
|
||||
convertedFiles.push(convertedFile);
|
||||
|
||||
// Update progress
|
||||
setConversionProgress(((i + 1) / activeFiles.length) * 100);
|
||||
|
||||
// Yield to main thread between files
|
||||
if (i < activeFiles.length - 1) {
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setFiles(convertedFiles);
|
||||
} catch (err) {
|
||||
console.error('Error converting active files:', err);
|
||||
} finally {
|
||||
setLocalLoading(false);
|
||||
setConversionProgress(0);
|
||||
}
|
||||
} else {
|
||||
setFiles([]);
|
||||
setLocalLoading(false);
|
||||
setConversionProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
convertActiveFiles();
|
||||
}, [activeFiles, processedFiles]);
|
||||
|
||||
|
||||
// Process uploaded files using context
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const allExtractedFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
if (file.type === 'application/pdf') {
|
||||
// Handle PDF files normally
|
||||
allExtractedFiles.push(file);
|
||||
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
|
||||
// Handle ZIP files
|
||||
try {
|
||||
// Validate ZIP file first
|
||||
const validation = await zipFileService.validateZipFile(file);
|
||||
if (!validation.isValid) {
|
||||
errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract PDF files from ZIP
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: file.name,
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: validation.fileCount
|
||||
});
|
||||
|
||||
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: progress.currentFile,
|
||||
progress: progress.progress,
|
||||
extractedCount: progress.extractedCount,
|
||||
totalFiles: progress.totalFiles
|
||||
});
|
||||
});
|
||||
|
||||
// Reset extraction progress
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
// Record ZIP extraction operation
|
||||
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'convert',
|
||||
timestamp: Date.now(),
|
||||
fileIds: extractionResult.extractedFiles.map(f => f.name),
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: file.name,
|
||||
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
extractionType: 'zip',
|
||||
extractedCount: extractionResult.extractedCount,
|
||||
totalFiles: extractionResult.totalFiles
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||
}
|
||||
} catch (zipError) {
|
||||
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errors.push(`Unsupported file type: ${file.name} (${file.type})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show any errors
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('\n'));
|
||||
}
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Record upload operations for PDF files
|
||||
for (const file of allExtractedFiles) {
|
||||
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'upload',
|
||||
timestamp: Date.now(),
|
||||
fileIds: [file.name],
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: file.name,
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
uploadMethod: 'drag-drop'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
}
|
||||
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
// Reset extraction progress on error
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
}
|
||||
}, [addFiles, recordOperation, markOperationApplied]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
|
||||
}, [files, setContextSelectedFiles]);
|
||||
|
||||
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
|
||||
|
||||
const closeAllFiles = useCallback(() => {
|
||||
if (activeFiles.length === 0) return;
|
||||
|
||||
// Record close all operation for each file
|
||||
activeFiles.forEach(file => {
|
||||
const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'remove',
|
||||
timestamp: Date.now(),
|
||||
fileIds: [file.name],
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: file.name,
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
action: 'close_all',
|
||||
reason: 'user_request'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
});
|
||||
|
||||
// Remove all files from context but keep in storage
|
||||
removeFiles(activeFiles.map(f => (f as any).id || f.name), false);
|
||||
|
||||
// Clear selections
|
||||
setContextSelectedFiles([]);
|
||||
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
|
||||
|
||||
const toggleFile = useCallback((fileId: string) => {
|
||||
const targetFile = files.find(f => f.id === fileId);
|
||||
if (!targetFile) return;
|
||||
|
||||
const contextFileId = (targetFile.file as any).id || targetFile.name;
|
||||
|
||||
if (!multiSelect) {
|
||||
// Single select mode for tools - toggle on/off
|
||||
const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId);
|
||||
if (isCurrentlySelected) {
|
||||
// Deselect the file
|
||||
setContextSelectedFiles([]);
|
||||
if (onFileSelect) {
|
||||
onFileSelect([]);
|
||||
}
|
||||
} else {
|
||||
// Select the file
|
||||
setContextSelectedFiles([contextFileId]);
|
||||
if (onFileSelect) {
|
||||
onFileSelect([targetFile.file]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi select mode (default)
|
||||
setContextSelectedFiles(prev => {
|
||||
const safePrev = Array.isArray(prev) ? prev : [];
|
||||
return safePrev.includes(contextFileId)
|
||||
? safePrev.filter(id => id !== contextFileId)
|
||||
: [...safePrev, contextFileId];
|
||||
});
|
||||
|
||||
// Notify parent with selected files
|
||||
if (onFileSelect) {
|
||||
const selectedFiles = files
|
||||
.filter(f => {
|
||||
const fId = (f.file as any).id || f.name;
|
||||
return safeSelectedFileIds.includes(fId) || fId === contextFileId;
|
||||
})
|
||||
.map(f => f.file);
|
||||
onFileSelect(selectedFiles);
|
||||
}
|
||||
}
|
||||
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
const newMode = !prev;
|
||||
if (!newMode) {
|
||||
setContextSelectedFiles([]);
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, [setContextSelectedFiles]);
|
||||
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = useCallback((fileId: string) => {
|
||||
setDraggedFile(fileId);
|
||||
|
||||
if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) {
|
||||
setMultiFileDrag({
|
||||
fileIds: localSelectedFiles,
|
||||
count: localSelectedFiles.length
|
||||
});
|
||||
} else {
|
||||
setMultiFileDrag(null);
|
||||
}
|
||||
}, [selectionMode, localSelectedFiles]);
|
||||
|
||||
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 && localSelectedFiles.includes(draggedFile)
|
||||
? localSelectedFiles
|
||||
: [draggedFile];
|
||||
|
||||
// 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);
|
||||
|
||||
// TODO: Update context with reordered files (need to implement file reordering in context)
|
||||
// For now, just return the reordered local state
|
||||
return newFiles;
|
||||
});
|
||||
|
||||
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
|
||||
}, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedFile) {
|
||||
setDropTarget('end');
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: string) => {
|
||||
console.log('handleDeleteFile called with fileId:', fileId);
|
||||
const file = files.find(f => f.id === fileId);
|
||||
console.log('Found file:', file);
|
||||
|
||||
if (file) {
|
||||
console.log('Attempting to remove file:', file.name);
|
||||
console.log('Actual file object:', file.file);
|
||||
console.log('Actual file.file.name:', file.file.name);
|
||||
|
||||
// Record close operation
|
||||
const fileName = file.file.name;
|
||||
const fileId = (file.file as any).id || fileName;
|
||||
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'remove',
|
||||
timestamp: Date.now(),
|
||||
fileIds: [fileName],
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: fileName,
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
action: 'close',
|
||||
reason: 'user_request'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(fileName, operation);
|
||||
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
console.log('Calling removeFiles with:', [fileId]);
|
||||
removeFiles([fileId], false);
|
||||
|
||||
// Remove from context selections
|
||||
setContextSelectedFiles(prev => {
|
||||
const safePrev = Array.isArray(prev) ? prev : [];
|
||||
return safePrev.filter(id => id !== fileId);
|
||||
});
|
||||
|
||||
// Mark operation as applied
|
||||
markOperationApplied(fileName, operationId);
|
||||
} else {
|
||||
console.log('File not found for fileId:', fileId);
|
||||
}
|
||||
}, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file) {
|
||||
// Set the file as selected in context and switch to page editor view
|
||||
const contextFileId = (file.file as any).id || file.name;
|
||||
setContextSelectedFiles([contextFileId]);
|
||||
setCurrentView('pageEditor');
|
||||
onOpenPageEditor?.(file.file);
|
||||
}
|
||||
}, [files, setContextSelectedFiles, setCurrentView, 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;
|
||||
|
||||
setLocalLoading(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 {
|
||||
setLocalLoading(false);
|
||||
}
|
||||
}, [convertToFileItem]);
|
||||
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
{showBulkActions && !toolMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
<Button onClick={closeAllFiles} variant="light" color="orange">
|
||||
Close All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Load from storage and upload buttons */}
|
||||
{showUpload && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="blue"
|
||||
onClick={() => setShowFilePickerModal(true)}
|
||||
>
|
||||
Load from Storage
|
||||
</Button>
|
||||
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{ display: 'contents' }}
|
||||
>
|
||||
<Button variant="outline" color="green">
|
||||
Upload Files
|
||||
</Button>
|
||||
</Dropzone>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
|
||||
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
<Text c="dimmed">No files loaded</Text>
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
|
||||
<Box>
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
{/* ZIP Extraction Progress */}
|
||||
{zipExtractionProgress.isExtracting && (
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
|
||||
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{zipExtractionProgress.currentFile || 'Processing files...'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
|
||||
</Text>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round(zipExtractionProgress.progress)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-orange-6)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Processing indicator */}
|
||||
{localLoading && (
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={500}>Loading files...</Text>
|
||||
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
|
||||
</Group>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round(conversionProgress)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
) : (
|
||||
<DragDropGrid
|
||||
items={files}
|
||||
selectedItems={localSelectedFiles}
|
||||
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={localSelectedFiles}
|
||||
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}
|
||||
toolMode={toolMode}
|
||||
/>
|
||||
)}
|
||||
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;
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import { StorageStats } from "../../services/fileStorage";
|
||||
import { formatFileSize } from "../../utils/fileUtils";
|
||||
import { getStorageUsagePercent } from "../../utils/storageUtils";
|
||||
import { StorageConfig } from "../../types/file";
|
||||
|
||||
interface StorageStatsCardProps {
|
||||
storageStats: StorageStats | null;
|
||||
filesCount: number;
|
||||
onClearAll: () => void;
|
||||
onReloadFiles: () => void;
|
||||
storageConfig: StorageConfig;
|
||||
}
|
||||
|
||||
const StorageStatsCard = ({
|
||||
storageStats,
|
||||
filesCount,
|
||||
onClearAll,
|
||||
onReloadFiles,
|
||||
storageConfig,
|
||||
}: StorageStatsCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!storageStats) return null;
|
||||
|
||||
const storageUsagePercent = getStorageUsagePercent(storageStats);
|
||||
const totalUsed = storageStats.totalSize || storageStats.used;
|
||||
const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
|
||||
const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
|
||||
|
||||
return (
|
||||
<Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
|
||||
<Card withBorder p="sm">
|
||||
<Group align="center" gap="md">
|
||||
<StorageIcon />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
|
||||
</Text>
|
||||
<Progress
|
||||
value={hardLimitPercent}
|
||||
color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
|
||||
size="sm"
|
||||
mt={4}
|
||||
/>
|
||||
<Group justify="space-between" mt={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{storageStats.fileCount} files • {t("storage.approximateSize", "Approximate size")}
|
||||
</Text>
|
||||
<Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
|
||||
{Math.round(hardLimitPercent)}% used
|
||||
</Text>
|
||||
</Group>
|
||||
{isNearLimit && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
{filesCount > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={onClearAll}
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t("fileManager.clearAll", "Clear All")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onReloadFiles}
|
||||
>
|
||||
{t("fileManager.reloadFiles", "Reload Files")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageStatsCard;
|
||||
@@ -0,0 +1,177 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Badge,
|
||||
Group,
|
||||
Collapse,
|
||||
Box,
|
||||
ScrollArea,
|
||||
Code,
|
||||
Divider
|
||||
} from '@mantine/core';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
|
||||
import { PageOperation } from '../../types/pageEditor';
|
||||
|
||||
interface FileOperationHistoryProps {
|
||||
fileId: string;
|
||||
showOnlyApplied?: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
|
||||
fileId,
|
||||
showOnlyApplied = false,
|
||||
maxHeight = 400
|
||||
}) => {
|
||||
const { getFileHistory, getAppliedOperations } = useFileContext();
|
||||
|
||||
const history = getFileHistory(fileId);
|
||||
const operations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const getOperationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'split': return '✂️';
|
||||
case 'merge': return '🔗';
|
||||
case 'compress': return '🗜️';
|
||||
case 'rotate': return '🔄';
|
||||
case 'delete': return '🗑️';
|
||||
case 'move': return '↕️';
|
||||
case 'insert': return '📄';
|
||||
case 'upload': return '⬆️';
|
||||
case 'add': return '➕';
|
||||
case 'remove': return '➖';
|
||||
case 'replace': return '🔄';
|
||||
case 'convert': return '🔄';
|
||||
default: return '⚙️';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'applied': return 'green';
|
||||
case 'failed': return 'red';
|
||||
case 'pending': return 'yellow';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const renderOperationDetails = (operation: FileOperation | PageOperation) => {
|
||||
if ('metadata' in operation && operation.metadata) {
|
||||
const { metadata } = operation;
|
||||
return (
|
||||
<Box mt="xs">
|
||||
{metadata.parameters && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Parameters: <Code>{JSON.stringify(metadata.parameters, null, 2)}</Code>
|
||||
</Text>
|
||||
)}
|
||||
{metadata.originalFileName && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Original file: {metadata.originalFileName}
|
||||
</Text>
|
||||
)}
|
||||
{metadata.outputFileNames && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Output files: {metadata.outputFileNames.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
{metadata.fileSize && (
|
||||
<Text size="xs" c="dimmed">
|
||||
File size: {(metadata.fileSize / 1024 / 1024).toFixed(2)} MB
|
||||
</Text>
|
||||
)}
|
||||
{metadata.pageCount && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Pages: {metadata.pageCount}
|
||||
</Text>
|
||||
)}
|
||||
{metadata.error && (
|
||||
<Text size="xs" c="red">
|
||||
Error: {metadata.error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!history || operations.length === 0) {
|
||||
return (
|
||||
<Paper p="md" withBorder>
|
||||
<Text c="dimmed" ta="center">
|
||||
{showOnlyApplied ? 'No applied operations found' : 'No operation history available'}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={500}>
|
||||
{showOnlyApplied ? 'Applied Operations' : 'Operation History'}
|
||||
</Text>
|
||||
<Badge variant="light" color="blue">
|
||||
{operations.length} operations
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<ScrollArea h={maxHeight}>
|
||||
<Stack gap="sm">
|
||||
{operations.map((operation, index) => (
|
||||
<Paper key={operation.id} p="sm" withBorder radius="sm" bg="gray.0">
|
||||
<Group justify="space-between" align="start">
|
||||
<Group gap="xs">
|
||||
<Text span size="lg">
|
||||
{getOperationIcon(operation.type)}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text fw={500} size="sm">
|
||||
{operation.type.charAt(0).toUpperCase() + operation.type.slice(1)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatTimestamp(operation.timestamp)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Badge
|
||||
variant="filled"
|
||||
color={getStatusColor(operation.status)}
|
||||
size="sm"
|
||||
>
|
||||
{operation.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{renderOperationDetails(operation)}
|
||||
|
||||
{index < operations.length - 1 && <Divider mt="sm" />}
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
{history && (
|
||||
<Group justify="space-between" mt="sm" pt="sm" style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Created: {formatTimestamp(history.createdAt)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Last modified: {formatTimestamp(history.lastModified)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileOperationHistory;
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
|
||||
|
||||
interface BulkSelectionPanelProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPages: number[];
|
||||
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;
|
||||
@@ -0,0 +1,137 @@
|
||||
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: number[];
|
||||
selectionMode: boolean;
|
||||
isAnimating: boolean;
|
||||
onDragStart: (pageNumber: number) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (pageNumber: number) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, targetPageNumber: number | '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: number | null;
|
||||
dropTarget: number | null;
|
||||
multiItemDrag: {pageNumbers: number[], 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',
|
||||
// Performance optimizations for smooth scrolling
|
||||
willChange: 'scroll-position',
|
||||
transform: 'translateZ(0)', // Force hardware acceleration
|
||||
backfaceVisibility: 'hidden',
|
||||
// Use containment for better rendering performance
|
||||
contain: 'layout style paint',
|
||||
}}
|
||||
>
|
||||
{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;
|
||||
@@ -0,0 +1,332 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import styles from './PageEditor.module.css';
|
||||
import FileOperationHistory from '../history/FileOperationHistory';
|
||||
|
||||
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;
|
||||
onSetStatus: (status: string) => void;
|
||||
toolMode?: boolean;
|
||||
}
|
||||
|
||||
const FileThumbnail = ({
|
||||
file,
|
||||
index,
|
||||
totalFiles,
|
||||
selectedFiles,
|
||||
selectionMode,
|
||||
draggedFile,
|
||||
dropTarget,
|
||||
isAnimating,
|
||||
fileRefs,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onViewFile,
|
||||
onSetStatus,
|
||||
toolMode = false,
|
||||
}: FileThumbnailProps) => {
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
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'
|
||||
}}
|
||||
>
|
||||
{!toolMode && (
|
||||
<>
|
||||
<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="View History">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowHistory(true);
|
||||
onSetStatus(`Viewing history for ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<HistoryIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Close File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="orange"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Closed ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<CloseIcon 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>
|
||||
|
||||
{/* History Modal */}
|
||||
<Modal
|
||||
opened={showHistory}
|
||||
onClose={() => setShowHistory(false)}
|
||||
title={`Operation History - ${file.name}`}
|
||||
size="lg"
|
||||
scrollAreaComponent="div"
|
||||
>
|
||||
<FileOperationHistory
|
||||
fileId={file.name}
|
||||
showOnlyApplied={true}
|
||||
maxHeight={500}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileThumbnail;
|
||||
@@ -0,0 +1,67 @@
|
||||
/* Page container hover effects - optimized for smooth scrolling */
|
||||
.pageContainer {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
/* Enable hardware acceleration for smoother scrolling */
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.pageContainer:hover {
|
||||
transform: scale(1.02) translateZ(0);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user