Commit ·
32a841c
0
Parent(s):
PDF Tools Web App - compress, convert, watermark removal
Browse files- .env.example +24 -0
- .gitignore +32 -0
- Procfile +1 -0
- requirements.txt +25 -0
- runtime.txt +1 -0
- server.py +0 -0
- static/css/styles.css +2175 -0
- static/index.html +0 -0
- static/js/app.js +298 -0
- static/js/image-scraper.js +353 -0
- static/js/image-tools.js +956 -0
- static/js/pdf-editor.js +557 -0
- static/js/pdf-tools.js +1052 -0
- static/js/utils.js +571 -0
- watermark_remover.py +251 -0
.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LocalTools Configuration
|
| 2 |
+
# Copy this file to .env and customize as needed
|
| 3 |
+
|
| 4 |
+
# Debug mode (enables detailed logging)
|
| 5 |
+
LOCALTOOLS_DEBUG=false
|
| 6 |
+
|
| 7 |
+
# Directory paths (relative to server.py)
|
| 8 |
+
LOCALTOOLS_WORK_DIR=work
|
| 9 |
+
LOCALTOOLS_OUTPUT_DIR=outputs
|
| 10 |
+
LOCALTOOLS_STATIC_DIR=static
|
| 11 |
+
|
| 12 |
+
# File size limits (in MB)
|
| 13 |
+
LOCALTOOLS_MAX_PDF_SIZE_MB=100
|
| 14 |
+
LOCALTOOLS_MAX_IMAGE_SIZE_MB=50
|
| 15 |
+
|
| 16 |
+
# Minimum image dimension (filters out icons)
|
| 17 |
+
LOCALTOOLS_MIN_IMAGE_DIMENSION=50
|
| 18 |
+
|
| 19 |
+
# Request timeout (seconds)
|
| 20 |
+
LOCALTOOLS_REQUEST_TIMEOUT=60
|
| 21 |
+
|
| 22 |
+
# Cleanup settings
|
| 23 |
+
LOCALTOOLS_CLEANUP_INTERVAL_HOURS=24
|
| 24 |
+
LOCALTOOLS_FILE_RETENTION_HOURS=48
|
.gitignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
.env
|
| 10 |
+
|
| 11 |
+
# Work directories (uploaded/processed files)
|
| 12 |
+
work/
|
| 13 |
+
outputs/
|
| 14 |
+
|
| 15 |
+
# IDE
|
| 16 |
+
.vscode/
|
| 17 |
+
.idea/
|
| 18 |
+
|
| 19 |
+
# OS
|
| 20 |
+
.DS_Store
|
| 21 |
+
Thumbs.db
|
| 22 |
+
|
| 23 |
+
# Logs
|
| 24 |
+
*.log
|
| 25 |
+
|
| 26 |
+
# Tesseract/DLLs (not needed for deployment)
|
| 27 |
+
*.exe
|
| 28 |
+
*.dll
|
| 29 |
+
tessdata/
|
| 30 |
+
doc/
|
| 31 |
+
*.html
|
| 32 |
+
!static/**/*.html
|
Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: uvicorn server:app --host 0.0.0.0 --port ${PORT:-8000}
|
requirements.txt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LocalTools Dependencies
|
| 2 |
+
|
| 3 |
+
# Web Framework
|
| 4 |
+
fastapi>=0.104.0
|
| 5 |
+
uvicorn[standard]>=0.24.0
|
| 6 |
+
|
| 7 |
+
# Async HTTP
|
| 8 |
+
aiohttp>=3.9.0
|
| 9 |
+
|
| 10 |
+
# PDF Processing
|
| 11 |
+
PyMuPDF>=1.23.0
|
| 12 |
+
|
| 13 |
+
# Image Processing
|
| 14 |
+
Pillow>=10.0.0
|
| 15 |
+
opencv-python-headless>=4.8.0
|
| 16 |
+
numpy>=1.24.0
|
| 17 |
+
|
| 18 |
+
# Settings Management
|
| 19 |
+
pydantic-settings>=2.0.0
|
| 20 |
+
|
| 21 |
+
# OCR (requires Tesseract installed on system)
|
| 22 |
+
# pytesseract>=0.3.10
|
| 23 |
+
|
| 24 |
+
# AI Background Removal (optional - large dependency)
|
| 25 |
+
# rembg>=2.0.0
|
runtime.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
python-3.11.7
|
server.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/css/styles.css
ADDED
|
@@ -0,0 +1,2175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =============== CSS Variables - White Theme with Light Green =============== */
|
| 2 |
+
:root {
|
| 3 |
+
--bg: #ffffff;
|
| 4 |
+
--card: #ffffff;
|
| 5 |
+
--card-solid: #ffffff;
|
| 6 |
+
--card-hover: #fafafa;
|
| 7 |
+
--text: #1a1a1a;
|
| 8 |
+
--text-secondary: #555555;
|
| 9 |
+
--text-muted: #888888;
|
| 10 |
+
--border: #e0e0e0;
|
| 11 |
+
--border-light: #f0f0f0;
|
| 12 |
+
--primary: #8bc34a;
|
| 13 |
+
--primary-hover: #7cb342;
|
| 14 |
+
--primary-light: rgba(139, 195, 74, 0.12);
|
| 15 |
+
--primary-glow: rgba(139, 195, 74, 0.25);
|
| 16 |
+
--success: #4caf50;
|
| 17 |
+
--success-light: rgba(76, 175, 80, 0.12);
|
| 18 |
+
--error: #e53935;
|
| 19 |
+
--error-light: rgba(229, 57, 53, 0.1);
|
| 20 |
+
--warning: #ffc107;
|
| 21 |
+
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
|
| 22 |
+
--shadow: 0 2px 8px rgba(0,0,0,0.08);
|
| 23 |
+
--shadow-lg: 0 4px 16px rgba(0,0,0,0.12);
|
| 24 |
+
--radius: 12px;
|
| 25 |
+
--radius-sm: 8px;
|
| 26 |
+
--radius-lg: 16px;
|
| 27 |
+
--sidebar-width: 260px;
|
| 28 |
+
--transition: 0.2s ease;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* =============== Dark Theme =============== */
|
| 32 |
+
[data-theme="dark"] {
|
| 33 |
+
--bg: #121212;
|
| 34 |
+
--card: #1e1e1e;
|
| 35 |
+
--card-solid: #1e1e1e;
|
| 36 |
+
--card-hover: #252525;
|
| 37 |
+
--text: #f5f5f5;
|
| 38 |
+
--text-secondary: #b0b0b0;
|
| 39 |
+
--text-muted: #808080;
|
| 40 |
+
--border: #333333;
|
| 41 |
+
--border-light: #2a2a2a;
|
| 42 |
+
--primary: #aed581;
|
| 43 |
+
--primary-hover: #8bc34a;
|
| 44 |
+
--primary-light: rgba(174, 213, 129, 0.15);
|
| 45 |
+
--primary-glow: rgba(174, 213, 129, 0.3);
|
| 46 |
+
--shadow: 0 2px 8px rgba(0,0,0,0.3);
|
| 47 |
+
--shadow-lg: 0 4px 16px rgba(0,0,0,0.4);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
/* =============== Reset & Base =============== */
|
| 52 |
+
* {
|
| 53 |
+
box-sizing: border-box;
|
| 54 |
+
margin: 0;
|
| 55 |
+
padding: 0;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
html {
|
| 59 |
+
scroll-behavior: smooth;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
body {
|
| 63 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 64 |
+
background: var(--bg);
|
| 65 |
+
color: var(--text);
|
| 66 |
+
line-height: 1.6;
|
| 67 |
+
min-height: 100vh;
|
| 68 |
+
overflow-x: hidden;
|
| 69 |
+
transition: background var(--transition), color var(--transition);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/* =============== App Layout =============== */
|
| 73 |
+
.app-container {
|
| 74 |
+
display: flex;
|
| 75 |
+
min-height: 100vh;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* =============== Animated Background (disabled for white theme) =============== */
|
| 79 |
+
.animated-bg {
|
| 80 |
+
position: fixed;
|
| 81 |
+
inset: 0;
|
| 82 |
+
z-index: -1;
|
| 83 |
+
background: var(--bg);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/* =============== Sidebar Navigation =============== */
|
| 87 |
+
.sidebar {
|
| 88 |
+
width: var(--sidebar-width);
|
| 89 |
+
height: 100vh;
|
| 90 |
+
position: fixed;
|
| 91 |
+
left: 0;
|
| 92 |
+
top: 0;
|
| 93 |
+
background: var(--card);
|
| 94 |
+
border-right: 1px solid var(--border);
|
| 95 |
+
display: flex;
|
| 96 |
+
flex-direction: column;
|
| 97 |
+
z-index: 100;
|
| 98 |
+
transition: transform var(--transition), background var(--transition);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.sidebar-header {
|
| 102 |
+
padding: 24px 20px;
|
| 103 |
+
border-bottom: 1px solid var(--border-light);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.logo {
|
| 107 |
+
display: flex;
|
| 108 |
+
align-items: center;
|
| 109 |
+
gap: 12px;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.logo-icon {
|
| 113 |
+
width: 44px;
|
| 114 |
+
height: 44px;
|
| 115 |
+
background: var(--primary);
|
| 116 |
+
border-radius: 10px;
|
| 117 |
+
display: flex;
|
| 118 |
+
align-items: center;
|
| 119 |
+
justify-content: center;
|
| 120 |
+
color: white;
|
| 121 |
+
font-weight: 700;
|
| 122 |
+
font-size: 18px;
|
| 123 |
+
transition: transform var(--transition);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.logo:hover .logo-icon {
|
| 127 |
+
transform: scale(1.05);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
.logo-text h1 {
|
| 132 |
+
font-size: 18px;
|
| 133 |
+
font-weight: 700;
|
| 134 |
+
color: var(--text);
|
| 135 |
+
letter-spacing: -0.5px;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.logo-text span {
|
| 139 |
+
font-size: 11px;
|
| 140 |
+
color: var(--text-muted);
|
| 141 |
+
text-transform: uppercase;
|
| 142 |
+
letter-spacing: 0.5px;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/* Sidebar Navigation */
|
| 146 |
+
.sidebar-nav {
|
| 147 |
+
flex: 1;
|
| 148 |
+
padding: 16px 12px;
|
| 149 |
+
overflow-y: auto;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.nav-section {
|
| 153 |
+
margin-bottom: 24px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.nav-section-title {
|
| 157 |
+
font-size: 10px;
|
| 158 |
+
font-weight: 600;
|
| 159 |
+
color: var(--text-muted);
|
| 160 |
+
text-transform: uppercase;
|
| 161 |
+
letter-spacing: 1px;
|
| 162 |
+
padding: 0 12px;
|
| 163 |
+
margin-bottom: 8px;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.nav-item {
|
| 167 |
+
display: flex;
|
| 168 |
+
align-items: center;
|
| 169 |
+
gap: 12px;
|
| 170 |
+
padding: 12px 16px;
|
| 171 |
+
border-radius: var(--radius-sm);
|
| 172 |
+
color: var(--text-secondary);
|
| 173 |
+
font-size: 14px;
|
| 174 |
+
font-weight: 500;
|
| 175 |
+
cursor: pointer;
|
| 176 |
+
border: none;
|
| 177 |
+
background: transparent;
|
| 178 |
+
width: 100%;
|
| 179 |
+
text-align: left;
|
| 180 |
+
transition: all var(--transition);
|
| 181 |
+
position: relative;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.nav-item:hover {
|
| 185 |
+
color: var(--text);
|
| 186 |
+
background: var(--primary-light);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.nav-item.active {
|
| 190 |
+
color: var(--primary);
|
| 191 |
+
background: var(--primary-light);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.nav-item.active::after {
|
| 195 |
+
content: '';
|
| 196 |
+
position: absolute;
|
| 197 |
+
left: 0;
|
| 198 |
+
top: 50%;
|
| 199 |
+
transform: translateY(-50%);
|
| 200 |
+
width: 3px;
|
| 201 |
+
height: 24px;
|
| 202 |
+
background: var(--primary);
|
| 203 |
+
border-radius: 0 3px 3px 0;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.nav-item svg {
|
| 207 |
+
width: 20px;
|
| 208 |
+
height: 20px;
|
| 209 |
+
flex-shrink: 0;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/* Sidebar Footer */
|
| 213 |
+
.sidebar-footer {
|
| 214 |
+
padding: 16px;
|
| 215 |
+
border-top: 1px solid var(--border-light);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
.theme-toggle {
|
| 220 |
+
display: flex;
|
| 221 |
+
align-items: center;
|
| 222 |
+
justify-content: space-between;
|
| 223 |
+
padding: 12px 16px;
|
| 224 |
+
background: var(--border-light);
|
| 225 |
+
border-radius: var(--radius-sm);
|
| 226 |
+
cursor: pointer;
|
| 227 |
+
transition: background var(--transition);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.theme-toggle:hover {
|
| 231 |
+
background: var(--border);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.theme-toggle-label {
|
| 235 |
+
display: flex;
|
| 236 |
+
align-items: center;
|
| 237 |
+
gap: 10px;
|
| 238 |
+
font-size: 13px;
|
| 239 |
+
color: var(--text-secondary);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.theme-toggle-label svg {
|
| 243 |
+
width: 18px;
|
| 244 |
+
height: 18px;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.theme-switch {
|
| 248 |
+
width: 44px;
|
| 249 |
+
height: 24px;
|
| 250 |
+
background: var(--border);
|
| 251 |
+
border-radius: 12px;
|
| 252 |
+
position: relative;
|
| 253 |
+
transition: background var(--transition);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.theme-switch::after {
|
| 257 |
+
content: '';
|
| 258 |
+
position: absolute;
|
| 259 |
+
width: 18px;
|
| 260 |
+
height: 18px;
|
| 261 |
+
background: white;
|
| 262 |
+
border-radius: 50%;
|
| 263 |
+
top: 3px;
|
| 264 |
+
left: 3px;
|
| 265 |
+
transition: transform var(--transition);
|
| 266 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
[data-theme="dark"] .theme-switch {
|
| 270 |
+
background: var(--primary);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
[data-theme="dark"] .theme-switch::after {
|
| 274 |
+
transform: translateX(20px);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* =============== Main Content =============== */
|
| 278 |
+
.main-content {
|
| 279 |
+
flex: 1;
|
| 280 |
+
margin-left: var(--sidebar-width);
|
| 281 |
+
min-height: 100vh;
|
| 282 |
+
padding: 24px 32px;
|
| 283 |
+
transition: margin var(--transition);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/* =============== Breadcrumbs =============== */
|
| 287 |
+
.breadcrumbs {
|
| 288 |
+
display: flex;
|
| 289 |
+
align-items: center;
|
| 290 |
+
gap: 8px;
|
| 291 |
+
margin-bottom: 20px;
|
| 292 |
+
font-size: 13px;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.breadcrumb-item {
|
| 296 |
+
color: var(--text-muted);
|
| 297 |
+
display: flex;
|
| 298 |
+
align-items: center;
|
| 299 |
+
gap: 8px;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.breadcrumb-item a {
|
| 303 |
+
color: var(--text-secondary);
|
| 304 |
+
text-decoration: none;
|
| 305 |
+
transition: color var(--transition);
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.breadcrumb-item a:hover {
|
| 309 |
+
color: var(--primary);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.breadcrumb-item.active {
|
| 313 |
+
color: var(--text);
|
| 314 |
+
font-weight: 500;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.breadcrumb-separator {
|
| 318 |
+
color: var(--text-muted);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
/* =============== Page Header =============== */
|
| 323 |
+
.page-header {
|
| 324 |
+
margin-bottom: 28px;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.page-header h2 {
|
| 328 |
+
font-size: 28px;
|
| 329 |
+
font-weight: 700;
|
| 330 |
+
margin-bottom: 8px;
|
| 331 |
+
letter-spacing: -0.5px;
|
| 332 |
+
color: var(--text);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.page-header p {
|
| 336 |
+
color: var(--text-secondary);
|
| 337 |
+
font-size: 15px;
|
| 338 |
+
max-width: 600px;
|
| 339 |
+
line-height: 1.7;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
/* =============== Pages =============== */
|
| 343 |
+
.page {
|
| 344 |
+
display: none;
|
| 345 |
+
animation: pageIn 0.3s ease;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.page.active {
|
| 349 |
+
display: block;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
@keyframes pageIn {
|
| 353 |
+
from {
|
| 354 |
+
opacity: 0;
|
| 355 |
+
transform: translateY(10px);
|
| 356 |
+
}
|
| 357 |
+
to {
|
| 358 |
+
opacity: 1;
|
| 359 |
+
transform: translateY(0);
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
/* =============== Cards =============== */
|
| 364 |
+
.card {
|
| 365 |
+
background: var(--card);
|
| 366 |
+
border: 1px solid var(--border);
|
| 367 |
+
border-radius: var(--radius-lg);
|
| 368 |
+
box-shadow: var(--shadow-sm);
|
| 369 |
+
overflow: hidden;
|
| 370 |
+
transition: transform var(--transition), box-shadow var(--transition);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.card:hover {
|
| 374 |
+
box-shadow: var(--shadow);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.card + .card {
|
| 378 |
+
margin-top: 20px;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.card-header {
|
| 382 |
+
padding: 20px 24px;
|
| 383 |
+
border-bottom: 1px solid var(--border-light);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.card-header h3 {
|
| 387 |
+
font-size: 16px;
|
| 388 |
+
font-weight: 600;
|
| 389 |
+
margin-bottom: 4px;
|
| 390 |
+
display: flex;
|
| 391 |
+
align-items: center;
|
| 392 |
+
gap: 10px;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.card-header h3 svg {
|
| 396 |
+
width: 20px;
|
| 397 |
+
height: 20px;
|
| 398 |
+
color: var(--primary);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.card-header p {
|
| 402 |
+
font-size: 13px;
|
| 403 |
+
color: var(--text-secondary);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.card-body {
|
| 407 |
+
padding: 24px;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
/* =============== Form Elements =============== */
|
| 412 |
+
.form-group {
|
| 413 |
+
margin-bottom: 20px;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.form-group:last-child {
|
| 417 |
+
margin-bottom: 0;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.form-label {
|
| 421 |
+
display: block;
|
| 422 |
+
font-size: 13px;
|
| 423 |
+
font-weight: 600;
|
| 424 |
+
color: var(--text);
|
| 425 |
+
margin-bottom: 8px;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.form-label .optional {
|
| 429 |
+
color: var(--text-muted);
|
| 430 |
+
font-weight: 400;
|
| 431 |
+
font-size: 12px;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.form-input, .form-select {
|
| 435 |
+
width: 100%;
|
| 436 |
+
padding: 12px 16px;
|
| 437 |
+
border: 1px solid var(--border);
|
| 438 |
+
border-radius: var(--radius-sm);
|
| 439 |
+
font-size: 14px;
|
| 440 |
+
color: var(--text);
|
| 441 |
+
background: var(--card-solid);
|
| 442 |
+
transition: all var(--transition);
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
.form-input:focus, .form-select:focus {
|
| 446 |
+
outline: none;
|
| 447 |
+
border-color: var(--primary);
|
| 448 |
+
box-shadow: 0 0 0 3px var(--primary-light);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.form-input::placeholder {
|
| 452 |
+
color: var(--text-muted);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
/* =============== Grid Layouts =============== */
|
| 456 |
+
.grid-2 {
|
| 457 |
+
display: grid;
|
| 458 |
+
grid-template-columns: repeat(2, 1fr);
|
| 459 |
+
gap: 16px;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.grid-3 {
|
| 463 |
+
display: grid;
|
| 464 |
+
grid-template-columns: repeat(3, 1fr);
|
| 465 |
+
gap: 16px;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.grid-4 {
|
| 469 |
+
display: grid;
|
| 470 |
+
grid-template-columns: repeat(4, 1fr);
|
| 471 |
+
gap: 12px;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
@media (max-width: 768px) {
|
| 475 |
+
.grid-2, .grid-3, .grid-4 {
|
| 476 |
+
grid-template-columns: 1fr;
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
/* =============== Buttons =============== */
|
| 481 |
+
.btn {
|
| 482 |
+
display: inline-flex;
|
| 483 |
+
align-items: center;
|
| 484 |
+
justify-content: center;
|
| 485 |
+
gap: 8px;
|
| 486 |
+
padding: 12px 24px;
|
| 487 |
+
border: none;
|
| 488 |
+
border-radius: var(--radius-sm);
|
| 489 |
+
font-size: 14px;
|
| 490 |
+
font-weight: 600;
|
| 491 |
+
cursor: pointer;
|
| 492 |
+
transition: all var(--transition);
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
.btn-primary {
|
| 496 |
+
background: var(--primary);
|
| 497 |
+
color: white;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.btn-primary:hover {
|
| 501 |
+
background: var(--primary-hover);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.btn-secondary {
|
| 505 |
+
background: var(--card-solid);
|
| 506 |
+
color: var(--text);
|
| 507 |
+
border: 1px solid var(--border);
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
.btn-secondary:hover {
|
| 511 |
+
background: var(--card-hover);
|
| 512 |
+
border-color: var(--primary);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.btn-success {
|
| 516 |
+
background: var(--success);
|
| 517 |
+
color: white;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.btn-success:hover {
|
| 521 |
+
opacity: 0.9;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.btn:disabled {
|
| 525 |
+
opacity: 0.5;
|
| 526 |
+
cursor: not-allowed;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
.btn svg {
|
| 530 |
+
width: 18px;
|
| 531 |
+
height: 18px;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
.btn-group {
|
| 535 |
+
display: flex;
|
| 536 |
+
gap: 12px;
|
| 537 |
+
flex-wrap: wrap;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
/* =============== Status Messages =============== */
|
| 542 |
+
.status {
|
| 543 |
+
padding: 14px 18px;
|
| 544 |
+
border-radius: var(--radius-sm);
|
| 545 |
+
font-size: 14px;
|
| 546 |
+
margin-top: 16px;
|
| 547 |
+
display: flex;
|
| 548 |
+
align-items: center;
|
| 549 |
+
gap: 12px;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
.status-info {
|
| 553 |
+
background: var(--primary-light);
|
| 554 |
+
color: var(--primary);
|
| 555 |
+
border: 1px solid rgba(139, 195, 74, 0.25);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.status-success {
|
| 559 |
+
background: var(--success-light);
|
| 560 |
+
color: var(--success);
|
| 561 |
+
border: 1px solid rgba(76, 175, 80, 0.25);
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
.status-error {
|
| 565 |
+
background: var(--error-light);
|
| 566 |
+
color: var(--error);
|
| 567 |
+
border: 1px solid rgba(229, 57, 53, 0.25);
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
/* =============== Split View Preview =============== */
|
| 571 |
+
.preview-container {
|
| 572 |
+
position: relative;
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
.preview-toolbar {
|
| 576 |
+
display: flex;
|
| 577 |
+
align-items: center;
|
| 578 |
+
justify-content: space-between;
|
| 579 |
+
margin-bottom: 16px;
|
| 580 |
+
flex-wrap: wrap;
|
| 581 |
+
gap: 12px;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.preview-tabs {
|
| 585 |
+
display: flex;
|
| 586 |
+
gap: 8px;
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
.preview-tab {
|
| 590 |
+
padding: 10px 20px;
|
| 591 |
+
border: 1px solid var(--border);
|
| 592 |
+
background: var(--card-solid);
|
| 593 |
+
color: var(--text-secondary);
|
| 594 |
+
font-size: 13px;
|
| 595 |
+
font-weight: 500;
|
| 596 |
+
cursor: pointer;
|
| 597 |
+
border-radius: var(--radius-sm);
|
| 598 |
+
transition: all var(--transition);
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
.preview-tab:hover {
|
| 602 |
+
border-color: var(--primary);
|
| 603 |
+
color: var(--text);
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.preview-tab.active {
|
| 607 |
+
background: var(--primary);
|
| 608 |
+
color: white;
|
| 609 |
+
border-color: var(--primary);
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
.preview-actions {
|
| 613 |
+
display: flex;
|
| 614 |
+
gap: 8px;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.preview-action-btn {
|
| 618 |
+
width: 40px;
|
| 619 |
+
height: 40px;
|
| 620 |
+
border: 1px solid var(--border);
|
| 621 |
+
background: var(--card-solid);
|
| 622 |
+
border-radius: var(--radius-sm);
|
| 623 |
+
cursor: pointer;
|
| 624 |
+
display: flex;
|
| 625 |
+
align-items: center;
|
| 626 |
+
justify-content: center;
|
| 627 |
+
color: var(--text-secondary);
|
| 628 |
+
transition: all var(--transition);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.preview-action-btn:hover {
|
| 632 |
+
border-color: var(--primary);
|
| 633 |
+
color: var(--primary);
|
| 634 |
+
background: var(--primary-light);
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.preview-action-btn.active {
|
| 638 |
+
background: var(--primary);
|
| 639 |
+
color: white;
|
| 640 |
+
border-color: var(--primary);
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.preview-action-btn svg {
|
| 644 |
+
width: 18px;
|
| 645 |
+
height: 18px;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
/* Single Preview Panel */
|
| 650 |
+
.preview-panel {
|
| 651 |
+
display: none;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.preview-panel.active {
|
| 655 |
+
display: block;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.preview-frame {
|
| 659 |
+
width: 100%;
|
| 660 |
+
height: 500px;
|
| 661 |
+
border: 1px solid var(--border);
|
| 662 |
+
border-radius: var(--radius);
|
| 663 |
+
background: var(--card-solid);
|
| 664 |
+
overflow: hidden;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.preview-frame iframe {
|
| 668 |
+
width: 100%;
|
| 669 |
+
height: 100%;
|
| 670 |
+
border: none;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
/* Split View */
|
| 674 |
+
.split-view {
|
| 675 |
+
display: none;
|
| 676 |
+
gap: 16px;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
.split-view.active {
|
| 680 |
+
display: grid;
|
| 681 |
+
grid-template-columns: 1fr 1fr;
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
.split-panel {
|
| 685 |
+
border: 1px solid var(--border);
|
| 686 |
+
border-radius: var(--radius);
|
| 687 |
+
overflow: hidden;
|
| 688 |
+
background: var(--card-solid);
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
.split-panel-header {
|
| 692 |
+
padding: 12px 16px;
|
| 693 |
+
background: var(--border-light);
|
| 694 |
+
font-size: 13px;
|
| 695 |
+
font-weight: 600;
|
| 696 |
+
color: var(--text-secondary);
|
| 697 |
+
border-bottom: 1px solid var(--border-light);
|
| 698 |
+
display: flex;
|
| 699 |
+
align-items: center;
|
| 700 |
+
gap: 8px;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
.split-panel-header .dot {
|
| 704 |
+
width: 8px;
|
| 705 |
+
height: 8px;
|
| 706 |
+
border-radius: 50%;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
.split-panel-header .dot.before {
|
| 710 |
+
background: var(--text-muted);
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
.split-panel-header .dot.after {
|
| 714 |
+
background: var(--success);
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.split-panel iframe {
|
| 718 |
+
width: 100%;
|
| 719 |
+
height: 450px;
|
| 720 |
+
border: none;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
/* Fullscreen Preview */
|
| 724 |
+
.fullscreen-overlay {
|
| 725 |
+
position: fixed;
|
| 726 |
+
inset: 0;
|
| 727 |
+
background: var(--bg);
|
| 728 |
+
z-index: 1000;
|
| 729 |
+
display: none;
|
| 730 |
+
flex-direction: column;
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
.fullscreen-overlay.active {
|
| 734 |
+
display: flex;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.fullscreen-header {
|
| 738 |
+
padding: 16px 24px;
|
| 739 |
+
background: var(--card);
|
| 740 |
+
border-bottom: 1px solid var(--border);
|
| 741 |
+
display: flex;
|
| 742 |
+
align-items: center;
|
| 743 |
+
justify-content: space-between;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
.fullscreen-title {
|
| 747 |
+
font-size: 16px;
|
| 748 |
+
font-weight: 600;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.fullscreen-close {
|
| 752 |
+
width: 40px;
|
| 753 |
+
height: 40px;
|
| 754 |
+
border: none;
|
| 755 |
+
background: var(--border-light);
|
| 756 |
+
border-radius: var(--radius-sm);
|
| 757 |
+
cursor: pointer;
|
| 758 |
+
display: flex;
|
| 759 |
+
align-items: center;
|
| 760 |
+
justify-content: center;
|
| 761 |
+
color: var(--text-secondary);
|
| 762 |
+
transition: all var(--transition);
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
.fullscreen-close:hover {
|
| 766 |
+
background: var(--error-light);
|
| 767 |
+
color: var(--error);
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
.fullscreen-content {
|
| 771 |
+
flex: 1;
|
| 772 |
+
padding: 24px;
|
| 773 |
+
overflow: hidden;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
.fullscreen-content iframe {
|
| 777 |
+
width: 100%;
|
| 778 |
+
height: 100%;
|
| 779 |
+
border: 1px solid var(--border);
|
| 780 |
+
border-radius: var(--radius);
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
/* =============== Drag & Drop Zone =============== */
|
| 785 |
+
.drop-zone {
|
| 786 |
+
border: 2px dashed var(--border);
|
| 787 |
+
border-radius: var(--radius);
|
| 788 |
+
padding: 40px 24px;
|
| 789 |
+
text-align: center;
|
| 790 |
+
transition: all var(--transition);
|
| 791 |
+
cursor: pointer;
|
| 792 |
+
background: var(--border-light);
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.drop-zone:hover,
|
| 796 |
+
.drop-zone.drag-over {
|
| 797 |
+
border-color: var(--primary);
|
| 798 |
+
background: var(--primary-light);
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
.drop-zone-icon {
|
| 802 |
+
width: 56px;
|
| 803 |
+
height: 56px;
|
| 804 |
+
margin: 0 auto 16px;
|
| 805 |
+
color: var(--text-muted);
|
| 806 |
+
transition: transform var(--transition), color var(--transition);
|
| 807 |
+
pointer-events: none;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
.drop-zone:hover .drop-zone-icon,
|
| 811 |
+
.drop-zone.drag-over .drop-zone-icon {
|
| 812 |
+
color: var(--primary);
|
| 813 |
+
transform: scale(1.1);
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.drop-zone-text {
|
| 817 |
+
font-size: 15px;
|
| 818 |
+
font-weight: 500;
|
| 819 |
+
color: var(--text-secondary);
|
| 820 |
+
margin-bottom: 6px;
|
| 821 |
+
pointer-events: none;
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
.drop-zone-hint {
|
| 825 |
+
font-size: 12px;
|
| 826 |
+
color: var(--text-muted);
|
| 827 |
+
pointer-events: none;
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
.drop-zone-file {
|
| 831 |
+
display: none;
|
| 832 |
+
align-items: center;
|
| 833 |
+
justify-content: center;
|
| 834 |
+
gap: 12px;
|
| 835 |
+
padding: 14px 18px;
|
| 836 |
+
background: var(--card-solid);
|
| 837 |
+
border: 1px solid var(--border);
|
| 838 |
+
border-radius: var(--radius-sm);
|
| 839 |
+
margin-top: 16px;
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
.drop-zone-file.active {
|
| 843 |
+
display: flex;
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
.drop-zone-file-name {
|
| 847 |
+
font-size: 14px;
|
| 848 |
+
font-weight: 500;
|
| 849 |
+
color: var(--text);
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
.drop-zone-file-size {
|
| 853 |
+
font-size: 12px;
|
| 854 |
+
color: var(--text-muted);
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.drop-zone-file-remove {
|
| 858 |
+
margin-left: auto;
|
| 859 |
+
padding: 6px 12px;
|
| 860 |
+
background: var(--error-light);
|
| 861 |
+
color: var(--error);
|
| 862 |
+
border: none;
|
| 863 |
+
border-radius: 6px;
|
| 864 |
+
font-size: 12px;
|
| 865 |
+
font-weight: 500;
|
| 866 |
+
cursor: pointer;
|
| 867 |
+
transition: all var(--transition);
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
.drop-zone-file-remove:hover {
|
| 871 |
+
background: var(--error);
|
| 872 |
+
color: white;
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
/* =============== Skeleton Loaders =============== */
|
| 876 |
+
.skeleton {
|
| 877 |
+
background: var(--border-light);
|
| 878 |
+
animation: shimmer 1.5s infinite;
|
| 879 |
+
border-radius: var(--radius-sm);
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
@keyframes shimmer {
|
| 883 |
+
0% { opacity: 1; }
|
| 884 |
+
50% { opacity: 0.5; }
|
| 885 |
+
100% { opacity: 1; }
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
.skeleton-text {
|
| 889 |
+
height: 16px;
|
| 890 |
+
margin-bottom: 8px;
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
.skeleton-text:last-child {
|
| 894 |
+
width: 60%;
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
.skeleton-image {
|
| 898 |
+
height: 140px;
|
| 899 |
+
border-radius: var(--radius-sm);
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
.skeleton-card {
|
| 903 |
+
padding: 16px;
|
| 904 |
+
background: var(--card);
|
| 905 |
+
border-radius: var(--radius);
|
| 906 |
+
border: 1px solid var(--border);
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
/* Image Grid Skeleton */
|
| 910 |
+
.image-grid-skeleton {
|
| 911 |
+
display: grid;
|
| 912 |
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
| 913 |
+
gap: 16px;
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
.image-card-skeleton {
|
| 917 |
+
background: var(--card);
|
| 918 |
+
border: 1px solid var(--border);
|
| 919 |
+
border-radius: var(--radius-sm);
|
| 920 |
+
overflow: hidden;
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
.image-card-skeleton .skeleton-image {
|
| 924 |
+
border-radius: 0;
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
.image-card-skeleton .skeleton-meta {
|
| 928 |
+
padding: 12px;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
|
| 932 |
+
/* =============== Image Grid =============== */
|
| 933 |
+
.image-toolbar {
|
| 934 |
+
display: flex;
|
| 935 |
+
align-items: center;
|
| 936 |
+
justify-content: space-between;
|
| 937 |
+
flex-wrap: wrap;
|
| 938 |
+
gap: 16px;
|
| 939 |
+
padding: 16px 0;
|
| 940 |
+
border-bottom: 1px solid var(--border-light);
|
| 941 |
+
margin-bottom: 20px;
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
.image-toolbar-left,
|
| 945 |
+
.image-toolbar-right {
|
| 946 |
+
display: flex;
|
| 947 |
+
align-items: center;
|
| 948 |
+
gap: 12px;
|
| 949 |
+
flex-wrap: wrap;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
.counter-badge {
|
| 953 |
+
padding: 10px 16px;
|
| 954 |
+
background: var(--primary-light);
|
| 955 |
+
border: 1px solid rgba(139, 195, 74, 0.2);
|
| 956 |
+
border-radius: 20px;
|
| 957 |
+
font-size: 13px;
|
| 958 |
+
color: var(--primary);
|
| 959 |
+
font-weight: 600;
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
.image-grid {
|
| 963 |
+
display: grid;
|
| 964 |
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
| 965 |
+
gap: 16px;
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
.image-card {
|
| 969 |
+
background: var(--card);
|
| 970 |
+
border: 1px solid var(--border);
|
| 971 |
+
border-radius: var(--radius);
|
| 972 |
+
overflow: hidden;
|
| 973 |
+
transition: all var(--transition);
|
| 974 |
+
cursor: pointer;
|
| 975 |
+
position: relative;
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
.image-card:hover {
|
| 979 |
+
transform: translateY(-2px);
|
| 980 |
+
box-shadow: var(--shadow);
|
| 981 |
+
border-color: var(--primary);
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
.image-card.selected {
|
| 985 |
+
border-color: var(--primary);
|
| 986 |
+
box-shadow: 0 0 0 2px var(--primary-light);
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
.image-thumb {
|
| 990 |
+
width: 100%;
|
| 991 |
+
height: 150px;
|
| 992 |
+
object-fit: cover;
|
| 993 |
+
background: var(--border-light);
|
| 994 |
+
transition: transform var(--transition);
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
.image-card:hover .image-thumb {
|
| 998 |
+
transform: scale(1.02);
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
.image-meta {
|
| 1002 |
+
padding: 14px;
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
.image-filename {
|
| 1006 |
+
font-size: 13px;
|
| 1007 |
+
font-weight: 600;
|
| 1008 |
+
color: var(--text);
|
| 1009 |
+
white-space: nowrap;
|
| 1010 |
+
overflow: hidden;
|
| 1011 |
+
text-overflow: ellipsis;
|
| 1012 |
+
margin-bottom: 10px;
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
.image-info {
|
| 1016 |
+
display: flex;
|
| 1017 |
+
align-items: center;
|
| 1018 |
+
justify-content: space-between;
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
.image-badge {
|
| 1022 |
+
font-size: 11px;
|
| 1023 |
+
padding: 4px 10px;
|
| 1024 |
+
background: var(--border-light);
|
| 1025 |
+
border-radius: 6px;
|
| 1026 |
+
color: var(--text-secondary);
|
| 1027 |
+
font-weight: 500;
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
.image-checkbox {
|
| 1031 |
+
display: flex;
|
| 1032 |
+
align-items: center;
|
| 1033 |
+
gap: 6px;
|
| 1034 |
+
font-size: 12px;
|
| 1035 |
+
color: var(--text-secondary);
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
.image-checkbox input {
|
| 1039 |
+
width: 18px;
|
| 1040 |
+
height: 18px;
|
| 1041 |
+
accent-color: var(--primary);
|
| 1042 |
+
cursor: pointer;
|
| 1043 |
+
}
|
| 1044 |
+
|
| 1045 |
+
.image-preview-btn {
|
| 1046 |
+
position: absolute;
|
| 1047 |
+
top: 10px;
|
| 1048 |
+
right: 10px;
|
| 1049 |
+
width: 36px;
|
| 1050 |
+
height: 36px;
|
| 1051 |
+
background: rgba(0,0,0,0.6);
|
| 1052 |
+
border: none;
|
| 1053 |
+
border-radius: 8px;
|
| 1054 |
+
color: white;
|
| 1055 |
+
cursor: pointer;
|
| 1056 |
+
display: flex;
|
| 1057 |
+
align-items: center;
|
| 1058 |
+
justify-content: center;
|
| 1059 |
+
opacity: 0;
|
| 1060 |
+
transform: scale(0.8);
|
| 1061 |
+
transition: all var(--transition);
|
| 1062 |
+
z-index: 10;
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
.image-card:hover .image-preview-btn {
|
| 1066 |
+
opacity: 1;
|
| 1067 |
+
transform: scale(1);
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
.image-preview-btn:hover {
|
| 1071 |
+
background: var(--primary);
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
.image-preview-btn svg {
|
| 1075 |
+
width: 18px;
|
| 1076 |
+
height: 18px;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
|
| 1080 |
+
/* =============== Download Section =============== */
|
| 1081 |
+
.download-section {
|
| 1082 |
+
display: flex;
|
| 1083 |
+
align-items: center;
|
| 1084 |
+
gap: 10px;
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
.download-section .form-input {
|
| 1088 |
+
width: 140px;
|
| 1089 |
+
padding: 10px 14px;
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
/* =============== Toast Notifications =============== */
|
| 1093 |
+
.toast-container {
|
| 1094 |
+
position: fixed;
|
| 1095 |
+
top: 24px;
|
| 1096 |
+
right: 24px;
|
| 1097 |
+
z-index: 1001;
|
| 1098 |
+
display: flex;
|
| 1099 |
+
flex-direction: column;
|
| 1100 |
+
gap: 12px;
|
| 1101 |
+
max-width: 400px;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
.toast {
|
| 1105 |
+
display: flex;
|
| 1106 |
+
align-items: flex-start;
|
| 1107 |
+
gap: 14px;
|
| 1108 |
+
padding: 16px 18px;
|
| 1109 |
+
background: var(--card);
|
| 1110 |
+
border: 1px solid var(--border);
|
| 1111 |
+
border-radius: var(--radius);
|
| 1112 |
+
box-shadow: var(--shadow-lg);
|
| 1113 |
+
animation: toastIn 0.3s ease;
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
.toast.hiding {
|
| 1117 |
+
animation: toastOut 0.3s ease forwards;
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
@keyframes toastIn {
|
| 1121 |
+
from {
|
| 1122 |
+
transform: translateX(100%);
|
| 1123 |
+
opacity: 0;
|
| 1124 |
+
}
|
| 1125 |
+
to {
|
| 1126 |
+
transform: translateX(0);
|
| 1127 |
+
opacity: 1;
|
| 1128 |
+
}
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
@keyframes toastOut {
|
| 1132 |
+
from {
|
| 1133 |
+
transform: translateX(0);
|
| 1134 |
+
opacity: 1;
|
| 1135 |
+
}
|
| 1136 |
+
to {
|
| 1137 |
+
transform: translateX(100%);
|
| 1138 |
+
opacity: 0;
|
| 1139 |
+
}
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
.toast-icon {
|
| 1143 |
+
width: 22px;
|
| 1144 |
+
height: 22px;
|
| 1145 |
+
flex-shrink: 0;
|
| 1146 |
+
}
|
| 1147 |
+
|
| 1148 |
+
.toast-success .toast-icon { color: var(--success); }
|
| 1149 |
+
.toast-error .toast-icon { color: var(--error); }
|
| 1150 |
+
.toast-info .toast-icon { color: var(--primary); }
|
| 1151 |
+
|
| 1152 |
+
.toast-content {
|
| 1153 |
+
flex: 1;
|
| 1154 |
+
}
|
| 1155 |
+
|
| 1156 |
+
.toast-title {
|
| 1157 |
+
font-size: 14px;
|
| 1158 |
+
font-weight: 600;
|
| 1159 |
+
color: var(--text);
|
| 1160 |
+
margin-bottom: 2px;
|
| 1161 |
+
}
|
| 1162 |
+
|
| 1163 |
+
.toast-message {
|
| 1164 |
+
font-size: 13px;
|
| 1165 |
+
color: var(--text-secondary);
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
.toast-close {
|
| 1169 |
+
background: none;
|
| 1170 |
+
border: none;
|
| 1171 |
+
padding: 4px;
|
| 1172 |
+
cursor: pointer;
|
| 1173 |
+
color: var(--text-muted);
|
| 1174 |
+
border-radius: 6px;
|
| 1175 |
+
transition: all var(--transition);
|
| 1176 |
+
}
|
| 1177 |
+
|
| 1178 |
+
.toast-close:hover {
|
| 1179 |
+
background: var(--border-light);
|
| 1180 |
+
color: var(--text);
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
|
| 1184 |
+
/* =============== Modal =============== */
|
| 1185 |
+
.modal-overlay {
|
| 1186 |
+
position: fixed;
|
| 1187 |
+
inset: 0;
|
| 1188 |
+
background: rgba(0,0,0,0.6);
|
| 1189 |
+
z-index: 1000;
|
| 1190 |
+
display: none;
|
| 1191 |
+
align-items: center;
|
| 1192 |
+
justify-content: center;
|
| 1193 |
+
padding: 24px;
|
| 1194 |
+
}
|
| 1195 |
+
|
| 1196 |
+
.modal-overlay.active {
|
| 1197 |
+
display: flex;
|
| 1198 |
+
animation: fadeIn 0.2s ease;
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
@keyframes fadeIn {
|
| 1202 |
+
from { opacity: 0; }
|
| 1203 |
+
to { opacity: 1; }
|
| 1204 |
+
}
|
| 1205 |
+
|
| 1206 |
+
.modal-content {
|
| 1207 |
+
position: relative;
|
| 1208 |
+
max-width: 90vw;
|
| 1209 |
+
max-height: 90vh;
|
| 1210 |
+
background: var(--card);
|
| 1211 |
+
border-radius: var(--radius-lg);
|
| 1212 |
+
overflow: hidden;
|
| 1213 |
+
box-shadow: var(--shadow-lg);
|
| 1214 |
+
animation: modalIn 0.3s ease;
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
@keyframes modalIn {
|
| 1218 |
+
from {
|
| 1219 |
+
transform: scale(0.95);
|
| 1220 |
+
opacity: 0;
|
| 1221 |
+
}
|
| 1222 |
+
to {
|
| 1223 |
+
transform: scale(1);
|
| 1224 |
+
opacity: 1;
|
| 1225 |
+
}
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
.modal-image {
|
| 1229 |
+
max-width: 100%;
|
| 1230 |
+
max-height: 75vh;
|
| 1231 |
+
display: block;
|
| 1232 |
+
}
|
| 1233 |
+
|
| 1234 |
+
.modal-footer {
|
| 1235 |
+
padding: 18px 24px;
|
| 1236 |
+
background: var(--card);
|
| 1237 |
+
border-top: 1px solid var(--border);
|
| 1238 |
+
display: flex;
|
| 1239 |
+
align-items: center;
|
| 1240 |
+
justify-content: space-between;
|
| 1241 |
+
gap: 16px;
|
| 1242 |
+
}
|
| 1243 |
+
|
| 1244 |
+
.modal-info {
|
| 1245 |
+
font-size: 13px;
|
| 1246 |
+
color: var(--text-secondary);
|
| 1247 |
+
}
|
| 1248 |
+
|
| 1249 |
+
.modal-info strong {
|
| 1250 |
+
color: var(--text);
|
| 1251 |
+
font-weight: 600;
|
| 1252 |
+
}
|
| 1253 |
+
|
| 1254 |
+
.modal-close {
|
| 1255 |
+
position: absolute;
|
| 1256 |
+
top: 16px;
|
| 1257 |
+
right: 16px;
|
| 1258 |
+
width: 40px;
|
| 1259 |
+
height: 40px;
|
| 1260 |
+
background: rgba(0,0,0,0.5);
|
| 1261 |
+
border: none;
|
| 1262 |
+
border-radius: 50%;
|
| 1263 |
+
color: white;
|
| 1264 |
+
cursor: pointer;
|
| 1265 |
+
display: flex;
|
| 1266 |
+
align-items: center;
|
| 1267 |
+
justify-content: center;
|
| 1268 |
+
transition: all var(--transition);
|
| 1269 |
+
}
|
| 1270 |
+
|
| 1271 |
+
.modal-close:hover {
|
| 1272 |
+
background: var(--error);
|
| 1273 |
+
}
|
| 1274 |
+
|
| 1275 |
+
/* =============== Progress Bar =============== */
|
| 1276 |
+
.progress-container {
|
| 1277 |
+
margin-top: 16px;
|
| 1278 |
+
display: none;
|
| 1279 |
+
}
|
| 1280 |
+
|
| 1281 |
+
.progress-container.active {
|
| 1282 |
+
display: block;
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
.progress-bar {
|
| 1286 |
+
height: 6px;
|
| 1287 |
+
background: var(--border);
|
| 1288 |
+
border-radius: 3px;
|
| 1289 |
+
overflow: hidden;
|
| 1290 |
+
}
|
| 1291 |
+
|
| 1292 |
+
.progress-fill {
|
| 1293 |
+
height: 100%;
|
| 1294 |
+
background: var(--primary);
|
| 1295 |
+
border-radius: 3px;
|
| 1296 |
+
width: 0%;
|
| 1297 |
+
transition: width 0.3s ease;
|
| 1298 |
+
}
|
| 1299 |
+
|
| 1300 |
+
.progress-fill.indeterminate {
|
| 1301 |
+
width: 30%;
|
| 1302 |
+
animation: indeterminate 1.5s ease-in-out infinite;
|
| 1303 |
+
}
|
| 1304 |
+
|
| 1305 |
+
@keyframes indeterminate {
|
| 1306 |
+
0% { transform: translateX(-100%); }
|
| 1307 |
+
100% { transform: translateX(400%); }
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
.progress-text {
|
| 1311 |
+
font-size: 12px;
|
| 1312 |
+
color: var(--text-secondary);
|
| 1313 |
+
margin-top: 8px;
|
| 1314 |
+
text-align: center;
|
| 1315 |
+
}
|
| 1316 |
+
|
| 1317 |
+
/* =============== Spinner =============== */
|
| 1318 |
+
.spinner {
|
| 1319 |
+
width: 20px;
|
| 1320 |
+
height: 20px;
|
| 1321 |
+
border: 2px solid rgba(255,255,255,0.3);
|
| 1322 |
+
border-top-color: white;
|
| 1323 |
+
border-radius: 50%;
|
| 1324 |
+
animation: spin 0.8s linear infinite;
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
@keyframes spin {
|
| 1328 |
+
to { transform: rotate(360deg); }
|
| 1329 |
+
}
|
| 1330 |
+
|
| 1331 |
+
.btn.loading {
|
| 1332 |
+
pointer-events: none;
|
| 1333 |
+
}
|
| 1334 |
+
|
| 1335 |
+
|
| 1336 |
+
/* =============== Form Validation =============== */
|
| 1337 |
+
.form-group.error .form-input,
|
| 1338 |
+
.form-group.error .form-select {
|
| 1339 |
+
border-color: var(--error);
|
| 1340 |
+
}
|
| 1341 |
+
|
| 1342 |
+
.form-group.error .form-input:focus {
|
| 1343 |
+
box-shadow: 0 0 0 3px var(--error-light);
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
.form-error {
|
| 1347 |
+
display: none;
|
| 1348 |
+
font-size: 12px;
|
| 1349 |
+
color: var(--error);
|
| 1350 |
+
margin-top: 6px;
|
| 1351 |
+
align-items: center;
|
| 1352 |
+
gap: 6px;
|
| 1353 |
+
}
|
| 1354 |
+
|
| 1355 |
+
.form-group.error .form-error {
|
| 1356 |
+
display: flex;
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
.form-error svg {
|
| 1360 |
+
width: 14px;
|
| 1361 |
+
height: 14px;
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
/* =============== Keyboard Hints =============== */
|
| 1365 |
+
.kbd {
|
| 1366 |
+
display: inline-flex;
|
| 1367 |
+
align-items: center;
|
| 1368 |
+
justify-content: center;
|
| 1369 |
+
padding: 3px 8px;
|
| 1370 |
+
background: var(--border-light);
|
| 1371 |
+
border: 1px solid var(--border);
|
| 1372 |
+
border-radius: 6px;
|
| 1373 |
+
font-size: 10px;
|
| 1374 |
+
font-family: monospace;
|
| 1375 |
+
color: var(--text-muted);
|
| 1376 |
+
margin-left: 8px;
|
| 1377 |
+
}
|
| 1378 |
+
|
| 1379 |
+
/* =============== Responsive =============== */
|
| 1380 |
+
@media (max-width: 1024px) {
|
| 1381 |
+
.sidebar {
|
| 1382 |
+
transform: translateX(-100%);
|
| 1383 |
+
}
|
| 1384 |
+
|
| 1385 |
+
.sidebar.open {
|
| 1386 |
+
transform: translateX(0);
|
| 1387 |
+
}
|
| 1388 |
+
|
| 1389 |
+
.main-content {
|
| 1390 |
+
margin-left: 0;
|
| 1391 |
+
}
|
| 1392 |
+
|
| 1393 |
+
.mobile-menu-btn {
|
| 1394 |
+
display: flex;
|
| 1395 |
+
}
|
| 1396 |
+
|
| 1397 |
+
.split-view.active {
|
| 1398 |
+
grid-template-columns: 1fr;
|
| 1399 |
+
}
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
@media (max-width: 640px) {
|
| 1403 |
+
.main-content {
|
| 1404 |
+
padding: 16px;
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
.page-header h2 {
|
| 1408 |
+
font-size: 22px;
|
| 1409 |
+
}
|
| 1410 |
+
|
| 1411 |
+
.image-toolbar {
|
| 1412 |
+
flex-direction: column;
|
| 1413 |
+
align-items: flex-start;
|
| 1414 |
+
}
|
| 1415 |
+
|
| 1416 |
+
.download-section {
|
| 1417 |
+
flex-direction: column;
|
| 1418 |
+
width: 100%;
|
| 1419 |
+
}
|
| 1420 |
+
|
| 1421 |
+
.download-section .form-input {
|
| 1422 |
+
width: 100%;
|
| 1423 |
+
}
|
| 1424 |
+
}
|
| 1425 |
+
|
| 1426 |
+
/* Mobile Menu Button */
|
| 1427 |
+
.mobile-menu-btn {
|
| 1428 |
+
display: none;
|
| 1429 |
+
position: fixed;
|
| 1430 |
+
top: 16px;
|
| 1431 |
+
left: 16px;
|
| 1432 |
+
z-index: 101;
|
| 1433 |
+
width: 44px;
|
| 1434 |
+
height: 44px;
|
| 1435 |
+
background: var(--card);
|
| 1436 |
+
border: 1px solid var(--border);
|
| 1437 |
+
border-radius: var(--radius-sm);
|
| 1438 |
+
cursor: pointer;
|
| 1439 |
+
align-items: center;
|
| 1440 |
+
justify-content: center;
|
| 1441 |
+
color: var(--text);
|
| 1442 |
+
}
|
| 1443 |
+
|
| 1444 |
+
@media (max-width: 1024px) {
|
| 1445 |
+
.mobile-menu-btn {
|
| 1446 |
+
display: flex;
|
| 1447 |
+
}
|
| 1448 |
+
}
|
| 1449 |
+
|
| 1450 |
+
/* Sidebar Overlay */
|
| 1451 |
+
.sidebar-overlay {
|
| 1452 |
+
display: none;
|
| 1453 |
+
position: fixed;
|
| 1454 |
+
inset: 0;
|
| 1455 |
+
background: rgba(0,0,0,0.5);
|
| 1456 |
+
z-index: 99;
|
| 1457 |
+
}
|
| 1458 |
+
|
| 1459 |
+
.sidebar-overlay.active {
|
| 1460 |
+
display: block;
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
|
| 1464 |
+
/* =============== Tooltips =============== */
|
| 1465 |
+
.tooltip {
|
| 1466 |
+
position: relative;
|
| 1467 |
+
}
|
| 1468 |
+
|
| 1469 |
+
.tooltip::before,
|
| 1470 |
+
.tooltip::after {
|
| 1471 |
+
position: absolute;
|
| 1472 |
+
opacity: 0;
|
| 1473 |
+
visibility: hidden;
|
| 1474 |
+
transition: all 0.2s ease;
|
| 1475 |
+
z-index: 100;
|
| 1476 |
+
pointer-events: none;
|
| 1477 |
+
}
|
| 1478 |
+
|
| 1479 |
+
.tooltip::before {
|
| 1480 |
+
content: attr(data-tooltip);
|
| 1481 |
+
bottom: calc(100% + 8px);
|
| 1482 |
+
left: 50%;
|
| 1483 |
+
transform: translateX(-50%);
|
| 1484 |
+
padding: 8px 12px;
|
| 1485 |
+
background: var(--text);
|
| 1486 |
+
color: var(--bg);
|
| 1487 |
+
font-size: 12px;
|
| 1488 |
+
font-weight: 500;
|
| 1489 |
+
white-space: nowrap;
|
| 1490 |
+
border-radius: 8px;
|
| 1491 |
+
box-shadow: var(--shadow);
|
| 1492 |
+
}
|
| 1493 |
+
|
| 1494 |
+
.tooltip::after {
|
| 1495 |
+
content: '';
|
| 1496 |
+
bottom: calc(100% + 2px);
|
| 1497 |
+
left: 50%;
|
| 1498 |
+
transform: translateX(-50%);
|
| 1499 |
+
border: 6px solid transparent;
|
| 1500 |
+
border-top-color: var(--text);
|
| 1501 |
+
}
|
| 1502 |
+
|
| 1503 |
+
.tooltip:hover::before,
|
| 1504 |
+
.tooltip:hover::after {
|
| 1505 |
+
opacity: 1;
|
| 1506 |
+
visibility: visible;
|
| 1507 |
+
}
|
| 1508 |
+
|
| 1509 |
+
/* Tooltip positions */
|
| 1510 |
+
.tooltip-bottom::before {
|
| 1511 |
+
bottom: auto;
|
| 1512 |
+
top: calc(100% + 8px);
|
| 1513 |
+
}
|
| 1514 |
+
|
| 1515 |
+
.tooltip-bottom::after {
|
| 1516 |
+
bottom: auto;
|
| 1517 |
+
top: calc(100% + 2px);
|
| 1518 |
+
border-top-color: transparent;
|
| 1519 |
+
border-bottom-color: var(--text);
|
| 1520 |
+
}
|
| 1521 |
+
|
| 1522 |
+
.tooltip-left::before {
|
| 1523 |
+
bottom: auto;
|
| 1524 |
+
left: auto;
|
| 1525 |
+
right: calc(100% + 8px);
|
| 1526 |
+
top: 50%;
|
| 1527 |
+
transform: translateY(-50%);
|
| 1528 |
+
}
|
| 1529 |
+
|
| 1530 |
+
.tooltip-left::after {
|
| 1531 |
+
bottom: auto;
|
| 1532 |
+
left: auto;
|
| 1533 |
+
right: calc(100% + 2px);
|
| 1534 |
+
top: 50%;
|
| 1535 |
+
border-top-color: transparent;
|
| 1536 |
+
border-left-color: var(--text);
|
| 1537 |
+
transform: translateY(-50%);
|
| 1538 |
+
}
|
| 1539 |
+
|
| 1540 |
+
/* =============== Step Indicator =============== */
|
| 1541 |
+
.step-indicator {
|
| 1542 |
+
display: flex;
|
| 1543 |
+
align-items: center;
|
| 1544 |
+
justify-content: center;
|
| 1545 |
+
gap: 8px;
|
| 1546 |
+
margin-bottom: 24px;
|
| 1547 |
+
padding: 16px;
|
| 1548 |
+
background: var(--card);
|
| 1549 |
+
border: 1px solid var(--border);
|
| 1550 |
+
border-radius: var(--radius);
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
.step {
|
| 1554 |
+
display: flex;
|
| 1555 |
+
align-items: center;
|
| 1556 |
+
gap: 12px;
|
| 1557 |
+
}
|
| 1558 |
+
|
| 1559 |
+
.step-number {
|
| 1560 |
+
width: 32px;
|
| 1561 |
+
height: 32px;
|
| 1562 |
+
border-radius: 50%;
|
| 1563 |
+
background: var(--border-light);
|
| 1564 |
+
color: var(--text-muted);
|
| 1565 |
+
font-size: 13px;
|
| 1566 |
+
font-weight: 600;
|
| 1567 |
+
display: flex;
|
| 1568 |
+
align-items: center;
|
| 1569 |
+
justify-content: center;
|
| 1570 |
+
transition: all var(--transition);
|
| 1571 |
+
}
|
| 1572 |
+
|
| 1573 |
+
.step-number svg {
|
| 1574 |
+
width: 16px;
|
| 1575 |
+
height: 16px;
|
| 1576 |
+
}
|
| 1577 |
+
|
| 1578 |
+
.step.active .step-number {
|
| 1579 |
+
background: var(--primary);
|
| 1580 |
+
color: white;
|
| 1581 |
+
}
|
| 1582 |
+
|
| 1583 |
+
.step.completed .step-number {
|
| 1584 |
+
background: var(--success);
|
| 1585 |
+
color: white;
|
| 1586 |
+
}
|
| 1587 |
+
|
| 1588 |
+
.step-label {
|
| 1589 |
+
font-size: 13px;
|
| 1590 |
+
color: var(--text-muted);
|
| 1591 |
+
font-weight: 500;
|
| 1592 |
+
transition: color var(--transition);
|
| 1593 |
+
}
|
| 1594 |
+
|
| 1595 |
+
.step.active .step-label {
|
| 1596 |
+
color: var(--text);
|
| 1597 |
+
}
|
| 1598 |
+
|
| 1599 |
+
.step.completed .step-label {
|
| 1600 |
+
color: var(--success);
|
| 1601 |
+
}
|
| 1602 |
+
|
| 1603 |
+
.step-connector {
|
| 1604 |
+
width: 40px;
|
| 1605 |
+
height: 2px;
|
| 1606 |
+
background: var(--border);
|
| 1607 |
+
border-radius: 1px;
|
| 1608 |
+
position: relative;
|
| 1609 |
+
overflow: hidden;
|
| 1610 |
+
}
|
| 1611 |
+
|
| 1612 |
+
.step-connector::after {
|
| 1613 |
+
content: '';
|
| 1614 |
+
position: absolute;
|
| 1615 |
+
left: 0;
|
| 1616 |
+
top: 0;
|
| 1617 |
+
height: 100%;
|
| 1618 |
+
width: 0%;
|
| 1619 |
+
background: var(--success);
|
| 1620 |
+
transition: width 0.5s ease;
|
| 1621 |
+
}
|
| 1622 |
+
|
| 1623 |
+
.step-connector.completed::after {
|
| 1624 |
+
width: 100%;
|
| 1625 |
+
}
|
| 1626 |
+
|
| 1627 |
+
|
| 1628 |
+
/* =============== Success Animation =============== */
|
| 1629 |
+
.success-overlay {
|
| 1630 |
+
position: fixed;
|
| 1631 |
+
inset: 0;
|
| 1632 |
+
background: rgba(139, 195, 74, 0.1);
|
| 1633 |
+
z-index: 1000;
|
| 1634 |
+
display: none;
|
| 1635 |
+
align-items: center;
|
| 1636 |
+
justify-content: center;
|
| 1637 |
+
animation: successFadeIn 0.3s ease;
|
| 1638 |
+
}
|
| 1639 |
+
|
| 1640 |
+
.success-overlay.active {
|
| 1641 |
+
display: flex;
|
| 1642 |
+
}
|
| 1643 |
+
|
| 1644 |
+
@keyframes successFadeIn {
|
| 1645 |
+
from { opacity: 0; }
|
| 1646 |
+
to { opacity: 1; }
|
| 1647 |
+
}
|
| 1648 |
+
|
| 1649 |
+
.success-content {
|
| 1650 |
+
text-align: center;
|
| 1651 |
+
animation: successBounce 0.4s ease;
|
| 1652 |
+
}
|
| 1653 |
+
|
| 1654 |
+
@keyframes successBounce {
|
| 1655 |
+
0% { transform: scale(0.8); opacity: 0; }
|
| 1656 |
+
100% { transform: scale(1); opacity: 1; }
|
| 1657 |
+
}
|
| 1658 |
+
|
| 1659 |
+
.success-icon {
|
| 1660 |
+
width: 80px;
|
| 1661 |
+
height: 80px;
|
| 1662 |
+
background: var(--success);
|
| 1663 |
+
border-radius: 50%;
|
| 1664 |
+
display: flex;
|
| 1665 |
+
align-items: center;
|
| 1666 |
+
justify-content: center;
|
| 1667 |
+
margin: 0 auto 20px;
|
| 1668 |
+
box-shadow: var(--shadow-lg);
|
| 1669 |
+
}
|
| 1670 |
+
|
| 1671 |
+
.success-icon svg {
|
| 1672 |
+
width: 40px;
|
| 1673 |
+
height: 40px;
|
| 1674 |
+
color: white;
|
| 1675 |
+
}
|
| 1676 |
+
|
| 1677 |
+
.success-title {
|
| 1678 |
+
font-size: 24px;
|
| 1679 |
+
font-weight: 700;
|
| 1680 |
+
color: var(--text);
|
| 1681 |
+
margin-bottom: 8px;
|
| 1682 |
+
}
|
| 1683 |
+
|
| 1684 |
+
.success-message {
|
| 1685 |
+
font-size: 15px;
|
| 1686 |
+
color: var(--text-secondary);
|
| 1687 |
+
margin-bottom: 20px;
|
| 1688 |
+
}
|
| 1689 |
+
|
| 1690 |
+
/* Confetti effect */
|
| 1691 |
+
.confetti {
|
| 1692 |
+
position: absolute;
|
| 1693 |
+
width: 10px;
|
| 1694 |
+
height: 10px;
|
| 1695 |
+
border-radius: 2px;
|
| 1696 |
+
animation: confettiFall 3s ease-out forwards;
|
| 1697 |
+
}
|
| 1698 |
+
|
| 1699 |
+
@keyframes confettiFall {
|
| 1700 |
+
0% {
|
| 1701 |
+
transform: translateY(0) rotate(0deg);
|
| 1702 |
+
opacity: 1;
|
| 1703 |
+
}
|
| 1704 |
+
100% {
|
| 1705 |
+
transform: translateY(400px) rotate(720deg);
|
| 1706 |
+
opacity: 0;
|
| 1707 |
+
}
|
| 1708 |
+
}
|
| 1709 |
+
|
| 1710 |
+
/* =============== Error States =============== */
|
| 1711 |
+
.error-state {
|
| 1712 |
+
padding: 40px 24px;
|
| 1713 |
+
text-align: center;
|
| 1714 |
+
background: var(--error-light);
|
| 1715 |
+
border: 1px solid rgba(229, 57, 53, 0.2);
|
| 1716 |
+
border-radius: var(--radius);
|
| 1717 |
+
margin-top: 16px;
|
| 1718 |
+
}
|
| 1719 |
+
|
| 1720 |
+
.error-state-icon {
|
| 1721 |
+
width: 64px;
|
| 1722 |
+
height: 64px;
|
| 1723 |
+
background: rgba(229, 57, 53, 0.15);
|
| 1724 |
+
border-radius: 50%;
|
| 1725 |
+
display: flex;
|
| 1726 |
+
align-items: center;
|
| 1727 |
+
justify-content: center;
|
| 1728 |
+
margin: 0 auto 16px;
|
| 1729 |
+
}
|
| 1730 |
+
|
| 1731 |
+
.error-state-icon svg {
|
| 1732 |
+
width: 32px;
|
| 1733 |
+
height: 32px;
|
| 1734 |
+
color: var(--error);
|
| 1735 |
+
}
|
| 1736 |
+
|
| 1737 |
+
.error-state-title {
|
| 1738 |
+
font-size: 18px;
|
| 1739 |
+
font-weight: 600;
|
| 1740 |
+
color: var(--error);
|
| 1741 |
+
margin-bottom: 8px;
|
| 1742 |
+
}
|
| 1743 |
+
|
| 1744 |
+
.error-state-message {
|
| 1745 |
+
font-size: 14px;
|
| 1746 |
+
color: var(--text-secondary);
|
| 1747 |
+
margin-bottom: 20px;
|
| 1748 |
+
max-width: 400px;
|
| 1749 |
+
margin-left: auto;
|
| 1750 |
+
margin-right: auto;
|
| 1751 |
+
}
|
| 1752 |
+
|
| 1753 |
+
.error-state-actions {
|
| 1754 |
+
display: flex;
|
| 1755 |
+
gap: 12px;
|
| 1756 |
+
justify-content: center;
|
| 1757 |
+
flex-wrap: wrap;
|
| 1758 |
+
}
|
| 1759 |
+
|
| 1760 |
+
.error-state .btn {
|
| 1761 |
+
min-width: 120px;
|
| 1762 |
+
}
|
| 1763 |
+
|
| 1764 |
+
/* Inline error with action */
|
| 1765 |
+
.inline-error {
|
| 1766 |
+
display: flex;
|
| 1767 |
+
align-items: center;
|
| 1768 |
+
gap: 12px;
|
| 1769 |
+
padding: 14px 18px;
|
| 1770 |
+
background: var(--error-light);
|
| 1771 |
+
border: 1px solid rgba(229, 57, 53, 0.3);
|
| 1772 |
+
border-radius: var(--radius-sm);
|
| 1773 |
+
margin-top: 16px;
|
| 1774 |
+
}
|
| 1775 |
+
|
| 1776 |
+
.inline-error-icon {
|
| 1777 |
+
width: 40px;
|
| 1778 |
+
height: 40px;
|
| 1779 |
+
background: rgba(229, 57, 53, 0.15);
|
| 1780 |
+
border-radius: 50%;
|
| 1781 |
+
display: flex;
|
| 1782 |
+
align-items: center;
|
| 1783 |
+
justify-content: center;
|
| 1784 |
+
flex-shrink: 0;
|
| 1785 |
+
}
|
| 1786 |
+
|
| 1787 |
+
.inline-error-icon svg {
|
| 1788 |
+
width: 20px;
|
| 1789 |
+
height: 20px;
|
| 1790 |
+
color: var(--error);
|
| 1791 |
+
}
|
| 1792 |
+
|
| 1793 |
+
.inline-error-content {
|
| 1794 |
+
flex: 1;
|
| 1795 |
+
}
|
| 1796 |
+
|
| 1797 |
+
.inline-error-title {
|
| 1798 |
+
font-size: 14px;
|
| 1799 |
+
font-weight: 600;
|
| 1800 |
+
color: var(--error);
|
| 1801 |
+
margin-bottom: 2px;
|
| 1802 |
+
}
|
| 1803 |
+
|
| 1804 |
+
.inline-error-message {
|
| 1805 |
+
font-size: 13px;
|
| 1806 |
+
color: var(--text-secondary);
|
| 1807 |
+
}
|
| 1808 |
+
|
| 1809 |
+
.inline-error-action {
|
| 1810 |
+
padding: 8px 16px;
|
| 1811 |
+
background: var(--error);
|
| 1812 |
+
color: white;
|
| 1813 |
+
border: none;
|
| 1814 |
+
border-radius: 6px;
|
| 1815 |
+
font-size: 13px;
|
| 1816 |
+
font-weight: 500;
|
| 1817 |
+
cursor: pointer;
|
| 1818 |
+
transition: all var(--transition);
|
| 1819 |
+
}
|
| 1820 |
+
|
| 1821 |
+
.inline-error-action:hover {
|
| 1822 |
+
opacity: 0.9;
|
| 1823 |
+
}
|
| 1824 |
+
|
| 1825 |
+
|
| 1826 |
+
/* =============== Empty States =============== */
|
| 1827 |
+
.empty-state {
|
| 1828 |
+
padding: 60px 24px;
|
| 1829 |
+
text-align: center;
|
| 1830 |
+
}
|
| 1831 |
+
|
| 1832 |
+
.empty-state-illustration {
|
| 1833 |
+
width: 120px;
|
| 1834 |
+
height: 120px;
|
| 1835 |
+
margin: 0 auto 24px;
|
| 1836 |
+
background: var(--primary-light);
|
| 1837 |
+
border-radius: 50%;
|
| 1838 |
+
display: flex;
|
| 1839 |
+
align-items: center;
|
| 1840 |
+
justify-content: center;
|
| 1841 |
+
position: relative;
|
| 1842 |
+
}
|
| 1843 |
+
|
| 1844 |
+
.empty-state-illustration::before {
|
| 1845 |
+
content: '';
|
| 1846 |
+
position: absolute;
|
| 1847 |
+
inset: -4px;
|
| 1848 |
+
border-radius: 50%;
|
| 1849 |
+
border: 2px dashed var(--border);
|
| 1850 |
+
animation: emptyRotate 20s linear infinite;
|
| 1851 |
+
}
|
| 1852 |
+
|
| 1853 |
+
@keyframes emptyRotate {
|
| 1854 |
+
from { transform: rotate(0deg); }
|
| 1855 |
+
to { transform: rotate(360deg); }
|
| 1856 |
+
}
|
| 1857 |
+
|
| 1858 |
+
.empty-state-illustration svg {
|
| 1859 |
+
width: 48px;
|
| 1860 |
+
height: 48px;
|
| 1861 |
+
color: var(--primary);
|
| 1862 |
+
}
|
| 1863 |
+
|
| 1864 |
+
.empty-state-title {
|
| 1865 |
+
font-size: 20px;
|
| 1866 |
+
font-weight: 600;
|
| 1867 |
+
color: var(--text);
|
| 1868 |
+
margin-bottom: 8px;
|
| 1869 |
+
}
|
| 1870 |
+
|
| 1871 |
+
.empty-state-message {
|
| 1872 |
+
font-size: 14px;
|
| 1873 |
+
color: var(--text-secondary);
|
| 1874 |
+
margin-bottom: 24px;
|
| 1875 |
+
max-width: 360px;
|
| 1876 |
+
margin-left: auto;
|
| 1877 |
+
margin-right: auto;
|
| 1878 |
+
line-height: 1.6;
|
| 1879 |
+
}
|
| 1880 |
+
|
| 1881 |
+
.empty-state-actions {
|
| 1882 |
+
display: flex;
|
| 1883 |
+
gap: 12px;
|
| 1884 |
+
justify-content: center;
|
| 1885 |
+
flex-wrap: wrap;
|
| 1886 |
+
}
|
| 1887 |
+
|
| 1888 |
+
/* Empty state for preview */
|
| 1889 |
+
.preview-empty {
|
| 1890 |
+
height: 100%;
|
| 1891 |
+
min-height: 400px;
|
| 1892 |
+
display: flex;
|
| 1893 |
+
flex-direction: column;
|
| 1894 |
+
align-items: center;
|
| 1895 |
+
justify-content: center;
|
| 1896 |
+
background: var(--border-light);
|
| 1897 |
+
border-radius: var(--radius);
|
| 1898 |
+
border: 2px dashed var(--border);
|
| 1899 |
+
}
|
| 1900 |
+
|
| 1901 |
+
.preview-empty-icon {
|
| 1902 |
+
width: 80px;
|
| 1903 |
+
height: 80px;
|
| 1904 |
+
background: var(--card);
|
| 1905 |
+
border-radius: 50%;
|
| 1906 |
+
display: flex;
|
| 1907 |
+
align-items: center;
|
| 1908 |
+
justify-content: center;
|
| 1909 |
+
margin-bottom: 20px;
|
| 1910 |
+
box-shadow: var(--shadow);
|
| 1911 |
+
}
|
| 1912 |
+
|
| 1913 |
+
.preview-empty-icon svg {
|
| 1914 |
+
width: 36px;
|
| 1915 |
+
height: 36px;
|
| 1916 |
+
color: var(--text-muted);
|
| 1917 |
+
}
|
| 1918 |
+
|
| 1919 |
+
.preview-empty-title {
|
| 1920 |
+
font-size: 16px;
|
| 1921 |
+
font-weight: 600;
|
| 1922 |
+
color: var(--text);
|
| 1923 |
+
margin-bottom: 6px;
|
| 1924 |
+
}
|
| 1925 |
+
|
| 1926 |
+
.preview-empty-message {
|
| 1927 |
+
font-size: 13px;
|
| 1928 |
+
color: var(--text-muted);
|
| 1929 |
+
}
|
| 1930 |
+
|
| 1931 |
+
/* Empty state for image grid */
|
| 1932 |
+
.image-grid-empty {
|
| 1933 |
+
grid-column: 1 / -1;
|
| 1934 |
+
padding: 60px 24px;
|
| 1935 |
+
text-align: center;
|
| 1936 |
+
background: var(--border-light);
|
| 1937 |
+
border-radius: var(--radius);
|
| 1938 |
+
border: 2px dashed var(--border);
|
| 1939 |
+
}
|
| 1940 |
+
|
| 1941 |
+
.image-grid-empty-icon {
|
| 1942 |
+
width: 72px;
|
| 1943 |
+
height: 72px;
|
| 1944 |
+
background: var(--card);
|
| 1945 |
+
border-radius: 50%;
|
| 1946 |
+
display: flex;
|
| 1947 |
+
align-items: center;
|
| 1948 |
+
justify-content: center;
|
| 1949 |
+
margin: 0 auto 16px;
|
| 1950 |
+
box-shadow: var(--shadow);
|
| 1951 |
+
}
|
| 1952 |
+
|
| 1953 |
+
.image-grid-empty-icon svg {
|
| 1954 |
+
width: 32px;
|
| 1955 |
+
height: 32px;
|
| 1956 |
+
color: var(--text-muted);
|
| 1957 |
+
}
|
| 1958 |
+
|
| 1959 |
+
.image-grid-empty-title {
|
| 1960 |
+
font-size: 16px;
|
| 1961 |
+
font-weight: 600;
|
| 1962 |
+
color: var(--text);
|
| 1963 |
+
margin-bottom: 6px;
|
| 1964 |
+
}
|
| 1965 |
+
|
| 1966 |
+
.image-grid-empty-message {
|
| 1967 |
+
font-size: 13px;
|
| 1968 |
+
color: var(--text-muted);
|
| 1969 |
+
max-width: 300px;
|
| 1970 |
+
margin: 0 auto;
|
| 1971 |
+
}
|
| 1972 |
+
|
| 1973 |
+
|
| 1974 |
+
/* =============== Help Tips =============== */
|
| 1975 |
+
.help-tip {
|
| 1976 |
+
display: inline-flex;
|
| 1977 |
+
align-items: center;
|
| 1978 |
+
justify-content: center;
|
| 1979 |
+
width: 18px;
|
| 1980 |
+
height: 18px;
|
| 1981 |
+
background: var(--border-light);
|
| 1982 |
+
border: 1px solid var(--border);
|
| 1983 |
+
border-radius: 50%;
|
| 1984 |
+
font-size: 11px;
|
| 1985 |
+
font-weight: 600;
|
| 1986 |
+
color: var(--text-muted);
|
| 1987 |
+
cursor: help;
|
| 1988 |
+
margin-left: 6px;
|
| 1989 |
+
transition: all var(--transition);
|
| 1990 |
+
}
|
| 1991 |
+
|
| 1992 |
+
.help-tip:hover {
|
| 1993 |
+
background: var(--primary-light);
|
| 1994 |
+
border-color: var(--primary);
|
| 1995 |
+
color: var(--primary);
|
| 1996 |
+
}
|
| 1997 |
+
|
| 1998 |
+
/* =============== Feature Hints =============== */
|
| 1999 |
+
.feature-hint {
|
| 2000 |
+
display: flex;
|
| 2001 |
+
align-items: flex-start;
|
| 2002 |
+
gap: 12px;
|
| 2003 |
+
padding: 14px 16px;
|
| 2004 |
+
background: var(--primary-light);
|
| 2005 |
+
border: 1px solid rgba(139, 195, 74, 0.2);
|
| 2006 |
+
border-radius: var(--radius-sm);
|
| 2007 |
+
margin-bottom: 20px;
|
| 2008 |
+
}
|
| 2009 |
+
|
| 2010 |
+
.feature-hint-icon {
|
| 2011 |
+
width: 36px;
|
| 2012 |
+
height: 36px;
|
| 2013 |
+
background: var(--primary);
|
| 2014 |
+
border-radius: 8px;
|
| 2015 |
+
display: flex;
|
| 2016 |
+
align-items: center;
|
| 2017 |
+
justify-content: center;
|
| 2018 |
+
flex-shrink: 0;
|
| 2019 |
+
}
|
| 2020 |
+
|
| 2021 |
+
.feature-hint-icon svg {
|
| 2022 |
+
width: 18px;
|
| 2023 |
+
height: 18px;
|
| 2024 |
+
color: white;
|
| 2025 |
+
}
|
| 2026 |
+
|
| 2027 |
+
.feature-hint-content {
|
| 2028 |
+
flex: 1;
|
| 2029 |
+
}
|
| 2030 |
+
|
| 2031 |
+
.feature-hint-title {
|
| 2032 |
+
font-size: 14px;
|
| 2033 |
+
font-weight: 600;
|
| 2034 |
+
color: var(--text);
|
| 2035 |
+
margin-bottom: 2px;
|
| 2036 |
+
}
|
| 2037 |
+
|
| 2038 |
+
.feature-hint-message {
|
| 2039 |
+
font-size: 13px;
|
| 2040 |
+
color: var(--text-secondary);
|
| 2041 |
+
}
|
| 2042 |
+
|
| 2043 |
+
.feature-hint-dismiss {
|
| 2044 |
+
padding: 4px;
|
| 2045 |
+
background: none;
|
| 2046 |
+
border: none;
|
| 2047 |
+
color: var(--text-muted);
|
| 2048 |
+
cursor: pointer;
|
| 2049 |
+
border-radius: 4px;
|
| 2050 |
+
transition: all var(--transition);
|
| 2051 |
+
}
|
| 2052 |
+
|
| 2053 |
+
.feature-hint-dismiss:hover {
|
| 2054 |
+
background: var(--border-light);
|
| 2055 |
+
color: var(--text);
|
| 2056 |
+
}
|
| 2057 |
+
|
| 2058 |
+
/* =============== Setup Notice =============== */
|
| 2059 |
+
.setup-notice {
|
| 2060 |
+
background: var(--primary-light);
|
| 2061 |
+
border: 1px solid rgba(139, 195, 74, 0.25);
|
| 2062 |
+
border-radius: var(--radius-sm);
|
| 2063 |
+
margin-bottom: 12px;
|
| 2064 |
+
overflow: hidden;
|
| 2065 |
+
}
|
| 2066 |
+
|
| 2067 |
+
.setup-notice-header {
|
| 2068 |
+
display: flex;
|
| 2069 |
+
align-items: center;
|
| 2070 |
+
gap: 8px;
|
| 2071 |
+
padding: 10px 12px;
|
| 2072 |
+
background: rgba(139, 195, 74, 0.1);
|
| 2073 |
+
border-bottom: 1px solid rgba(139, 195, 74, 0.15);
|
| 2074 |
+
font-size: 12px;
|
| 2075 |
+
font-weight: 600;
|
| 2076 |
+
color: var(--primary);
|
| 2077 |
+
}
|
| 2078 |
+
|
| 2079 |
+
.setup-notice-header svg {
|
| 2080 |
+
width: 16px;
|
| 2081 |
+
height: 16px;
|
| 2082 |
+
}
|
| 2083 |
+
|
| 2084 |
+
.setup-notice-close {
|
| 2085 |
+
margin-left: auto;
|
| 2086 |
+
background: none;
|
| 2087 |
+
border: none;
|
| 2088 |
+
font-size: 18px;
|
| 2089 |
+
color: var(--text-muted);
|
| 2090 |
+
cursor: pointer;
|
| 2091 |
+
padding: 0 4px;
|
| 2092 |
+
line-height: 1;
|
| 2093 |
+
border-radius: 4px;
|
| 2094 |
+
transition: all var(--transition);
|
| 2095 |
+
}
|
| 2096 |
+
|
| 2097 |
+
.setup-notice-close:hover {
|
| 2098 |
+
background: rgba(139, 195, 74, 0.2);
|
| 2099 |
+
color: var(--text);
|
| 2100 |
+
}
|
| 2101 |
+
|
| 2102 |
+
.setup-notice-body {
|
| 2103 |
+
padding: 12px;
|
| 2104 |
+
}
|
| 2105 |
+
|
| 2106 |
+
.setup-notice-body p {
|
| 2107 |
+
font-size: 11px;
|
| 2108 |
+
color: var(--text-secondary);
|
| 2109 |
+
margin-bottom: 8px;
|
| 2110 |
+
}
|
| 2111 |
+
|
| 2112 |
+
.setup-notice-code {
|
| 2113 |
+
display: flex;
|
| 2114 |
+
align-items: center;
|
| 2115 |
+
gap: 8px;
|
| 2116 |
+
background: var(--card-solid);
|
| 2117 |
+
border: 1px solid var(--border);
|
| 2118 |
+
border-radius: 6px;
|
| 2119 |
+
padding: 8px 10px;
|
| 2120 |
+
margin-bottom: 8px;
|
| 2121 |
+
}
|
| 2122 |
+
|
| 2123 |
+
.setup-notice-code code {
|
| 2124 |
+
flex: 1;
|
| 2125 |
+
font-family: 'Consolas', 'Monaco', monospace;
|
| 2126 |
+
font-size: 11px;
|
| 2127 |
+
color: var(--text);
|
| 2128 |
+
word-break: break-all;
|
| 2129 |
+
}
|
| 2130 |
+
|
| 2131 |
+
.copy-btn {
|
| 2132 |
+
background: var(--border-light);
|
| 2133 |
+
border: none;
|
| 2134 |
+
padding: 4px 6px;
|
| 2135 |
+
border-radius: 4px;
|
| 2136 |
+
cursor: pointer;
|
| 2137 |
+
color: var(--text-muted);
|
| 2138 |
+
transition: all var(--transition);
|
| 2139 |
+
display: flex;
|
| 2140 |
+
align-items: center;
|
| 2141 |
+
justify-content: center;
|
| 2142 |
+
}
|
| 2143 |
+
|
| 2144 |
+
.copy-btn:hover {
|
| 2145 |
+
background: var(--primary-light);
|
| 2146 |
+
color: var(--primary);
|
| 2147 |
+
}
|
| 2148 |
+
|
| 2149 |
+
.copy-btn.copied {
|
| 2150 |
+
background: var(--success-light);
|
| 2151 |
+
color: var(--success);
|
| 2152 |
+
}
|
| 2153 |
+
|
| 2154 |
+
.setup-notice-hint {
|
| 2155 |
+
font-size: 10px !important;
|
| 2156 |
+
color: var(--text-muted) !important;
|
| 2157 |
+
margin-bottom: 0 !important;
|
| 2158 |
+
margin-top: 4px;
|
| 2159 |
+
}
|
| 2160 |
+
|
| 2161 |
+
.setup-notice-hint strong {
|
| 2162 |
+
color: var(--primary);
|
| 2163 |
+
}
|
| 2164 |
+
|
| 2165 |
+
.setup-notice-or {
|
| 2166 |
+
font-size: 10px !important;
|
| 2167 |
+
color: var(--text-muted) !important;
|
| 2168 |
+
text-align: center;
|
| 2169 |
+
margin: 4px 0 !important;
|
| 2170 |
+
}
|
| 2171 |
+
|
| 2172 |
+
/* Hidden state */
|
| 2173 |
+
.setup-notice.hidden {
|
| 2174 |
+
display: none;
|
| 2175 |
+
}
|
static/index.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/js/app.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LocalTools - Main Application
|
| 3 |
+
* Handles navigation, theme, and global features
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 7 |
+
initTheme();
|
| 8 |
+
initNavigation();
|
| 9 |
+
initMobileMenu();
|
| 10 |
+
initSetupNotice();
|
| 11 |
+
initPdfEditor();
|
| 12 |
+
initImageScraper();
|
| 13 |
+
initGlobalShortcuts();
|
| 14 |
+
initPdfTools();
|
| 15 |
+
initImageTools();
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Theme Management
|
| 20 |
+
*/
|
| 21 |
+
function initTheme() {
|
| 22 |
+
const themeToggle = document.getElementById('themeToggle');
|
| 23 |
+
const savedTheme = localStorage.getItem('theme') || 'light';
|
| 24 |
+
|
| 25 |
+
document.documentElement.setAttribute('data-theme', savedTheme);
|
| 26 |
+
|
| 27 |
+
themeToggle.addEventListener('click', () => {
|
| 28 |
+
const currentTheme = document.documentElement.getAttribute('data-theme');
|
| 29 |
+
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
| 30 |
+
|
| 31 |
+
document.documentElement.setAttribute('data-theme', newTheme);
|
| 32 |
+
localStorage.setItem('theme', newTheme);
|
| 33 |
+
|
| 34 |
+
showToast(
|
| 35 |
+
newTheme === 'dark' ? 'Dark Mode' : 'Light Mode',
|
| 36 |
+
'Theme updated',
|
| 37 |
+
'success',
|
| 38 |
+
2000
|
| 39 |
+
);
|
| 40 |
+
});
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Setup Notice
|
| 45 |
+
*/
|
| 46 |
+
function initSetupNotice() {
|
| 47 |
+
const notice = document.getElementById('setupNotice');
|
| 48 |
+
const closeBtn = document.getElementById('closeSetupNotice');
|
| 49 |
+
|
| 50 |
+
// Check if dismissed
|
| 51 |
+
if (localStorage.getItem('setupNoticeDismissed')) {
|
| 52 |
+
notice.classList.add('hidden');
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Close button
|
| 56 |
+
closeBtn.addEventListener('click', () => {
|
| 57 |
+
notice.classList.add('hidden');
|
| 58 |
+
localStorage.setItem('setupNoticeDismissed', 'true');
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
// Copy buttons
|
| 62 |
+
document.querySelectorAll('.copy-btn').forEach(btn => {
|
| 63 |
+
btn.addEventListener('click', async () => {
|
| 64 |
+
const text = btn.dataset.copy;
|
| 65 |
+
|
| 66 |
+
try {
|
| 67 |
+
await navigator.clipboard.writeText(text);
|
| 68 |
+
btn.classList.add('copied');
|
| 69 |
+
showToast('Copied!', text, 'success', 2000);
|
| 70 |
+
|
| 71 |
+
setTimeout(() => {
|
| 72 |
+
btn.classList.remove('copied');
|
| 73 |
+
}, 2000);
|
| 74 |
+
} catch (err) {
|
| 75 |
+
showToast('Copy failed', 'Please copy manually', 'error');
|
| 76 |
+
}
|
| 77 |
+
});
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* Sidebar Navigation
|
| 83 |
+
*/
|
| 84 |
+
function initNavigation() {
|
| 85 |
+
const navItems = document.querySelectorAll('.nav-item[data-page]');
|
| 86 |
+
const pages = document.querySelectorAll('.page');
|
| 87 |
+
|
| 88 |
+
navItems.forEach(item => {
|
| 89 |
+
item.addEventListener('click', () => {
|
| 90 |
+
const targetPage = item.dataset.page;
|
| 91 |
+
switchToPage(targetPage);
|
| 92 |
+
|
| 93 |
+
// Close mobile menu
|
| 94 |
+
closeMobileMenu();
|
| 95 |
+
});
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function switchToPage(pageName) {
|
| 100 |
+
const navItems = document.querySelectorAll('.nav-item[data-page]');
|
| 101 |
+
const pages = document.querySelectorAll('.page');
|
| 102 |
+
|
| 103 |
+
navItems.forEach(item => {
|
| 104 |
+
item.classList.toggle('active', item.dataset.page === pageName);
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
pages.forEach(page => {
|
| 108 |
+
page.classList.toggle('active', page.id === `page-${pageName}`);
|
| 109 |
+
});
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* Mobile Menu
|
| 114 |
+
*/
|
| 115 |
+
function initMobileMenu() {
|
| 116 |
+
const menuBtn = document.getElementById('mobileMenuBtn');
|
| 117 |
+
const sidebar = document.getElementById('sidebar');
|
| 118 |
+
const overlay = document.getElementById('sidebarOverlay');
|
| 119 |
+
|
| 120 |
+
menuBtn.addEventListener('click', () => {
|
| 121 |
+
sidebar.classList.toggle('open');
|
| 122 |
+
overlay.classList.toggle('active');
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
overlay.addEventListener('click', closeMobileMenu);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function closeMobileMenu() {
|
| 129 |
+
const sidebar = document.getElementById('sidebar');
|
| 130 |
+
const overlay = document.getElementById('sidebarOverlay');
|
| 131 |
+
sidebar.classList.remove('open');
|
| 132 |
+
overlay.classList.remove('active');
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* Global Keyboard Shortcuts
|
| 137 |
+
*/
|
| 138 |
+
function initGlobalShortcuts() {
|
| 139 |
+
registerShortcut('1', () => switchToPage('pdf'), 'PDF Editor');
|
| 140 |
+
registerShortcut('2', () => switchToPage('watermark'), 'Watermark Remover');
|
| 141 |
+
registerShortcut('3', () => switchToPage('img2pdf'), 'Images to PDF');
|
| 142 |
+
registerShortcut('4', () => switchToPage('merge'), 'Merge PDFs');
|
| 143 |
+
registerShortcut('5', () => switchToPage('split'), 'Split PDF');
|
| 144 |
+
registerShortcut('6', () => switchToPage('images'), 'Image Scraper');
|
| 145 |
+
|
| 146 |
+
registerShortcut('ctrl+k', () => {
|
| 147 |
+
document.getElementById('themeToggle').click();
|
| 148 |
+
}, 'Toggle Theme');
|
| 149 |
+
|
| 150 |
+
registerShortcut('?', () => {
|
| 151 |
+
showKeyboardShortcutsModal();
|
| 152 |
+
}, 'Show Shortcuts');
|
| 153 |
+
|
| 154 |
+
// Nav button handlers
|
| 155 |
+
document.getElementById('navRecentFiles').addEventListener('click', showRecentFilesModal);
|
| 156 |
+
document.getElementById('navKeyboardShortcuts').addEventListener('click', showKeyboardShortcutsModal);
|
| 157 |
+
|
| 158 |
+
// Initialize watermark removal page
|
| 159 |
+
initWatermarkRemoval();
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/**
|
| 163 |
+
* Recent Files Modal
|
| 164 |
+
*/
|
| 165 |
+
function showRecentFilesModal() {
|
| 166 |
+
const recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
|
| 167 |
+
|
| 168 |
+
let content = '';
|
| 169 |
+
if (recentFiles.length === 0) {
|
| 170 |
+
content = `
|
| 171 |
+
<div class="empty-state" style="padding: 40px 20px;">
|
| 172 |
+
<div class="empty-state-illustration" style="width: 80px; height: 80px;">
|
| 173 |
+
<svg width="32" height="32" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 174 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 175 |
+
</svg>
|
| 176 |
+
</div>
|
| 177 |
+
<div class="empty-state-title" style="font-size: 16px;">No Recent Files</div>
|
| 178 |
+
<div class="empty-state-message" style="font-size: 13px;">Files you work with will appear here</div>
|
| 179 |
+
</div>
|
| 180 |
+
`;
|
| 181 |
+
} else {
|
| 182 |
+
content = `
|
| 183 |
+
<div style="padding: 16px;">
|
| 184 |
+
<div style="font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text);">Recent Files</div>
|
| 185 |
+
<div style="display: flex; flex-direction: column; gap: 8px;">
|
| 186 |
+
${recentFiles.map(file => `
|
| 187 |
+
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--border-light); border-radius: 8px;">
|
| 188 |
+
<svg width="20" height="20" fill="none" stroke="var(--primary)" viewBox="0 0 24 24">
|
| 189 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
| 190 |
+
</svg>
|
| 191 |
+
<div style="flex: 1; min-width: 0;">
|
| 192 |
+
<div style="font-size: 13px; font-weight: 500; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${file.name}</div>
|
| 193 |
+
<div style="font-size: 11px; color: var(--text-muted);">${file.date}</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
`).join('')}
|
| 197 |
+
</div>
|
| 198 |
+
<button class="btn btn-secondary" style="width: 100%; margin-top: 12px;" onclick="clearRecentFiles()">Clear History</button>
|
| 199 |
+
</div>
|
| 200 |
+
`;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
showModal('Recent Files', content);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function addToRecentFiles(name, type) {
|
| 207 |
+
const recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
|
| 208 |
+
const newFile = {
|
| 209 |
+
name: name,
|
| 210 |
+
type: type,
|
| 211 |
+
date: new Date().toLocaleDateString()
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
// Remove duplicate if exists
|
| 215 |
+
const filtered = recentFiles.filter(f => f.name !== name);
|
| 216 |
+
filtered.unshift(newFile);
|
| 217 |
+
|
| 218 |
+
// Keep only last 10
|
| 219 |
+
localStorage.setItem('recentFiles', JSON.stringify(filtered.slice(0, 10)));
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function clearRecentFiles() {
|
| 223 |
+
localStorage.removeItem('recentFiles');
|
| 224 |
+
closeModal();
|
| 225 |
+
showToast('Cleared', 'Recent files history cleared', 'success', 2000);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/**
|
| 229 |
+
* Keyboard Shortcuts Modal
|
| 230 |
+
*/
|
| 231 |
+
function showKeyboardShortcutsModal() {
|
| 232 |
+
const shortcuts = [
|
| 233 |
+
{ key: '1', desc: 'PDF Editor' },
|
| 234 |
+
{ key: '2', desc: 'Watermark Remover' },
|
| 235 |
+
{ key: '3', desc: 'Images to PDF' },
|
| 236 |
+
{ key: '4', desc: 'Merge PDFs' },
|
| 237 |
+
{ key: '5', desc: 'Split PDF' },
|
| 238 |
+
{ key: '6', desc: 'Image Scraper' },
|
| 239 |
+
{ key: 'Ctrl + K', desc: 'Toggle theme' },
|
| 240 |
+
{ key: '?', desc: 'Show this help' },
|
| 241 |
+
];
|
| 242 |
+
|
| 243 |
+
const content = `
|
| 244 |
+
<div style="padding: 16px;">
|
| 245 |
+
<div style="display: flex; flex-direction: column; gap: 8px;">
|
| 246 |
+
${shortcuts.map(s => `
|
| 247 |
+
<div style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: var(--border-light); border-radius: 8px;">
|
| 248 |
+
<span style="font-size: 13px; color: var(--text-secondary);">${s.desc}</span>
|
| 249 |
+
<kbd class="kbd" style="margin-left: 0;">${s.key}</kbd>
|
| 250 |
+
</div>
|
| 251 |
+
`).join('')}
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
`;
|
| 255 |
+
|
| 256 |
+
showModal('Keyboard Shortcuts', content);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
/**
|
| 260 |
+
* Generic Modal
|
| 261 |
+
*/
|
| 262 |
+
function showModal(title, content) {
|
| 263 |
+
// Remove existing modal if any
|
| 264 |
+
closeModal();
|
| 265 |
+
|
| 266 |
+
const modal = document.createElement('div');
|
| 267 |
+
modal.className = 'modal-overlay active';
|
| 268 |
+
modal.id = 'genericModal';
|
| 269 |
+
modal.innerHTML = `
|
| 270 |
+
<div class="modal-content" style="max-width: 400px; width: 100%;">
|
| 271 |
+
<button class="modal-close" onclick="closeModal()">
|
| 272 |
+
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 273 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
| 274 |
+
</svg>
|
| 275 |
+
</button>
|
| 276 |
+
<div style="padding: 20px 24px; border-bottom: 1px solid var(--border);">
|
| 277 |
+
<h3 style="font-size: 16px; font-weight: 600; color: var(--text);">${title}</h3>
|
| 278 |
+
</div>
|
| 279 |
+
${content}
|
| 280 |
+
</div>
|
| 281 |
+
`;
|
| 282 |
+
|
| 283 |
+
modal.addEventListener('click', (e) => {
|
| 284 |
+
if (e.target === modal) closeModal();
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
document.body.appendChild(modal);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function closeModal() {
|
| 291 |
+
const modal = document.getElementById('genericModal');
|
| 292 |
+
if (modal) modal.remove();
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
// Close modal on Escape
|
| 296 |
+
document.addEventListener('keydown', (e) => {
|
| 297 |
+
if (e.key === 'Escape') closeModal();
|
| 298 |
+
});
|
static/js/image-scraper.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Image Scraper Module
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
let currentJobId = null;
|
| 6 |
+
let currentImages = [];
|
| 7 |
+
let imageStepIndicator = null;
|
| 8 |
+
|
| 9 |
+
function initImageScraper() {
|
| 10 |
+
const elements = {
|
| 11 |
+
grid: document.getElementById('imgGrid'),
|
| 12 |
+
counter: document.getElementById('img_counter'),
|
| 13 |
+
toolbar: document.getElementById('imageToolbar'),
|
| 14 |
+
skeleton: document.getElementById('imageSkeleton'),
|
| 15 |
+
btnScrape: document.getElementById('btnScrape'),
|
| 16 |
+
btnSelectAll: document.getElementById('btnSelectAll'),
|
| 17 |
+
btnSelectNone: document.getElementById('btnSelectNone'),
|
| 18 |
+
btnZip: document.getElementById('btnZip'),
|
| 19 |
+
btnPdf: document.getElementById('btnPdf'),
|
| 20 |
+
zipName: document.getElementById('zip_name'),
|
| 21 |
+
pdfName: document.getElementById('pdf_name'),
|
| 22 |
+
pageUrl: document.getElementById('page_url'),
|
| 23 |
+
progress: document.getElementById('imgProgress')
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
// Initialize step indicator
|
| 27 |
+
imageStepIndicator = new StepIndicator('imageStepIndicator', [
|
| 28 |
+
'Enter URL',
|
| 29 |
+
'Fetch Images',
|
| 30 |
+
'Select',
|
| 31 |
+
'Download'
|
| 32 |
+
]);
|
| 33 |
+
|
| 34 |
+
// Show empty state initially
|
| 35 |
+
showImageGridEmpty(elements.grid);
|
| 36 |
+
|
| 37 |
+
// Event listeners
|
| 38 |
+
elements.btnScrape.addEventListener('click', () => handleScrapeImages(elements));
|
| 39 |
+
elements.btnSelectAll.addEventListener('click', () => selectAllImages(elements));
|
| 40 |
+
elements.btnSelectNone.addEventListener('click', () => clearImageSelection(elements));
|
| 41 |
+
|
| 42 |
+
elements.btnZip.addEventListener('click', () => {
|
| 43 |
+
let name = elements.zipName.value.trim() || 'images.zip';
|
| 44 |
+
if (!name.toLowerCase().endsWith('.zip')) name += '.zip';
|
| 45 |
+
handleDownload('/api/download-zip', 'zip_name', name, elements);
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
elements.btnPdf.addEventListener('click', () => {
|
| 49 |
+
let name = elements.pdfName.value.trim() || 'images.pdf';
|
| 50 |
+
if (!name.toLowerCase().endsWith('.pdf')) name += '.pdf';
|
| 51 |
+
handleDownload('/api/download-pdf', 'pdf_name', name, elements);
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
// URL input - update step
|
| 55 |
+
elements.pageUrl.addEventListener('input', () => {
|
| 56 |
+
if (elements.pageUrl.value.trim()) {
|
| 57 |
+
imageStepIndicator.setStep(1);
|
| 58 |
+
}
|
| 59 |
+
clearFieldError(elements.pageUrl.closest('.form-group'));
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
// URL validation
|
| 63 |
+
elements.pageUrl.addEventListener('blur', () => {
|
| 64 |
+
const value = elements.pageUrl.value.trim();
|
| 65 |
+
if (value) validateUrl(elements.pageUrl);
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
// Enter to scrape
|
| 69 |
+
elements.pageUrl.addEventListener('keydown', (e) => {
|
| 70 |
+
if (e.key === 'Enter') {
|
| 71 |
+
e.preventDefault();
|
| 72 |
+
elements.btnScrape.click();
|
| 73 |
+
}
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
// Keyboard shortcuts
|
| 77 |
+
registerShortcut('ctrl+a', () => {
|
| 78 |
+
if (document.getElementById('page-images').classList.contains('active') && currentImages.length > 0) {
|
| 79 |
+
selectAllImages(elements);
|
| 80 |
+
}
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
registerShortcut('ctrl+d', () => {
|
| 84 |
+
if (document.getElementById('page-images').classList.contains('active') && currentImages.length > 0) {
|
| 85 |
+
clearImageSelection(elements);
|
| 86 |
+
}
|
| 87 |
+
});
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function getSelectedIds(grid) {
|
| 91 |
+
const ids = [];
|
| 92 |
+
grid.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
| 93 |
+
if (cb.checked) ids.push(cb.dataset.id);
|
| 94 |
+
});
|
| 95 |
+
return ids;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function updateCounter(elements) {
|
| 99 |
+
const total = currentImages.length;
|
| 100 |
+
const selected = getSelectedIds(elements.grid).length;
|
| 101 |
+
elements.counter.textContent = `${total} images • ${selected} selected`;
|
| 102 |
+
|
| 103 |
+
// Update step indicator based on selection
|
| 104 |
+
if (selected > 0 && imageStepIndicator) {
|
| 105 |
+
imageStepIndicator.setStep(3); // Ready to download
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function setImageButtonsEnabled(elements, enabled) {
|
| 110 |
+
elements.btnSelectAll.disabled = !enabled;
|
| 111 |
+
elements.btnSelectNone.disabled = !enabled;
|
| 112 |
+
elements.btnZip.disabled = !enabled;
|
| 113 |
+
elements.btnPdf.disabled = !enabled;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function renderImages(images, elements) {
|
| 117 |
+
elements.grid.innerHTML = '';
|
| 118 |
+
|
| 119 |
+
images.forEach(img => {
|
| 120 |
+
const card = createImageCard(img, elements);
|
| 121 |
+
elements.grid.appendChild(card);
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
updateCounter(elements);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function createImageCard(img, elements) {
|
| 128 |
+
const card = document.createElement('div');
|
| 129 |
+
card.className = 'image-card';
|
| 130 |
+
|
| 131 |
+
// Preview button
|
| 132 |
+
const previewBtn = document.createElement('button');
|
| 133 |
+
previewBtn.className = 'image-preview-btn';
|
| 134 |
+
previewBtn.innerHTML = `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"/></svg>`;
|
| 135 |
+
previewBtn.addEventListener('click', (e) => {
|
| 136 |
+
e.stopPropagation();
|
| 137 |
+
const dims = (img.width && img.height) ? `${img.width}×${img.height}` : null;
|
| 138 |
+
const size = img.bytes ? formatFileSize(img.bytes) : null;
|
| 139 |
+
showImageModal(img.url, img.filename, dims, size);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
// Thumbnail
|
| 143 |
+
const thumb = document.createElement('img');
|
| 144 |
+
thumb.className = 'image-thumb';
|
| 145 |
+
thumb.src = img.url;
|
| 146 |
+
thumb.loading = 'lazy';
|
| 147 |
+
thumb.referrerPolicy = 'no-referrer';
|
| 148 |
+
thumb.alt = img.filename;
|
| 149 |
+
|
| 150 |
+
// Meta
|
| 151 |
+
const meta = document.createElement('div');
|
| 152 |
+
meta.className = 'image-meta';
|
| 153 |
+
|
| 154 |
+
const filename = document.createElement('div');
|
| 155 |
+
filename.className = 'image-filename';
|
| 156 |
+
filename.title = img.filename;
|
| 157 |
+
filename.textContent = img.filename;
|
| 158 |
+
|
| 159 |
+
const info = document.createElement('div');
|
| 160 |
+
info.className = 'image-info';
|
| 161 |
+
|
| 162 |
+
const sizeBadge = document.createElement('span');
|
| 163 |
+
sizeBadge.className = 'image-badge';
|
| 164 |
+
sizeBadge.textContent = (img.width && img.height) ? `${img.width}×${img.height}` : '?';
|
| 165 |
+
|
| 166 |
+
const checkboxContainer = document.createElement('div');
|
| 167 |
+
checkboxContainer.className = 'image-checkbox';
|
| 168 |
+
|
| 169 |
+
const checkbox = document.createElement('input');
|
| 170 |
+
checkbox.type = 'checkbox';
|
| 171 |
+
checkbox.dataset.id = img.id;
|
| 172 |
+
checkbox.addEventListener('click', e => e.stopPropagation());
|
| 173 |
+
checkbox.addEventListener('change', () => {
|
| 174 |
+
card.classList.toggle('selected', checkbox.checked);
|
| 175 |
+
updateCounter(elements);
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
checkboxContainer.appendChild(checkbox);
|
| 179 |
+
checkboxContainer.appendChild(document.createTextNode('Select'));
|
| 180 |
+
|
| 181 |
+
info.appendChild(sizeBadge);
|
| 182 |
+
info.appendChild(checkboxContainer);
|
| 183 |
+
meta.appendChild(filename);
|
| 184 |
+
meta.appendChild(info);
|
| 185 |
+
|
| 186 |
+
card.appendChild(previewBtn);
|
| 187 |
+
card.appendChild(thumb);
|
| 188 |
+
card.appendChild(meta);
|
| 189 |
+
|
| 190 |
+
card.addEventListener('click', () => {
|
| 191 |
+
checkbox.checked = !checkbox.checked;
|
| 192 |
+
card.classList.toggle('selected', checkbox.checked);
|
| 193 |
+
updateCounter(elements);
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
return card;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
async function handleScrapeImages(elements) {
|
| 200 |
+
const pageUrl = elements.pageUrl.value.trim();
|
| 201 |
+
|
| 202 |
+
if (!pageUrl) {
|
| 203 |
+
setFieldError(elements.pageUrl.closest('.form-group'), 'URL required');
|
| 204 |
+
showToast('URL Required', 'Enter a webpage URL', 'error');
|
| 205 |
+
return;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
if (!validateUrl(elements.pageUrl)) {
|
| 209 |
+
showToast('Invalid URL', 'Enter a valid URL', 'error');
|
| 210 |
+
return;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
setButtonLoading(elements.btnScrape, true, 'Scraping...');
|
| 214 |
+
setImageButtonsEnabled(elements, false);
|
| 215 |
+
elements.toolbar.style.display = 'none';
|
| 216 |
+
elements.skeleton.style.display = 'grid';
|
| 217 |
+
elements.grid.innerHTML = '';
|
| 218 |
+
currentJobId = null;
|
| 219 |
+
currentImages = [];
|
| 220 |
+
showProgress(elements.progress, true, true);
|
| 221 |
+
imageStepIndicator.setStep(1); // Fetching
|
| 222 |
+
|
| 223 |
+
try {
|
| 224 |
+
const fd = new FormData();
|
| 225 |
+
fd.append('page_url', pageUrl);
|
| 226 |
+
|
| 227 |
+
const res = await fetch('/api/scrape-images', { method: 'POST', body: fd });
|
| 228 |
+
|
| 229 |
+
if (!res.ok) {
|
| 230 |
+
let err = { detail: 'Request failed' };
|
| 231 |
+
try { err = await res.json(); } catch {}
|
| 232 |
+
throw new Error(err.detail || 'Request failed');
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
const data = await res.json();
|
| 236 |
+
currentJobId = data.job_id;
|
| 237 |
+
currentImages = data.images || [];
|
| 238 |
+
|
| 239 |
+
elements.skeleton.style.display = 'none';
|
| 240 |
+
|
| 241 |
+
if (currentImages.length > 0) {
|
| 242 |
+
renderImages(currentImages, elements);
|
| 243 |
+
elements.toolbar.style.display = 'flex';
|
| 244 |
+
setImageButtonsEnabled(elements, true);
|
| 245 |
+
showToast('Scrape Complete', `Found ${currentImages.length} images`, 'success');
|
| 246 |
+
imageStepIndicator.setStep(2); // Select step
|
| 247 |
+
} else {
|
| 248 |
+
showImageGridEmpty(elements.grid);
|
| 249 |
+
showToast('No Images', 'No suitable images found on this page', 'info');
|
| 250 |
+
imageStepIndicator.setStep(0);
|
| 251 |
+
}
|
| 252 |
+
} catch (e) {
|
| 253 |
+
elements.skeleton.style.display = 'none';
|
| 254 |
+
|
| 255 |
+
// Show error state with retry action
|
| 256 |
+
showErrorState(elements.grid,
|
| 257 |
+
'Scraping Failed',
|
| 258 |
+
e.message || 'Could not fetch images from this URL. The page might be blocking requests or require authentication.',
|
| 259 |
+
[
|
| 260 |
+
{ id: 'retry', label: 'Try Again', primary: true, handler: () => handleScrapeImages(elements) },
|
| 261 |
+
{ id: 'clear', label: 'Clear', handler: () => {
|
| 262 |
+
elements.pageUrl.value = '';
|
| 263 |
+
showImageGridEmpty(elements.grid);
|
| 264 |
+
imageStepIndicator.reset();
|
| 265 |
+
}}
|
| 266 |
+
]
|
| 267 |
+
);
|
| 268 |
+
|
| 269 |
+
imageStepIndicator.setStep(0);
|
| 270 |
+
} finally {
|
| 271 |
+
setButtonLoading(elements.btnScrape, false);
|
| 272 |
+
showProgress(elements.progress, false);
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function selectAllImages(elements) {
|
| 277 |
+
elements.grid.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
| 278 |
+
cb.checked = true;
|
| 279 |
+
cb.closest('.image-card').classList.add('selected');
|
| 280 |
+
});
|
| 281 |
+
updateCounter(elements);
|
| 282 |
+
showToast('Selected All', `${currentImages.length} images`, 'success');
|
| 283 |
+
imageStepIndicator.setStep(3); // Ready to download
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function clearImageSelection(elements) {
|
| 287 |
+
elements.grid.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
| 288 |
+
cb.checked = false;
|
| 289 |
+
cb.closest('.image-card').classList.remove('selected');
|
| 290 |
+
});
|
| 291 |
+
updateCounter(elements);
|
| 292 |
+
showToast('Cleared', 'Selection cleared', 'info');
|
| 293 |
+
imageStepIndicator.setStep(2); // Back to select
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
async function handleDownload(endpoint, nameField, nameValue, elements) {
|
| 297 |
+
if (!currentJobId) {
|
| 298 |
+
showToast('No Images', 'Fetch images first', 'error');
|
| 299 |
+
return;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
const ids = getSelectedIds(elements.grid);
|
| 303 |
+
|
| 304 |
+
if (ids.length === 0) {
|
| 305 |
+
showToast('No Selection', 'Select at least one image', 'error');
|
| 306 |
+
return;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
const isZip = nameField === 'zip_name';
|
| 310 |
+
const btn = isZip ? elements.btnZip : elements.btnPdf;
|
| 311 |
+
|
| 312 |
+
setButtonLoading(btn, true, 'Preparing...');
|
| 313 |
+
showProgress(elements.progress, true, true);
|
| 314 |
+
|
| 315 |
+
try {
|
| 316 |
+
const fd = new FormData();
|
| 317 |
+
fd.append('job_id', currentJobId);
|
| 318 |
+
fd.append('image_ids', ids.join(','));
|
| 319 |
+
fd.append(nameField, nameValue);
|
| 320 |
+
|
| 321 |
+
const res = await fetch(endpoint, { method: 'POST', body: fd });
|
| 322 |
+
|
| 323 |
+
if (!res.ok) {
|
| 324 |
+
let err = { detail: 'Request failed' };
|
| 325 |
+
try { err = await res.json(); } catch {}
|
| 326 |
+
throw new Error(err.detail || 'Request failed');
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
const blob = await res.blob();
|
| 330 |
+
|
| 331 |
+
const url = URL.createObjectURL(blob);
|
| 332 |
+
const a = document.createElement('a');
|
| 333 |
+
a.href = url;
|
| 334 |
+
a.download = nameValue;
|
| 335 |
+
document.body.appendChild(a);
|
| 336 |
+
a.click();
|
| 337 |
+
a.remove();
|
| 338 |
+
URL.revokeObjectURL(url);
|
| 339 |
+
|
| 340 |
+
showToast('Downloaded', `${ids.length} images as ${nameValue}`, 'success');
|
| 341 |
+
imageStepIndicator.complete();
|
| 342 |
+
|
| 343 |
+
// Show success animation
|
| 344 |
+
showSuccessAnimation('Download Complete!', `${ids.length} images saved as ${nameValue}`);
|
| 345 |
+
} catch (e) {
|
| 346 |
+
showToast('Download Failed', e.message, 'error');
|
| 347 |
+
imageStepIndicator.setStep(2);
|
| 348 |
+
} finally {
|
| 349 |
+
setButtonLoading(btn, false);
|
| 350 |
+
showProgress(elements.progress, false);
|
| 351 |
+
updateCounter(elements);
|
| 352 |
+
}
|
| 353 |
+
}
|
static/js/image-tools.js
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Image Tools Module
|
| 3 |
+
* Remove Background, Add Watermark, Resize, Convert, Compress
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// =============== Remove Background ===============
|
| 7 |
+
|
| 8 |
+
let rembgFile = null;
|
| 9 |
+
|
| 10 |
+
function initRemoveBackground() {
|
| 11 |
+
const dropZone = document.getElementById('rembgDropZone');
|
| 12 |
+
const fileInput = document.getElementById('rembg_file');
|
| 13 |
+
const btn = document.getElementById('btnRembg');
|
| 14 |
+
const btnPreview = document.getElementById('btnRembgPreview');
|
| 15 |
+
const notice = document.getElementById('rembgNotice');
|
| 16 |
+
|
| 17 |
+
if (!dropZone || !fileInput) return;
|
| 18 |
+
|
| 19 |
+
// Check if rembg is available
|
| 20 |
+
fetch('/api/rembg-status')
|
| 21 |
+
.then(r => r.json())
|
| 22 |
+
.then(data => {
|
| 23 |
+
if (!data.available && notice) {
|
| 24 |
+
notice.style.display = 'flex';
|
| 25 |
+
}
|
| 26 |
+
})
|
| 27 |
+
.catch(() => {});
|
| 28 |
+
|
| 29 |
+
initDropZone(dropZone, fileInput, {
|
| 30 |
+
maxSize: 50 * 1024 * 1024,
|
| 31 |
+
onFile: (file) => {
|
| 32 |
+
rembgFile = file;
|
| 33 |
+
btn.disabled = !file;
|
| 34 |
+
btnPreview.disabled = !file;
|
| 35 |
+
document.getElementById('rembgPreviewCard').style.display = 'none';
|
| 36 |
+
if (file) showToast('Image Selected', file.name, 'success', 2000);
|
| 37 |
+
}
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
btnPreview.addEventListener('click', () => previewRemoveBackground());
|
| 41 |
+
btn.addEventListener('click', () => removeBackground());
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
async function previewRemoveBackground() {
|
| 45 |
+
if (!rembgFile) {
|
| 46 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const btn = document.getElementById('btnRembgPreview');
|
| 51 |
+
const previewCard = document.getElementById('rembgPreviewCard');
|
| 52 |
+
const originalImg = document.getElementById('rembgPreviewOriginal');
|
| 53 |
+
const processedImg = document.getElementById('rembgPreviewProcessed');
|
| 54 |
+
|
| 55 |
+
setButtonLoading(btn, true, 'Processing...');
|
| 56 |
+
|
| 57 |
+
try {
|
| 58 |
+
const fd = new FormData();
|
| 59 |
+
fd.append('file', rembgFile);
|
| 60 |
+
|
| 61 |
+
const res = await fetch('/api/preview/remove-background', { method: 'POST', body: fd });
|
| 62 |
+
|
| 63 |
+
if (!res.ok) {
|
| 64 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 65 |
+
throw new Error(err.detail);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const data = await res.json();
|
| 69 |
+
originalImg.src = 'data:image/png;base64,' + data.original;
|
| 70 |
+
processedImg.src = 'data:image/png;base64,' + data.processed;
|
| 71 |
+
previewCard.style.display = 'block';
|
| 72 |
+
previewCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 73 |
+
|
| 74 |
+
showToast('Preview Ready', 'Compare original vs processed', 'success', 2000);
|
| 75 |
+
|
| 76 |
+
} catch (e) {
|
| 77 |
+
showToast('Error', e.message, 'error');
|
| 78 |
+
} finally {
|
| 79 |
+
setButtonLoading(btn, false);
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
async function removeBackground() {
|
| 84 |
+
if (!rembgFile) {
|
| 85 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 86 |
+
return;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const btn = document.getElementById('btnRembg');
|
| 90 |
+
const progress = document.getElementById('rembgProgress');
|
| 91 |
+
|
| 92 |
+
setButtonLoading(btn, true, 'Processing...');
|
| 93 |
+
showProgress(progress, true, true);
|
| 94 |
+
|
| 95 |
+
try {
|
| 96 |
+
const fd = new FormData();
|
| 97 |
+
fd.append('file', rembgFile);
|
| 98 |
+
fd.append('output_name', document.getElementById('rembg_output').value || 'no-background.png');
|
| 99 |
+
|
| 100 |
+
const res = await fetch('/api/remove-background', { method: 'POST', body: fd });
|
| 101 |
+
|
| 102 |
+
if (!res.ok) {
|
| 103 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 104 |
+
throw new Error(err.detail);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const blob = await res.blob();
|
| 108 |
+
const filename = document.getElementById('rembg_output').value || 'no-background.png';
|
| 109 |
+
downloadBlob(blob, filename.endsWith('.png') ? filename : filename + '.png');
|
| 110 |
+
|
| 111 |
+
showSuccessAnimation('Background Removed!', 'Download started');
|
| 112 |
+
|
| 113 |
+
} catch (e) {
|
| 114 |
+
showToast('Error', e.message, 'error');
|
| 115 |
+
} finally {
|
| 116 |
+
setButtonLoading(btn, false);
|
| 117 |
+
showProgress(progress, false);
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
// =============== Add Watermark ===============
|
| 123 |
+
|
| 124 |
+
let imgWatermarkFile = null;
|
| 125 |
+
|
| 126 |
+
function initImageWatermark() {
|
| 127 |
+
const dropZone = document.getElementById('imgwatermarkDropZone');
|
| 128 |
+
const fileInput = document.getElementById('imgwatermark_file');
|
| 129 |
+
const btn = document.getElementById('btnImgWatermark');
|
| 130 |
+
const btnPreview = document.getElementById('btnImgWatermarkPreview');
|
| 131 |
+
|
| 132 |
+
if (!dropZone || !fileInput) return;
|
| 133 |
+
|
| 134 |
+
initDropZone(dropZone, fileInput, {
|
| 135 |
+
maxSize: 50 * 1024 * 1024,
|
| 136 |
+
onFile: (file) => {
|
| 137 |
+
imgWatermarkFile = file;
|
| 138 |
+
btn.disabled = !file;
|
| 139 |
+
btnPreview.disabled = !file;
|
| 140 |
+
document.getElementById('imgwatermarkPreviewCard').style.display = 'none';
|
| 141 |
+
if (file) showToast('Image Selected', file.name, 'success', 2000);
|
| 142 |
+
}
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
btnPreview.addEventListener('click', () => previewImageWatermark());
|
| 146 |
+
btn.addEventListener('click', () => addImageWatermark());
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
async function previewImageWatermark() {
|
| 150 |
+
if (!imgWatermarkFile) {
|
| 151 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 152 |
+
return;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const text = document.getElementById('imgwatermark_text').value;
|
| 156 |
+
if (!text.trim()) {
|
| 157 |
+
showToast('No Text', 'Enter watermark text', 'error');
|
| 158 |
+
return;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const btn = document.getElementById('btnImgWatermarkPreview');
|
| 162 |
+
const previewCard = document.getElementById('imgwatermarkPreviewCard');
|
| 163 |
+
const originalImg = document.getElementById('imgwatermarkPreviewOriginal');
|
| 164 |
+
const processedImg = document.getElementById('imgwatermarkPreviewProcessed');
|
| 165 |
+
|
| 166 |
+
setButtonLoading(btn, true, 'Loading...');
|
| 167 |
+
|
| 168 |
+
try {
|
| 169 |
+
const fd = new FormData();
|
| 170 |
+
fd.append('file', imgWatermarkFile);
|
| 171 |
+
fd.append('text', text);
|
| 172 |
+
fd.append('position', document.getElementById('imgwatermark_position').value);
|
| 173 |
+
fd.append('opacity', document.getElementById('imgwatermark_opacity').value);
|
| 174 |
+
fd.append('font_size', document.getElementById('imgwatermark_fontsize').value);
|
| 175 |
+
fd.append('color', document.getElementById('imgwatermark_color').value);
|
| 176 |
+
|
| 177 |
+
const res = await fetch('/api/preview/image-watermark', { method: 'POST', body: fd });
|
| 178 |
+
|
| 179 |
+
if (!res.ok) {
|
| 180 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 181 |
+
throw new Error(err.detail);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
const data = await res.json();
|
| 185 |
+
originalImg.src = 'data:image/jpeg;base64,' + data.original;
|
| 186 |
+
processedImg.src = 'data:image/jpeg;base64,' + data.processed;
|
| 187 |
+
previewCard.style.display = 'block';
|
| 188 |
+
previewCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 189 |
+
|
| 190 |
+
showToast('Preview Ready', 'Compare original vs watermarked', 'success', 2000);
|
| 191 |
+
|
| 192 |
+
} catch (e) {
|
| 193 |
+
showToast('Error', e.message, 'error');
|
| 194 |
+
} finally {
|
| 195 |
+
setButtonLoading(btn, false);
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
async function addImageWatermark() {
|
| 200 |
+
if (!imgWatermarkFile) {
|
| 201 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 202 |
+
return;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
const text = document.getElementById('imgwatermark_text').value;
|
| 206 |
+
if (!text.trim()) {
|
| 207 |
+
showToast('No Text', 'Enter watermark text', 'error');
|
| 208 |
+
return;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const btn = document.getElementById('btnImgWatermark');
|
| 212 |
+
const progress = document.getElementById('imgwatermarkProgress');
|
| 213 |
+
|
| 214 |
+
setButtonLoading(btn, true, 'Processing...');
|
| 215 |
+
showProgress(progress, true, true);
|
| 216 |
+
|
| 217 |
+
try {
|
| 218 |
+
const fd = new FormData();
|
| 219 |
+
fd.append('file', imgWatermarkFile);
|
| 220 |
+
fd.append('text', text);
|
| 221 |
+
fd.append('position', document.getElementById('imgwatermark_position').value);
|
| 222 |
+
fd.append('opacity', document.getElementById('imgwatermark_opacity').value);
|
| 223 |
+
fd.append('font_size', document.getElementById('imgwatermark_fontsize').value);
|
| 224 |
+
fd.append('color', document.getElementById('imgwatermark_color').value);
|
| 225 |
+
fd.append('output_name', document.getElementById('imgwatermark_output').value || 'watermarked');
|
| 226 |
+
|
| 227 |
+
const res = await fetch('/api/add-image-watermark', { method: 'POST', body: fd });
|
| 228 |
+
|
| 229 |
+
if (!res.ok) {
|
| 230 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 231 |
+
throw new Error(err.detail);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
const blob = await res.blob();
|
| 235 |
+
const contentDisposition = res.headers.get('content-disposition');
|
| 236 |
+
let filename = 'watermarked.jpg';
|
| 237 |
+
if (contentDisposition) {
|
| 238 |
+
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
| 239 |
+
if (match) filename = match[1];
|
| 240 |
+
}
|
| 241 |
+
downloadBlob(blob, filename);
|
| 242 |
+
|
| 243 |
+
showSuccessAnimation('Watermark Added!', 'Download started');
|
| 244 |
+
|
| 245 |
+
} catch (e) {
|
| 246 |
+
showToast('Error', e.message, 'error');
|
| 247 |
+
} finally {
|
| 248 |
+
setButtonLoading(btn, false);
|
| 249 |
+
showProgress(progress, false);
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
// =============== Resize Image ===============
|
| 255 |
+
|
| 256 |
+
let resizeFile = null;
|
| 257 |
+
|
| 258 |
+
function initResizeImage() {
|
| 259 |
+
const dropZone = document.getElementById('resizeDropZone');
|
| 260 |
+
const fileInput = document.getElementById('resize_file');
|
| 261 |
+
const btn = document.getElementById('btnResize');
|
| 262 |
+
const btnPreview = document.getElementById('btnResizePreview');
|
| 263 |
+
const presetSelect = document.getElementById('resize_preset');
|
| 264 |
+
const widthInput = document.getElementById('resize_width');
|
| 265 |
+
const heightInput = document.getElementById('resize_height');
|
| 266 |
+
|
| 267 |
+
if (!dropZone || !fileInput) return;
|
| 268 |
+
|
| 269 |
+
initDropZone(dropZone, fileInput, {
|
| 270 |
+
maxSize: 50 * 1024 * 1024,
|
| 271 |
+
onFile: (file) => {
|
| 272 |
+
resizeFile = file;
|
| 273 |
+
btn.disabled = !file;
|
| 274 |
+
btnPreview.disabled = !file;
|
| 275 |
+
document.getElementById('resizePreviewCard').style.display = 'none';
|
| 276 |
+
if (file) showToast('Image Selected', file.name, 'success', 2000);
|
| 277 |
+
}
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
// Preset change handler
|
| 281 |
+
presetSelect.addEventListener('change', () => {
|
| 282 |
+
const preset = presetSelect.value;
|
| 283 |
+
if (preset !== 'custom') {
|
| 284 |
+
widthInput.disabled = true;
|
| 285 |
+
heightInput.disabled = true;
|
| 286 |
+
widthInput.placeholder = 'Auto';
|
| 287 |
+
heightInput.placeholder = 'Auto';
|
| 288 |
+
} else {
|
| 289 |
+
widthInput.disabled = false;
|
| 290 |
+
heightInput.disabled = false;
|
| 291 |
+
}
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
btnPreview.addEventListener('click', () => previewResizeImage());
|
| 295 |
+
btn.addEventListener('click', () => resizeImage());
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
async function previewResizeImage() {
|
| 299 |
+
if (!resizeFile) {
|
| 300 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 301 |
+
return;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
const btn = document.getElementById('btnResizePreview');
|
| 305 |
+
const previewCard = document.getElementById('resizePreviewCard');
|
| 306 |
+
const originalImg = document.getElementById('resizePreviewOriginal');
|
| 307 |
+
const processedImg = document.getElementById('resizePreviewProcessed');
|
| 308 |
+
const originalSizeSpan = document.getElementById('resizeOriginalSize');
|
| 309 |
+
const newSizeSpan = document.getElementById('resizeNewSize');
|
| 310 |
+
|
| 311 |
+
setButtonLoading(btn, true, 'Loading...');
|
| 312 |
+
|
| 313 |
+
try {
|
| 314 |
+
const fd = new FormData();
|
| 315 |
+
fd.append('file', resizeFile);
|
| 316 |
+
fd.append('preset', document.getElementById('resize_preset').value);
|
| 317 |
+
fd.append('width', document.getElementById('resize_width').value || '0');
|
| 318 |
+
fd.append('height', document.getElementById('resize_height').value || '0');
|
| 319 |
+
fd.append('maintain_aspect', document.getElementById('resize_aspect').checked);
|
| 320 |
+
|
| 321 |
+
const res = await fetch('/api/preview/resize-image', { method: 'POST', body: fd });
|
| 322 |
+
|
| 323 |
+
if (!res.ok) {
|
| 324 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 325 |
+
throw new Error(err.detail);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
const data = await res.json();
|
| 329 |
+
originalImg.src = 'data:image/jpeg;base64,' + data.original;
|
| 330 |
+
processedImg.src = 'data:image/jpeg;base64,' + data.processed;
|
| 331 |
+
originalSizeSpan.textContent = `(${data.original_size})`;
|
| 332 |
+
newSizeSpan.textContent = `(${data.new_size})`;
|
| 333 |
+
previewCard.style.display = 'block';
|
| 334 |
+
previewCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 335 |
+
|
| 336 |
+
showToast('Preview Ready', `${data.original_size} → ${data.new_size}`, 'success', 2000);
|
| 337 |
+
|
| 338 |
+
} catch (e) {
|
| 339 |
+
showToast('Error', e.message, 'error');
|
| 340 |
+
} finally {
|
| 341 |
+
setButtonLoading(btn, false);
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
async function resizeImage() {
|
| 346 |
+
if (!resizeFile) {
|
| 347 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 348 |
+
return;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
const btn = document.getElementById('btnResize');
|
| 352 |
+
const progress = document.getElementById('resizeProgress');
|
| 353 |
+
|
| 354 |
+
setButtonLoading(btn, true, 'Resizing...');
|
| 355 |
+
showProgress(progress, true, true);
|
| 356 |
+
|
| 357 |
+
try {
|
| 358 |
+
const fd = new FormData();
|
| 359 |
+
fd.append('file', resizeFile);
|
| 360 |
+
fd.append('preset', document.getElementById('resize_preset').value);
|
| 361 |
+
fd.append('width', document.getElementById('resize_width').value || '0');
|
| 362 |
+
fd.append('height', document.getElementById('resize_height').value || '0');
|
| 363 |
+
fd.append('maintain_aspect', document.getElementById('resize_aspect').checked);
|
| 364 |
+
fd.append('output_name', document.getElementById('resize_output').value || 'resized');
|
| 365 |
+
|
| 366 |
+
const res = await fetch('/api/resize-image', { method: 'POST', body: fd });
|
| 367 |
+
|
| 368 |
+
if (!res.ok) {
|
| 369 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 370 |
+
throw new Error(err.detail);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
const blob = await res.blob();
|
| 374 |
+
const contentDisposition = res.headers.get('content-disposition');
|
| 375 |
+
let filename = 'resized.jpg';
|
| 376 |
+
if (contentDisposition) {
|
| 377 |
+
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
| 378 |
+
if (match) filename = match[1];
|
| 379 |
+
}
|
| 380 |
+
downloadBlob(blob, filename);
|
| 381 |
+
|
| 382 |
+
const newSize = res.headers.get('X-New-Size');
|
| 383 |
+
showSuccessAnimation('Image Resized!', newSize ? `New size: ${newSize}` : 'Download started');
|
| 384 |
+
|
| 385 |
+
} catch (e) {
|
| 386 |
+
showToast('Error', e.message, 'error');
|
| 387 |
+
} finally {
|
| 388 |
+
setButtonLoading(btn, false);
|
| 389 |
+
showProgress(progress, false);
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
// =============== Convert Format ===============
|
| 395 |
+
|
| 396 |
+
let convertFile = null;
|
| 397 |
+
|
| 398 |
+
function initConvertImage() {
|
| 399 |
+
const dropZone = document.getElementById('convertDropZone');
|
| 400 |
+
const fileInput = document.getElementById('convert_file');
|
| 401 |
+
const btn = document.getElementById('btnConvert');
|
| 402 |
+
const formatSelect = document.getElementById('convert_format');
|
| 403 |
+
const qualityInput = document.getElementById('convert_quality');
|
| 404 |
+
|
| 405 |
+
if (!dropZone || !fileInput) return;
|
| 406 |
+
|
| 407 |
+
initDropZone(dropZone, fileInput, {
|
| 408 |
+
maxSize: 50 * 1024 * 1024,
|
| 409 |
+
onFile: (file) => {
|
| 410 |
+
convertFile = file;
|
| 411 |
+
btn.disabled = !file;
|
| 412 |
+
if (file) {
|
| 413 |
+
showToast('Image Selected', file.name, 'success', 2000);
|
| 414 |
+
// Trigger initial estimate
|
| 415 |
+
updateConvertImageEstimate();
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
});
|
| 419 |
+
|
| 420 |
+
// Format and quality change handlers with debounce
|
| 421 |
+
let estimateTimeout = null;
|
| 422 |
+
const triggerEstimate = () => {
|
| 423 |
+
if (estimateTimeout) clearTimeout(estimateTimeout);
|
| 424 |
+
estimateTimeout = setTimeout(() => {
|
| 425 |
+
updateConvertImageEstimate();
|
| 426 |
+
}, 300);
|
| 427 |
+
};
|
| 428 |
+
|
| 429 |
+
formatSelect.addEventListener('change', triggerEstimate);
|
| 430 |
+
qualityInput.addEventListener('input', triggerEstimate);
|
| 431 |
+
|
| 432 |
+
btn.addEventListener('click', () => convertImage());
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
async function updateConvertImageEstimate() {
|
| 436 |
+
if (!convertFile) return;
|
| 437 |
+
|
| 438 |
+
const format = document.getElementById('convert_format').value;
|
| 439 |
+
const quality = document.getElementById('convert_quality').value;
|
| 440 |
+
|
| 441 |
+
// Create estimate display if it doesn't exist
|
| 442 |
+
let displayDiv = document.getElementById('convertEstimate');
|
| 443 |
+
if (!displayDiv) {
|
| 444 |
+
displayDiv = document.createElement('div');
|
| 445 |
+
displayDiv.id = 'convertEstimate';
|
| 446 |
+
displayDiv.style.cssText = 'margin-top: 12px; padding: 12px; background: var(--border-light); border-radius: var(--radius); font-size: 13px;';
|
| 447 |
+
const btnGroup = document.querySelector('#page-convert .btn-group');
|
| 448 |
+
btnGroup.parentNode.insertBefore(displayDiv, btnGroup);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
displayDiv.innerHTML = '<span style="color: var(--text-muted);">Estimating file size...</span>';
|
| 452 |
+
|
| 453 |
+
try {
|
| 454 |
+
const fd = new FormData();
|
| 455 |
+
fd.append('file', convertFile);
|
| 456 |
+
fd.append('target_format', format);
|
| 457 |
+
fd.append('quality', quality);
|
| 458 |
+
|
| 459 |
+
const res = await fetch('/api/estimate/convert-image', { method: 'POST', body: fd });
|
| 460 |
+
|
| 461 |
+
if (!res.ok) {
|
| 462 |
+
displayDiv.innerHTML = '<span style="color: var(--text-muted);">Could not estimate</span>';
|
| 463 |
+
return;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
const data = await res.json();
|
| 467 |
+
const origKB = (data.original_size / 1024).toFixed(1);
|
| 468 |
+
const estKB = (data.estimated_size / 1024).toFixed(1);
|
| 469 |
+
const diff = data.estimated_size - data.original_size;
|
| 470 |
+
const diffPercent = ((diff / data.original_size) * 100).toFixed(1);
|
| 471 |
+
|
| 472 |
+
let sizeChange = '';
|
| 473 |
+
if (diff < 0) {
|
| 474 |
+
sizeChange = `<span style="color: var(--success); font-weight: 600;">${diffPercent}%</span>`;
|
| 475 |
+
} else if (diff > 0) {
|
| 476 |
+
sizeChange = `<span style="color: var(--warning); font-weight: 600;">+${diffPercent}%</span>`;
|
| 477 |
+
} else {
|
| 478 |
+
sizeChange = `<span style="color: var(--text-muted);">Same size</span>`;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
displayDiv.innerHTML = `
|
| 482 |
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
| 483 |
+
<span><strong>Original:</strong> ${origKB} KB</span>
|
| 484 |
+
<span style="color: var(--primary);">→</span>
|
| 485 |
+
<span><strong>${data.target_format}:</strong> ${estKB} KB</span>
|
| 486 |
+
${sizeChange}
|
| 487 |
+
</div>
|
| 488 |
+
`;
|
| 489 |
+
} catch (e) {
|
| 490 |
+
displayDiv.innerHTML = '<span style="color: var(--text-muted);">Could not estimate</span>';
|
| 491 |
+
}
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
async function convertImage() {
|
| 495 |
+
if (!convertFile) {
|
| 496 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 497 |
+
return;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
const btn = document.getElementById('btnConvert');
|
| 501 |
+
const progress = document.getElementById('convertProgress');
|
| 502 |
+
|
| 503 |
+
setButtonLoading(btn, true, 'Converting...');
|
| 504 |
+
showProgress(progress, true, true);
|
| 505 |
+
|
| 506 |
+
try {
|
| 507 |
+
const fd = new FormData();
|
| 508 |
+
fd.append('file', convertFile);
|
| 509 |
+
fd.append('target_format', document.getElementById('convert_format').value);
|
| 510 |
+
fd.append('quality', document.getElementById('convert_quality').value);
|
| 511 |
+
fd.append('output_name', document.getElementById('convert_output').value || 'converted');
|
| 512 |
+
|
| 513 |
+
const res = await fetch('/api/convert-image', { method: 'POST', body: fd });
|
| 514 |
+
|
| 515 |
+
if (!res.ok) {
|
| 516 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 517 |
+
throw new Error(err.detail);
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
const blob = await res.blob();
|
| 521 |
+
const contentDisposition = res.headers.get('content-disposition');
|
| 522 |
+
let filename = 'converted.jpg';
|
| 523 |
+
if (contentDisposition) {
|
| 524 |
+
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
| 525 |
+
if (match) filename = match[1];
|
| 526 |
+
}
|
| 527 |
+
downloadBlob(blob, filename);
|
| 528 |
+
|
| 529 |
+
const format = document.getElementById('convert_format').value.toUpperCase();
|
| 530 |
+
showSuccessAnimation('Image Converted!', `Converted to ${format}`);
|
| 531 |
+
|
| 532 |
+
} catch (e) {
|
| 533 |
+
showToast('Error', e.message, 'error');
|
| 534 |
+
} finally {
|
| 535 |
+
setButtonLoading(btn, false);
|
| 536 |
+
showProgress(progress, false);
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
// =============== Compress Image ===============
|
| 542 |
+
|
| 543 |
+
let compressImgFile = null;
|
| 544 |
+
|
| 545 |
+
function initCompressImage() {
|
| 546 |
+
const dropZone = document.getElementById('compressimgDropZone');
|
| 547 |
+
const fileInput = document.getElementById('compressimg_file');
|
| 548 |
+
const btn = document.getElementById('btnCompressImg');
|
| 549 |
+
const btnPreview = document.getElementById('btnCompressImgPreview');
|
| 550 |
+
const qualitySlider = document.getElementById('compressimg_quality');
|
| 551 |
+
const qualityVal = document.getElementById('compressimg_quality_val');
|
| 552 |
+
|
| 553 |
+
if (!dropZone || !fileInput) return;
|
| 554 |
+
|
| 555 |
+
initDropZone(dropZone, fileInput, {
|
| 556 |
+
maxSize: 50 * 1024 * 1024,
|
| 557 |
+
onFile: (file) => {
|
| 558 |
+
compressImgFile = file;
|
| 559 |
+
btn.disabled = !file;
|
| 560 |
+
btnPreview.disabled = !file;
|
| 561 |
+
document.getElementById('compressimgResult').style.display = 'none';
|
| 562 |
+
document.getElementById('compressimgPreviewCard').style.display = 'none';
|
| 563 |
+
if (file) {
|
| 564 |
+
showToast('Image Selected', file.name, 'success', 2000);
|
| 565 |
+
// Trigger initial estimate
|
| 566 |
+
updateCompressImageEstimate();
|
| 567 |
+
}
|
| 568 |
+
}
|
| 569 |
+
});
|
| 570 |
+
|
| 571 |
+
// Quality slider with debounced estimate
|
| 572 |
+
let estimateTimeout = null;
|
| 573 |
+
qualitySlider.addEventListener('input', () => {
|
| 574 |
+
qualityVal.textContent = qualitySlider.value;
|
| 575 |
+
// Debounce the estimate call
|
| 576 |
+
if (estimateTimeout) clearTimeout(estimateTimeout);
|
| 577 |
+
estimateTimeout = setTimeout(() => {
|
| 578 |
+
updateCompressImageEstimate();
|
| 579 |
+
}, 300);
|
| 580 |
+
});
|
| 581 |
+
|
| 582 |
+
btnPreview.addEventListener('click', () => previewCompressImage());
|
| 583 |
+
btn.addEventListener('click', () => compressImage());
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
async function updateCompressImageEstimate() {
|
| 587 |
+
if (!compressImgFile) return;
|
| 588 |
+
|
| 589 |
+
const estimateDiv = document.getElementById('compressimgEstimate');
|
| 590 |
+
const quality = document.getElementById('compressimg_quality').value;
|
| 591 |
+
|
| 592 |
+
// Create estimate display if it doesn't exist
|
| 593 |
+
let displayDiv = estimateDiv;
|
| 594 |
+
if (!displayDiv) {
|
| 595 |
+
displayDiv = document.createElement('div');
|
| 596 |
+
displayDiv.id = 'compressimgEstimate';
|
| 597 |
+
displayDiv.style.cssText = 'margin-top: 12px; padding: 12px; background: var(--border-light); border-radius: var(--radius); font-size: 13px;';
|
| 598 |
+
const qualityGroup = document.getElementById('compressimg_quality').closest('.form-group');
|
| 599 |
+
qualityGroup.appendChild(displayDiv);
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
displayDiv.innerHTML = '<span style="color: var(--text-muted);">Estimating file size...</span>';
|
| 603 |
+
|
| 604 |
+
try {
|
| 605 |
+
const fd = new FormData();
|
| 606 |
+
fd.append('file', compressImgFile);
|
| 607 |
+
fd.append('quality', quality);
|
| 608 |
+
|
| 609 |
+
const res = await fetch('/api/estimate/compress-image', { method: 'POST', body: fd });
|
| 610 |
+
|
| 611 |
+
if (!res.ok) {
|
| 612 |
+
displayDiv.innerHTML = '<span style="color: var(--text-muted);">Could not estimate</span>';
|
| 613 |
+
return;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
const data = await res.json();
|
| 617 |
+
const origKB = (data.original_size / 1024).toFixed(1);
|
| 618 |
+
const estKB = (data.estimated_size / 1024).toFixed(1);
|
| 619 |
+
const reduction = data.reduction_percent;
|
| 620 |
+
|
| 621 |
+
displayDiv.innerHTML = `
|
| 622 |
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
| 623 |
+
<span><strong>Original:</strong> ${origKB} KB</span>
|
| 624 |
+
<span style="color: var(--primary);">→</span>
|
| 625 |
+
<span><strong>Estimated:</strong> ${estKB} KB</span>
|
| 626 |
+
<span style="color: ${reduction > 0 ? 'var(--success)' : 'var(--text-muted)'}; font-weight: 600;">
|
| 627 |
+
${reduction > 0 ? '-' + reduction + '%' : 'No reduction'}
|
| 628 |
+
</span>
|
| 629 |
+
</div>
|
| 630 |
+
`;
|
| 631 |
+
} catch (e) {
|
| 632 |
+
displayDiv.innerHTML = '<span style="color: var(--text-muted);">Could not estimate</span>';
|
| 633 |
+
}
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
async function previewCompressImage() {
|
| 637 |
+
if (!compressImgFile) {
|
| 638 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 639 |
+
return;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
const btn = document.getElementById('btnCompressImgPreview');
|
| 643 |
+
const previewCard = document.getElementById('compressimgPreviewCard');
|
| 644 |
+
const originalImg = document.getElementById('compressimgPreviewOriginal');
|
| 645 |
+
const processedImg = document.getElementById('compressimgPreviewProcessed');
|
| 646 |
+
|
| 647 |
+
setButtonLoading(btn, true, 'Loading...');
|
| 648 |
+
|
| 649 |
+
try {
|
| 650 |
+
const fd = new FormData();
|
| 651 |
+
fd.append('file', compressImgFile);
|
| 652 |
+
fd.append('quality', document.getElementById('compressimg_quality').value);
|
| 653 |
+
|
| 654 |
+
const res = await fetch('/api/preview/compress-image', { method: 'POST', body: fd });
|
| 655 |
+
|
| 656 |
+
if (!res.ok) {
|
| 657 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 658 |
+
throw new Error(err.detail);
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
const data = await res.json();
|
| 662 |
+
originalImg.src = 'data:image/jpeg;base64,' + data.original;
|
| 663 |
+
processedImg.src = 'data:image/jpeg;base64,' + data.processed;
|
| 664 |
+
previewCard.style.display = 'block';
|
| 665 |
+
previewCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 666 |
+
|
| 667 |
+
const origKB = (data.original_size / 1024).toFixed(1);
|
| 668 |
+
const compKB = (data.compressed_size / 1024).toFixed(1);
|
| 669 |
+
showToast('Preview Ready', `${origKB} KB → ${compKB} KB`, 'success', 2000);
|
| 670 |
+
|
| 671 |
+
} catch (e) {
|
| 672 |
+
showToast('Error', e.message, 'error');
|
| 673 |
+
} finally {
|
| 674 |
+
setButtonLoading(btn, false);
|
| 675 |
+
}
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
async function compressImage() {
|
| 679 |
+
if (!compressImgFile) {
|
| 680 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 681 |
+
return;
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
const btn = document.getElementById('btnCompressImg');
|
| 685 |
+
const progress = document.getElementById('compressimgProgress');
|
| 686 |
+
const resultDiv = document.getElementById('compressimgResult');
|
| 687 |
+
const statsDiv = document.getElementById('compressimgStats');
|
| 688 |
+
|
| 689 |
+
setButtonLoading(btn, true, 'Compressing...');
|
| 690 |
+
showProgress(progress, true, true);
|
| 691 |
+
resultDiv.style.display = 'none';
|
| 692 |
+
|
| 693 |
+
try {
|
| 694 |
+
const fd = new FormData();
|
| 695 |
+
fd.append('file', compressImgFile);
|
| 696 |
+
fd.append('quality', document.getElementById('compressimg_quality').value);
|
| 697 |
+
fd.append('output_name', document.getElementById('compressimg_output').value || 'compressed');
|
| 698 |
+
|
| 699 |
+
const res = await fetch('/api/compress-image', { method: 'POST', body: fd });
|
| 700 |
+
|
| 701 |
+
if (!res.ok) {
|
| 702 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 703 |
+
throw new Error(err.detail);
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
const blob = await res.blob();
|
| 707 |
+
const contentDisposition = res.headers.get('content-disposition');
|
| 708 |
+
let filename = 'compressed.jpg';
|
| 709 |
+
if (contentDisposition) {
|
| 710 |
+
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
| 711 |
+
if (match) filename = match[1];
|
| 712 |
+
}
|
| 713 |
+
downloadBlob(blob, filename);
|
| 714 |
+
|
| 715 |
+
// Show compression stats
|
| 716 |
+
const originalSize = res.headers.get('X-Original-Size');
|
| 717 |
+
const compressedSize = res.headers.get('X-Compressed-Size');
|
| 718 |
+
const reduction = res.headers.get('X-Reduction-Percent');
|
| 719 |
+
|
| 720 |
+
if (originalSize && compressedSize) {
|
| 721 |
+
const origKB = (parseInt(originalSize) / 1024).toFixed(1);
|
| 722 |
+
const compKB = (parseInt(compressedSize) / 1024).toFixed(1);
|
| 723 |
+
statsDiv.textContent = `${origKB} KB → ${compKB} KB (${reduction}% smaller)`;
|
| 724 |
+
resultDiv.style.display = 'block';
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
showSuccessAnimation('Image Compressed!', `Reduced by ${reduction}%`);
|
| 728 |
+
|
| 729 |
+
} catch (e) {
|
| 730 |
+
showToast('Error', e.message, 'error');
|
| 731 |
+
} finally {
|
| 732 |
+
setButtonLoading(btn, false);
|
| 733 |
+
showProgress(progress, false);
|
| 734 |
+
}
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
// =============== Utility ===============
|
| 739 |
+
|
| 740 |
+
function downloadBlob(blob, filename) {
|
| 741 |
+
const url = URL.createObjectURL(blob);
|
| 742 |
+
const a = document.createElement('a');
|
| 743 |
+
a.href = url;
|
| 744 |
+
a.download = filename;
|
| 745 |
+
document.body.appendChild(a);
|
| 746 |
+
a.click();
|
| 747 |
+
a.remove();
|
| 748 |
+
URL.revokeObjectURL(url);
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
// =============== Enhance Image ===============
|
| 753 |
+
|
| 754 |
+
let enhanceImgFile = null;
|
| 755 |
+
|
| 756 |
+
function initEnhanceImage() {
|
| 757 |
+
const dropZone = document.getElementById('enhanceImgDropZone');
|
| 758 |
+
const fileInput = document.getElementById('enhanceimg_file');
|
| 759 |
+
const btn = document.getElementById('btnEnhanceImg');
|
| 760 |
+
const btnPreview = document.getElementById('btnEnhanceImgPreview');
|
| 761 |
+
|
| 762 |
+
if (!dropZone || !fileInput) return;
|
| 763 |
+
|
| 764 |
+
initDropZone(dropZone, fileInput, {
|
| 765 |
+
maxSize: 50 * 1024 * 1024,
|
| 766 |
+
onFile: (file) => {
|
| 767 |
+
enhanceImgFile = file;
|
| 768 |
+
btn.disabled = !file;
|
| 769 |
+
btnPreview.disabled = !file;
|
| 770 |
+
document.getElementById('enhanceimgPreviewCard').style.display = 'none';
|
| 771 |
+
if (file) showToast('Image Selected', file.name, 'success', 2000);
|
| 772 |
+
}
|
| 773 |
+
});
|
| 774 |
+
|
| 775 |
+
btnPreview.addEventListener('click', () => previewEnhanceImage());
|
| 776 |
+
btn.addEventListener('click', () => enhanceImage());
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
async function previewEnhanceImage() {
|
| 780 |
+
if (!enhanceImgFile) {
|
| 781 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 782 |
+
return;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
const btn = document.getElementById('btnEnhanceImgPreview');
|
| 786 |
+
const previewCard = document.getElementById('enhanceimgPreviewCard');
|
| 787 |
+
const originalImg = document.getElementById('enhanceimgPreviewOriginal');
|
| 788 |
+
const processedImg = document.getElementById('enhanceimgPreviewProcessed');
|
| 789 |
+
|
| 790 |
+
setButtonLoading(btn, true, 'Loading...');
|
| 791 |
+
|
| 792 |
+
try {
|
| 793 |
+
const fd = new FormData();
|
| 794 |
+
fd.append('file', enhanceImgFile);
|
| 795 |
+
fd.append('level', document.getElementById('enhanceimg_level').value);
|
| 796 |
+
fd.append('upscale', document.getElementById('enhanceimg_upscale').value);
|
| 797 |
+
|
| 798 |
+
const res = await fetch('/api/preview/enhance-image', { method: 'POST', body: fd });
|
| 799 |
+
|
| 800 |
+
if (!res.ok) {
|
| 801 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 802 |
+
throw new Error(err.detail);
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
const data = await res.json();
|
| 806 |
+
originalImg.src = 'data:image/jpeg;base64,' + data.original;
|
| 807 |
+
processedImg.src = 'data:image/jpeg;base64,' + data.processed;
|
| 808 |
+
previewCard.style.display = 'block';
|
| 809 |
+
previewCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 810 |
+
|
| 811 |
+
showToast('Preview Ready', 'Compare original vs enhanced', 'success', 2000);
|
| 812 |
+
|
| 813 |
+
} catch (e) {
|
| 814 |
+
showToast('Error', e.message, 'error');
|
| 815 |
+
} finally {
|
| 816 |
+
setButtonLoading(btn, false);
|
| 817 |
+
}
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
async function enhanceImage() {
|
| 821 |
+
if (!enhanceImgFile) {
|
| 822 |
+
showToast('No Image', 'Upload an image first', 'error');
|
| 823 |
+
return;
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
const btn = document.getElementById('btnEnhanceImg');
|
| 827 |
+
const progress = document.getElementById('enhanceimgProgress');
|
| 828 |
+
|
| 829 |
+
setButtonLoading(btn, true, 'Enhancing...');
|
| 830 |
+
showProgress(progress, true, true);
|
| 831 |
+
|
| 832 |
+
try {
|
| 833 |
+
const fd = new FormData();
|
| 834 |
+
fd.append('file', enhanceImgFile);
|
| 835 |
+
fd.append('level', document.getElementById('enhanceimg_level').value);
|
| 836 |
+
fd.append('upscale', document.getElementById('enhanceimg_upscale').value);
|
| 837 |
+
fd.append('output_name', document.getElementById('enhanceimg_output').value || 'enhanced');
|
| 838 |
+
|
| 839 |
+
const res = await fetch('/api/enhance-image', { method: 'POST', body: fd });
|
| 840 |
+
|
| 841 |
+
if (!res.ok) {
|
| 842 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 843 |
+
throw new Error(err.detail);
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
const blob = await res.blob();
|
| 847 |
+
const contentDisposition = res.headers.get('content-disposition');
|
| 848 |
+
let filename = 'enhanced.jpg';
|
| 849 |
+
if (contentDisposition) {
|
| 850 |
+
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
| 851 |
+
if (match) filename = match[1];
|
| 852 |
+
}
|
| 853 |
+
downloadBlob(blob, filename);
|
| 854 |
+
|
| 855 |
+
showSuccessAnimation('Image Enhanced!', 'Download started');
|
| 856 |
+
|
| 857 |
+
} catch (e) {
|
| 858 |
+
showToast('Error', e.message, 'error');
|
| 859 |
+
} finally {
|
| 860 |
+
setButtonLoading(btn, false);
|
| 861 |
+
showProgress(progress, false);
|
| 862 |
+
}
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
|
| 866 |
+
// =============== Enhance PDF ===============
|
| 867 |
+
|
| 868 |
+
let enhancePdfFile = null;
|
| 869 |
+
|
| 870 |
+
function initEnhancePdf() {
|
| 871 |
+
const dropZone = document.getElementById('enhancePdfDropZone');
|
| 872 |
+
const fileInput = document.getElementById('enhancepdf_file');
|
| 873 |
+
const btn = document.getElementById('btnEnhancePdf');
|
| 874 |
+
|
| 875 |
+
if (!dropZone || !fileInput) return;
|
| 876 |
+
|
| 877 |
+
initDropZone(dropZone, fileInput, {
|
| 878 |
+
maxSize: 100 * 1024 * 1024,
|
| 879 |
+
onFile: (file) => {
|
| 880 |
+
enhancePdfFile = file;
|
| 881 |
+
btn.disabled = !file;
|
| 882 |
+
document.getElementById('enhancepdfResult').style.display = 'none';
|
| 883 |
+
if (file) showToast('PDF Selected', file.name, 'success', 2000);
|
| 884 |
+
}
|
| 885 |
+
});
|
| 886 |
+
|
| 887 |
+
btn.addEventListener('click', () => enhancePdf());
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
async function enhancePdf() {
|
| 891 |
+
if (!enhancePdfFile) {
|
| 892 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 893 |
+
return;
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
const btn = document.getElementById('btnEnhancePdf');
|
| 897 |
+
const progress = document.getElementById('enhancepdfProgress');
|
| 898 |
+
const resultDiv = document.getElementById('enhancepdfResult');
|
| 899 |
+
const statsDiv = document.getElementById('enhancepdfStats');
|
| 900 |
+
|
| 901 |
+
setButtonLoading(btn, true, 'Enhancing...');
|
| 902 |
+
showProgress(progress, true, true);
|
| 903 |
+
resultDiv.style.display = 'none';
|
| 904 |
+
|
| 905 |
+
try {
|
| 906 |
+
const fd = new FormData();
|
| 907 |
+
fd.append('file', enhancePdfFile);
|
| 908 |
+
fd.append('level', document.getElementById('enhancepdf_level').value);
|
| 909 |
+
fd.append('output_name', document.getElementById('enhancepdf_output').value || 'enhanced.pdf');
|
| 910 |
+
|
| 911 |
+
const res = await fetch('/api/enhance-pdf', { method: 'POST', body: fd });
|
| 912 |
+
|
| 913 |
+
if (!res.ok) {
|
| 914 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 915 |
+
throw new Error(err.detail);
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
const blob = await res.blob();
|
| 919 |
+
const filename = document.getElementById('enhancepdf_output').value || 'enhanced.pdf';
|
| 920 |
+
downloadBlob(blob, filename.endsWith('.pdf') ? filename : filename + '.pdf');
|
| 921 |
+
|
| 922 |
+
// Show stats
|
| 923 |
+
const originalSize = res.headers.get('X-Original-Size');
|
| 924 |
+
const enhancedSize = res.headers.get('X-Enhanced-Size');
|
| 925 |
+
const imagesEnhanced = res.headers.get('X-Images-Enhanced');
|
| 926 |
+
|
| 927 |
+
if (originalSize && enhancedSize) {
|
| 928 |
+
const origMB = (parseInt(originalSize) / 1024 / 1024).toFixed(2);
|
| 929 |
+
const enhMB = (parseInt(enhancedSize) / 1024 / 1024).toFixed(2);
|
| 930 |
+
const change = (((parseInt(enhancedSize) - parseInt(originalSize)) / parseInt(originalSize)) * 100).toFixed(1);
|
| 931 |
+
statsDiv.textContent = `${origMB} MB → ${enhMB} MB (${change > 0 ? '+' : ''}${change}%), ${imagesEnhanced} images enhanced`;
|
| 932 |
+
resultDiv.style.display = 'block';
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
showSuccessAnimation('PDF Enhanced!', `${imagesEnhanced} images improved`);
|
| 936 |
+
|
| 937 |
+
} catch (e) {
|
| 938 |
+
showToast('Error', e.message, 'error');
|
| 939 |
+
} finally {
|
| 940 |
+
setButtonLoading(btn, false);
|
| 941 |
+
showProgress(progress, false);
|
| 942 |
+
}
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
// =============== Initialize All ===============
|
| 947 |
+
|
| 948 |
+
function initImageTools() {
|
| 949 |
+
initRemoveBackground();
|
| 950 |
+
initImageWatermark();
|
| 951 |
+
initResizeImage();
|
| 952 |
+
initConvertImage();
|
| 953 |
+
initCompressImage();
|
| 954 |
+
initEnhanceImage();
|
| 955 |
+
initEnhancePdf();
|
| 956 |
+
}
|
static/js/pdf-editor.js
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PDF Editor Module
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
let pdfBeforeUrl = null;
|
| 6 |
+
let pdfAfterUrl = null;
|
| 7 |
+
let isSplitView = false;
|
| 8 |
+
let pdfStepIndicator = null;
|
| 9 |
+
|
| 10 |
+
// Config storage key and expiry (24 hours)
|
| 11 |
+
const PDF_CONFIG_KEY = 'pdfEditorConfig';
|
| 12 |
+
const CONFIG_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
| 13 |
+
|
| 14 |
+
function savePdfConfig() {
|
| 15 |
+
const config = {
|
| 16 |
+
remove_pages: document.getElementById('remove_pages').value,
|
| 17 |
+
unit: document.getElementById('unit').value,
|
| 18 |
+
top: document.getElementById('top').value,
|
| 19 |
+
bottom: document.getElementById('bottom').value,
|
| 20 |
+
left: document.getElementById('left').value,
|
| 21 |
+
right: document.getElementById('right').value,
|
| 22 |
+
watermark_text: document.getElementById('watermark_text').value,
|
| 23 |
+
watermark_size: document.getElementById('watermark_size').value,
|
| 24 |
+
watermark_rotate: document.getElementById('watermark_rotate').value,
|
| 25 |
+
output_name: document.getElementById('output_name').value,
|
| 26 |
+
savedAt: Date.now()
|
| 27 |
+
};
|
| 28 |
+
localStorage.setItem(PDF_CONFIG_KEY, JSON.stringify(config));
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function loadPdfConfig() {
|
| 32 |
+
try {
|
| 33 |
+
const stored = localStorage.getItem(PDF_CONFIG_KEY);
|
| 34 |
+
if (!stored) return null;
|
| 35 |
+
|
| 36 |
+
const config = JSON.parse(stored);
|
| 37 |
+
|
| 38 |
+
// Check if expired (24 hours)
|
| 39 |
+
if (Date.now() - config.savedAt > CONFIG_EXPIRY_MS) {
|
| 40 |
+
localStorage.removeItem(PDF_CONFIG_KEY);
|
| 41 |
+
return null;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
return config;
|
| 45 |
+
} catch {
|
| 46 |
+
return null;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function applyPdfConfig(config) {
|
| 51 |
+
if (!config) return;
|
| 52 |
+
|
| 53 |
+
if (config.remove_pages) document.getElementById('remove_pages').value = config.remove_pages;
|
| 54 |
+
if (config.unit) document.getElementById('unit').value = config.unit;
|
| 55 |
+
if (config.top) document.getElementById('top').value = config.top;
|
| 56 |
+
if (config.bottom) document.getElementById('bottom').value = config.bottom;
|
| 57 |
+
if (config.left) document.getElementById('left').value = config.left;
|
| 58 |
+
if (config.right) document.getElementById('right').value = config.right;
|
| 59 |
+
if (config.watermark_text) document.getElementById('watermark_text').value = config.watermark_text;
|
| 60 |
+
if (config.watermark_size) document.getElementById('watermark_size').value = config.watermark_size;
|
| 61 |
+
if (config.watermark_rotate) document.getElementById('watermark_rotate').value = config.watermark_rotate;
|
| 62 |
+
if (config.output_name) document.getElementById('output_name').value = config.output_name;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function clearPdfConfig() {
|
| 66 |
+
localStorage.removeItem(PDF_CONFIG_KEY);
|
| 67 |
+
document.getElementById('remove_pages').value = '';
|
| 68 |
+
document.getElementById('unit').value = 'mm';
|
| 69 |
+
document.getElementById('top').value = '';
|
| 70 |
+
document.getElementById('bottom').value = '';
|
| 71 |
+
document.getElementById('left').value = '';
|
| 72 |
+
document.getElementById('right').value = '';
|
| 73 |
+
document.getElementById('watermark_text').value = '';
|
| 74 |
+
document.getElementById('watermark_size').value = '36';
|
| 75 |
+
document.getElementById('watermark_rotate').value = '45';
|
| 76 |
+
document.getElementById('output_name').value = 'cropped.pdf';
|
| 77 |
+
showToast('Config Cleared', 'Settings reset to defaults', 'success', 2000);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function initPdfEditor() {
|
| 81 |
+
const elements = {
|
| 82 |
+
btnBefore: document.getElementById('btnBefore'),
|
| 83 |
+
btnAfter: document.getElementById('btnAfter'),
|
| 84 |
+
frameBefore: document.getElementById('frameBefore'),
|
| 85 |
+
frameAfter: document.getElementById('frameAfter'),
|
| 86 |
+
splitFrameBefore: document.getElementById('splitFrameBefore'),
|
| 87 |
+
splitFrameAfter: document.getElementById('splitFrameAfter'),
|
| 88 |
+
previewTabs: document.querySelectorAll('.preview-tab'),
|
| 89 |
+
dropZone: document.getElementById('pdfDropZone'),
|
| 90 |
+
fileInput: document.getElementById('pdf_file'),
|
| 91 |
+
urlInput: document.getElementById('pdf_url'),
|
| 92 |
+
progress: document.getElementById('pdfProgress'),
|
| 93 |
+
btnSplitView: document.getElementById('btnSplitView'),
|
| 94 |
+
btnFullscreen: document.getElementById('btnFullscreen'),
|
| 95 |
+
splitView: document.getElementById('splitView'),
|
| 96 |
+
fullscreenOverlay: document.getElementById('fullscreenOverlay'),
|
| 97 |
+
fullscreenFrame: document.getElementById('fullscreenFrame'),
|
| 98 |
+
fullscreenClose: document.getElementById('fullscreenClose'),
|
| 99 |
+
previewBefore: document.getElementById('preview-before'),
|
| 100 |
+
previewAfter: document.getElementById('preview-after')
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
// Initialize step indicator
|
| 104 |
+
pdfStepIndicator = new StepIndicator('pdfStepIndicator', [
|
| 105 |
+
'Upload PDF',
|
| 106 |
+
'Configure',
|
| 107 |
+
'Preview',
|
| 108 |
+
'Download'
|
| 109 |
+
]);
|
| 110 |
+
|
| 111 |
+
// Load saved config (if within 24 hours)
|
| 112 |
+
const savedConfig = loadPdfConfig();
|
| 113 |
+
if (savedConfig) {
|
| 114 |
+
applyPdfConfig(savedConfig);
|
| 115 |
+
showToast('Config Restored', 'Previous settings loaded', 'info', 2000);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Auto-save config on input changes
|
| 119 |
+
const configInputs = ['remove_pages', 'unit', 'top', 'bottom', 'left', 'right',
|
| 120 |
+
'watermark_text', 'watermark_size', 'watermark_rotate', 'output_name'];
|
| 121 |
+
configInputs.forEach(id => {
|
| 122 |
+
const el = document.getElementById(id);
|
| 123 |
+
if (el) {
|
| 124 |
+
el.addEventListener('change', savePdfConfig);
|
| 125 |
+
el.addEventListener('input', savePdfConfig);
|
| 126 |
+
}
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
// Show empty state in preview
|
| 130 |
+
showPreviewEmpty(elements.previewBefore.querySelector('.preview-frame'));
|
| 131 |
+
showPreviewEmpty(elements.previewAfter.querySelector('.preview-frame'));
|
| 132 |
+
|
| 133 |
+
// Initialize drag & drop
|
| 134 |
+
if (elements.dropZone && elements.fileInput) {
|
| 135 |
+
initDropZone(elements.dropZone, elements.fileInput, {
|
| 136 |
+
accept: 'application/pdf',
|
| 137 |
+
maxSize: 100 * 1024 * 1024,
|
| 138 |
+
onFile: (file) => {
|
| 139 |
+
if (file) {
|
| 140 |
+
showToast('File Selected', file.name, 'success');
|
| 141 |
+
elements.urlInput.value = '';
|
| 142 |
+
pdfStepIndicator.setStep(1); // Move to Configure step
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// URL input change - update step
|
| 149 |
+
elements.urlInput.addEventListener('input', () => {
|
| 150 |
+
if (elements.urlInput.value.trim()) {
|
| 151 |
+
pdfStepIndicator.setStep(1);
|
| 152 |
+
}
|
| 153 |
+
clearFieldError(elements.urlInput.closest('.form-group'));
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
// Preview tab switching
|
| 157 |
+
elements.previewTabs.forEach(tab => {
|
| 158 |
+
tab.addEventListener('click', () => {
|
| 159 |
+
if (isSplitView) return;
|
| 160 |
+
const target = tab.dataset.preview;
|
| 161 |
+
switchPreview(target, elements.previewTabs);
|
| 162 |
+
});
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
// Split view toggle
|
| 166 |
+
elements.btnSplitView.addEventListener('click', () => {
|
| 167 |
+
isSplitView = !isSplitView;
|
| 168 |
+
elements.btnSplitView.classList.toggle('active', isSplitView);
|
| 169 |
+
elements.splitView.classList.toggle('active', isSplitView);
|
| 170 |
+
|
| 171 |
+
document.getElementById('preview-before').style.display = isSplitView ? 'none' : '';
|
| 172 |
+
document.getElementById('preview-after').style.display = isSplitView ? 'none' : '';
|
| 173 |
+
|
| 174 |
+
if (!isSplitView) {
|
| 175 |
+
const activeTab = document.querySelector('.preview-tab.active');
|
| 176 |
+
switchPreview(activeTab?.dataset.preview || 'before', elements.previewTabs);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// Sync frames
|
| 180 |
+
if (pdfBeforeUrl) {
|
| 181 |
+
elements.splitFrameBefore.src = pdfBeforeUrl;
|
| 182 |
+
}
|
| 183 |
+
if (pdfAfterUrl) {
|
| 184 |
+
elements.splitFrameAfter.src = pdfAfterUrl;
|
| 185 |
+
}
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
// Fullscreen toggle
|
| 189 |
+
elements.btnFullscreen.addEventListener('click', () => {
|
| 190 |
+
const activeTab = document.querySelector('.preview-tab.active');
|
| 191 |
+
const url = activeTab?.dataset.preview === 'after' ? pdfAfterUrl : pdfBeforeUrl;
|
| 192 |
+
|
| 193 |
+
if (url) {
|
| 194 |
+
elements.fullscreenFrame.src = url;
|
| 195 |
+
elements.fullscreenOverlay.classList.add('active');
|
| 196 |
+
} else {
|
| 197 |
+
showToast('No Preview', 'Load a PDF first', 'info');
|
| 198 |
+
}
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
elements.fullscreenClose.addEventListener('click', () => {
|
| 202 |
+
elements.fullscreenOverlay.classList.remove('active');
|
| 203 |
+
elements.fullscreenFrame.src = '';
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
// Close fullscreen on ESC
|
| 207 |
+
document.addEventListener('keydown', (e) => {
|
| 208 |
+
if (e.key === 'Escape' && elements.fullscreenOverlay.classList.contains('active')) {
|
| 209 |
+
elements.fullscreenOverlay.classList.remove('active');
|
| 210 |
+
elements.fullscreenFrame.src = '';
|
| 211 |
+
}
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
// Button handlers
|
| 215 |
+
elements.btnBefore.addEventListener('click', () => handlePreviewOriginal(elements));
|
| 216 |
+
elements.btnAfter.addEventListener('click', () => handleProcessPdf(elements));
|
| 217 |
+
|
| 218 |
+
// Clear config button
|
| 219 |
+
const btnClearConfig = document.getElementById('btnClearConfig');
|
| 220 |
+
if (btnClearConfig) {
|
| 221 |
+
btnClearConfig.addEventListener('click', clearPdfConfig);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// URL validation
|
| 225 |
+
elements.urlInput.addEventListener('blur', () => {
|
| 226 |
+
const value = elements.urlInput.value.trim();
|
| 227 |
+
if (value) validateUrl(elements.urlInput);
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
elements.urlInput.addEventListener('input', () => {
|
| 231 |
+
clearFieldError(elements.urlInput.closest('.form-group'));
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
// Keyboard shortcuts
|
| 235 |
+
registerShortcut('ctrl+p', () => {
|
| 236 |
+
if (document.getElementById('page-pdf').classList.contains('active')) {
|
| 237 |
+
elements.btnBefore.click();
|
| 238 |
+
}
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
registerShortcut('ctrl+enter', () => {
|
| 242 |
+
if (document.getElementById('page-pdf').classList.contains('active')) {
|
| 243 |
+
elements.btnAfter.click();
|
| 244 |
+
}
|
| 245 |
+
});
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* Watermark Removal Feature (separate page)
|
| 250 |
+
*/
|
| 251 |
+
function initWatermarkRemoval() {
|
| 252 |
+
const dropZone = document.getElementById('wmDropZone');
|
| 253 |
+
const fileInput = document.getElementById('wm_file');
|
| 254 |
+
const urlInput = document.getElementById('wm_url');
|
| 255 |
+
const btnRemove = document.getElementById('btnRemoveWatermark');
|
| 256 |
+
const btnPreview = document.getElementById('btnWmPreview');
|
| 257 |
+
const intensitySlider = document.getElementById('wm_intensity');
|
| 258 |
+
const intensityValue = document.getElementById('wm_intensity_value');
|
| 259 |
+
const progress = document.getElementById('wmProgress');
|
| 260 |
+
|
| 261 |
+
// Initialize drag & drop
|
| 262 |
+
if (dropZone && fileInput) {
|
| 263 |
+
initDropZone(dropZone, fileInput, {
|
| 264 |
+
accept: 'application/pdf',
|
| 265 |
+
maxSize: 100 * 1024 * 1024,
|
| 266 |
+
onFile: (file) => {
|
| 267 |
+
if (file) {
|
| 268 |
+
showToast('File Selected', file.name, 'success');
|
| 269 |
+
if (urlInput) urlInput.value = '';
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
});
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// Update intensity display
|
| 276 |
+
if (intensitySlider && intensityValue) {
|
| 277 |
+
intensitySlider.addEventListener('input', () => {
|
| 278 |
+
intensityValue.textContent = intensitySlider.value;
|
| 279 |
+
});
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
if (btnPreview) {
|
| 283 |
+
btnPreview.addEventListener('click', handleWatermarkPreview);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
if (btnRemove) {
|
| 287 |
+
btnRemove.addEventListener('click', handleRemoveWatermark);
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
async function handleWatermarkPreview() {
|
| 292 |
+
const urlInput = document.getElementById('wm_url');
|
| 293 |
+
const fileInput = document.getElementById('wm_file');
|
| 294 |
+
const btn = document.getElementById('btnWmPreview');
|
| 295 |
+
const previewCard = document.getElementById('wmPreviewCard');
|
| 296 |
+
const originalImg = document.getElementById('wmPreviewOriginal');
|
| 297 |
+
const processedImg = document.getElementById('wmPreviewProcessed');
|
| 298 |
+
|
| 299 |
+
const hasUrl = urlInput && urlInput.value.trim();
|
| 300 |
+
const hasFile = fileInput && fileInput.files.length > 0;
|
| 301 |
+
|
| 302 |
+
if (!hasUrl && !hasFile) {
|
| 303 |
+
showToast('No PDF', 'Upload a file or enter a URL first', 'error');
|
| 304 |
+
return;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
setButtonLoading(btn, true, 'Loading...');
|
| 308 |
+
|
| 309 |
+
try {
|
| 310 |
+
const fd = new FormData();
|
| 311 |
+
|
| 312 |
+
if (hasUrl) fd.append('url', urlInput.value.trim());
|
| 313 |
+
if (hasFile) fd.append('file', fileInput.files[0]);
|
| 314 |
+
|
| 315 |
+
fd.append('page', '0');
|
| 316 |
+
fd.append('method', document.getElementById('wm_method').value || 'inpaint');
|
| 317 |
+
fd.append('intensity', document.getElementById('wm_intensity').value || '50');
|
| 318 |
+
|
| 319 |
+
const res = await fetch('/api/watermark-preview', { method: 'POST', body: fd });
|
| 320 |
+
|
| 321 |
+
if (!res.ok) {
|
| 322 |
+
let err = { detail: 'Request failed' };
|
| 323 |
+
try { err = await res.json(); } catch {}
|
| 324 |
+
throw new Error(err.detail || 'Request failed');
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
const data = await res.json();
|
| 328 |
+
|
| 329 |
+
// Show preview images
|
| 330 |
+
originalImg.src = 'data:image/png;base64,' + data.original;
|
| 331 |
+
processedImg.src = 'data:image/png;base64,' + data.processed;
|
| 332 |
+
previewCard.style.display = 'block';
|
| 333 |
+
|
| 334 |
+
// Scroll to preview
|
| 335 |
+
previewCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 336 |
+
|
| 337 |
+
showToast('Preview Ready', 'Compare original vs processed', 'success', 2000);
|
| 338 |
+
|
| 339 |
+
} catch (e) {
|
| 340 |
+
showToast('Error', e.message, 'error');
|
| 341 |
+
} finally {
|
| 342 |
+
setButtonLoading(btn, false);
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
async function handleRemoveWatermark() {
|
| 347 |
+
const urlInput = document.getElementById('wm_url');
|
| 348 |
+
const fileInput = document.getElementById('wm_file');
|
| 349 |
+
const btn = document.getElementById('btnRemoveWatermark');
|
| 350 |
+
const progress = document.getElementById('wmProgress');
|
| 351 |
+
|
| 352 |
+
const hasUrl = urlInput && urlInput.value.trim();
|
| 353 |
+
const hasFile = fileInput && fileInput.files.length > 0;
|
| 354 |
+
|
| 355 |
+
if (!hasUrl && !hasFile) {
|
| 356 |
+
showToast('No PDF', 'Upload a file or enter a URL first', 'error');
|
| 357 |
+
return;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
setButtonLoading(btn, true, 'Processing...');
|
| 361 |
+
showProgress(progress, true, true);
|
| 362 |
+
|
| 363 |
+
try {
|
| 364 |
+
const fd = new FormData();
|
| 365 |
+
|
| 366 |
+
if (hasUrl) fd.append('url', urlInput.value.trim());
|
| 367 |
+
if (hasFile) fd.append('file', fileInput.files[0]);
|
| 368 |
+
|
| 369 |
+
fd.append('output_name', document.getElementById('wm_output_name').value || 'cleaned.pdf');
|
| 370 |
+
fd.append('watermark_text', document.getElementById('wm_text').value || 'Educated Nepal');
|
| 371 |
+
fd.append('method', document.getElementById('wm_method').value || 'inpaint');
|
| 372 |
+
fd.append('intensity', document.getElementById('wm_intensity').value || '50');
|
| 373 |
+
fd.append('dpi', document.getElementById('wm_dpi').value || '150');
|
| 374 |
+
fd.append('quality', document.getElementById('wm_quality').value || '85');
|
| 375 |
+
|
| 376 |
+
const res = await fetch('/api/remove-watermark', { method: 'POST', body: fd });
|
| 377 |
+
|
| 378 |
+
if (!res.ok) {
|
| 379 |
+
let err = { detail: 'Request failed' };
|
| 380 |
+
try { err = await res.json(); } catch {}
|
| 381 |
+
throw new Error(err.detail || 'Request failed');
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
const blob = await res.blob();
|
| 385 |
+
const filename = document.getElementById('wm_output_name').value || 'cleaned.pdf';
|
| 386 |
+
|
| 387 |
+
// Download
|
| 388 |
+
const url = URL.createObjectURL(blob);
|
| 389 |
+
const a = document.createElement('a');
|
| 390 |
+
a.href = url;
|
| 391 |
+
a.download = filename.endsWith('.pdf') ? filename : filename + '.pdf';
|
| 392 |
+
document.body.appendChild(a);
|
| 393 |
+
a.click();
|
| 394 |
+
a.remove();
|
| 395 |
+
URL.revokeObjectURL(url);
|
| 396 |
+
|
| 397 |
+
showSuccessAnimation('Watermark Removed!', `${filename} has been downloaded`);
|
| 398 |
+
|
| 399 |
+
// Add to recent files
|
| 400 |
+
if (typeof addToRecentFiles === 'function') {
|
| 401 |
+
addToRecentFiles(filename, 'watermark-removal');
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
} catch (e) {
|
| 405 |
+
showToast('Error', e.message, 'error');
|
| 406 |
+
} finally {
|
| 407 |
+
setButtonLoading(btn, false);
|
| 408 |
+
showProgress(progress, false);
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
function switchPreview(name, tabs) {
|
| 413 |
+
tabs.forEach(t => t.classList.toggle('active', t.dataset.preview === name));
|
| 414 |
+
document.getElementById('preview-before').classList.toggle('active', name === 'before');
|
| 415 |
+
document.getElementById('preview-after').classList.toggle('active', name === 'after');
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
function validatePdfForm() {
|
| 419 |
+
const urlInput = document.getElementById('pdf_url');
|
| 420 |
+
const fileInput = document.getElementById('pdf_file');
|
| 421 |
+
|
| 422 |
+
const hasUrl = urlInput.value.trim();
|
| 423 |
+
const hasFile = fileInput.files.length > 0;
|
| 424 |
+
|
| 425 |
+
if (!hasUrl && !hasFile) {
|
| 426 |
+
showToast('No PDF', 'Upload a file or enter a URL', 'error');
|
| 427 |
+
return false;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
if (hasUrl && !validateUrl(urlInput)) {
|
| 431 |
+
return false;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
return true;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
function buildPdfFormData(includeProcessOptions) {
|
| 438 |
+
const fd = new FormData();
|
| 439 |
+
|
| 440 |
+
const url = document.getElementById('pdf_url').value.trim();
|
| 441 |
+
const file = document.getElementById('pdf_file').files[0];
|
| 442 |
+
|
| 443 |
+
if (url) fd.append('url', url);
|
| 444 |
+
if (file) fd.append('file', file);
|
| 445 |
+
|
| 446 |
+
fd.append('output_name', document.getElementById('output_name').value.trim() || 'cropped.pdf');
|
| 447 |
+
|
| 448 |
+
if (includeProcessOptions) {
|
| 449 |
+
fd.append('remove_pages', document.getElementById('remove_pages').value.trim());
|
| 450 |
+
fd.append('unit', document.getElementById('unit').value);
|
| 451 |
+
fd.append('top', document.getElementById('top').value || '0');
|
| 452 |
+
fd.append('bottom', document.getElementById('bottom').value || '0');
|
| 453 |
+
fd.append('left', document.getElementById('left').value || '0');
|
| 454 |
+
fd.append('right', document.getElementById('right').value || '0');
|
| 455 |
+
fd.append('watermark_text', document.getElementById('watermark_text').value || '');
|
| 456 |
+
fd.append('watermark_size', document.getElementById('watermark_size').value || '36');
|
| 457 |
+
fd.append('watermark_rotate', document.getElementById('watermark_rotate').value || '45');
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
return fd;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
async function postForBlob(endpoint, formData) {
|
| 464 |
+
const res = await fetch(endpoint, { method: 'POST', body: formData });
|
| 465 |
+
|
| 466 |
+
if (!res.ok) {
|
| 467 |
+
let err = { detail: 'Request failed' };
|
| 468 |
+
try { err = await res.json(); } catch {}
|
| 469 |
+
throw new Error(err.detail || 'Request failed');
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
return await res.blob();
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
async function handlePreviewOriginal(elements) {
|
| 476 |
+
if (!validatePdfForm()) return;
|
| 477 |
+
|
| 478 |
+
setButtonLoading(elements.btnBefore, true, 'Loading...');
|
| 479 |
+
elements.btnAfter.disabled = true;
|
| 480 |
+
showProgress(elements.progress, true, true);
|
| 481 |
+
pdfStepIndicator.setStep(2); // Preview step
|
| 482 |
+
|
| 483 |
+
try {
|
| 484 |
+
const blob = await postForBlob('/api/fetch', buildPdfFormData(false));
|
| 485 |
+
|
| 486 |
+
if (pdfBeforeUrl) URL.revokeObjectURL(pdfBeforeUrl);
|
| 487 |
+
pdfBeforeUrl = URL.createObjectURL(blob);
|
| 488 |
+
|
| 489 |
+
// Clear empty state and show iframe
|
| 490 |
+
elements.previewBefore.querySelector('.preview-frame').innerHTML = '<iframe id="frameBefore"></iframe>';
|
| 491 |
+
elements.frameBefore = document.getElementById('frameBefore');
|
| 492 |
+
elements.frameBefore.src = pdfBeforeUrl;
|
| 493 |
+
elements.splitFrameBefore.src = pdfBeforeUrl;
|
| 494 |
+
|
| 495 |
+
if (!isSplitView) {
|
| 496 |
+
switchPreview('before', elements.previewTabs);
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
showToast('Preview Ready', 'Original PDF loaded', 'success');
|
| 500 |
+
} catch (e) {
|
| 501 |
+
showToast('Error', e.message, 'error');
|
| 502 |
+
pdfStepIndicator.setStep(1); // Back to configure
|
| 503 |
+
} finally {
|
| 504 |
+
setButtonLoading(elements.btnBefore, false);
|
| 505 |
+
elements.btnAfter.disabled = false;
|
| 506 |
+
showProgress(elements.progress, false);
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
async function handleProcessPdf(elements) {
|
| 511 |
+
if (!validatePdfForm()) return;
|
| 512 |
+
|
| 513 |
+
setButtonLoading(elements.btnAfter, true, 'Processing...');
|
| 514 |
+
elements.btnBefore.disabled = true;
|
| 515 |
+
showProgress(elements.progress, true, true);
|
| 516 |
+
pdfStepIndicator.setStep(3); // Download step
|
| 517 |
+
|
| 518 |
+
try {
|
| 519 |
+
const outName = document.getElementById('output_name').value.trim() || 'cropped.pdf';
|
| 520 |
+
const filename = outName.toLowerCase().endsWith('.pdf') ? outName : outName + '.pdf';
|
| 521 |
+
|
| 522 |
+
const blob = await postForBlob('/api/process', buildPdfFormData(true));
|
| 523 |
+
|
| 524 |
+
if (pdfAfterUrl) URL.revokeObjectURL(pdfAfterUrl);
|
| 525 |
+
pdfAfterUrl = URL.createObjectURL(blob);
|
| 526 |
+
|
| 527 |
+
// Clear empty state and show iframe
|
| 528 |
+
elements.previewAfter.querySelector('.preview-frame').innerHTML = '<iframe id="frameAfter"></iframe>';
|
| 529 |
+
elements.frameAfter = document.getElementById('frameAfter');
|
| 530 |
+
elements.frameAfter.src = pdfAfterUrl;
|
| 531 |
+
elements.splitFrameAfter.src = pdfAfterUrl;
|
| 532 |
+
|
| 533 |
+
// Download
|
| 534 |
+
const a = document.createElement('a');
|
| 535 |
+
a.href = pdfAfterUrl;
|
| 536 |
+
a.download = filename;
|
| 537 |
+
document.body.appendChild(a);
|
| 538 |
+
a.click();
|
| 539 |
+
a.remove();
|
| 540 |
+
|
| 541 |
+
if (!isSplitView) {
|
| 542 |
+
switchPreview('after', elements.previewTabs);
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
pdfStepIndicator.complete();
|
| 546 |
+
|
| 547 |
+
// Show success animation
|
| 548 |
+
showSuccessAnimation('PDF Processed!', `${filename} has been downloaded`);
|
| 549 |
+
} catch (e) {
|
| 550 |
+
showToast('Error', e.message, 'error');
|
| 551 |
+
pdfStepIndicator.setStep(2);
|
| 552 |
+
} finally {
|
| 553 |
+
setButtonLoading(elements.btnAfter, false);
|
| 554 |
+
elements.btnBefore.disabled = false;
|
| 555 |
+
showProgress(elements.progress, false);
|
| 556 |
+
}
|
| 557 |
+
}
|
static/js/pdf-tools.js
ADDED
|
@@ -0,0 +1,1052 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PDF Tools Module
|
| 3 |
+
* Images to PDF, Merge PDFs, Split PDF
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// =============== Images to PDF ===============
|
| 7 |
+
|
| 8 |
+
let img2pdfFiles = [];
|
| 9 |
+
|
| 10 |
+
function initImagesToPdf() {
|
| 11 |
+
const dropZone = document.getElementById('img2pdfDropZone');
|
| 12 |
+
const fileInput = document.getElementById('img2pdf_files');
|
| 13 |
+
const preview = document.getElementById('img2pdfPreview');
|
| 14 |
+
const btnCreate = document.getElementById('btnImg2Pdf');
|
| 15 |
+
const btnClear = document.getElementById('btnImg2PdfClear');
|
| 16 |
+
const progress = document.getElementById('img2pdfProgress');
|
| 17 |
+
|
| 18 |
+
if (!dropZone || !fileInput) return;
|
| 19 |
+
|
| 20 |
+
// Click to browse - handle clicks on the drop zone and its children
|
| 21 |
+
dropZone.addEventListener('click', (e) => {
|
| 22 |
+
// Don't trigger if clicking on a remove button
|
| 23 |
+
if (e.target.classList.contains('drop-zone-file-remove')) return;
|
| 24 |
+
fileInput.click();
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
// Drag & drop
|
| 28 |
+
['dragenter', 'dragover'].forEach(e => {
|
| 29 |
+
dropZone.addEventListener(e, (ev) => {
|
| 30 |
+
ev.preventDefault();
|
| 31 |
+
dropZone.classList.add('drag-over');
|
| 32 |
+
});
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
['dragleave', 'drop'].forEach(e => {
|
| 36 |
+
dropZone.addEventListener(e, (ev) => {
|
| 37 |
+
ev.preventDefault();
|
| 38 |
+
dropZone.classList.remove('drag-over');
|
| 39 |
+
});
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
dropZone.addEventListener('drop', (e) => {
|
| 43 |
+
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
|
| 44 |
+
addImages(files);
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
fileInput.addEventListener('change', () => {
|
| 48 |
+
addImages(Array.from(fileInput.files));
|
| 49 |
+
fileInput.value = '';
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
btnClear.addEventListener('click', () => {
|
| 53 |
+
img2pdfFiles = [];
|
| 54 |
+
renderImagePreview();
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
btnCreate.addEventListener('click', () => createPdfFromImages());
|
| 58 |
+
|
| 59 |
+
function addImages(files) {
|
| 60 |
+
files.forEach(file => {
|
| 61 |
+
img2pdfFiles.push(file);
|
| 62 |
+
});
|
| 63 |
+
renderImagePreview();
|
| 64 |
+
showToast('Images Added', `${files.length} image(s) added`, 'success', 2000);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function renderImagePreview() {
|
| 68 |
+
preview.innerHTML = '';
|
| 69 |
+
btnCreate.disabled = img2pdfFiles.length === 0;
|
| 70 |
+
btnClear.disabled = img2pdfFiles.length === 0;
|
| 71 |
+
|
| 72 |
+
img2pdfFiles.forEach((file, index) => {
|
| 73 |
+
const card = document.createElement('div');
|
| 74 |
+
card.className = 'image-card';
|
| 75 |
+
card.draggable = true;
|
| 76 |
+
card.dataset.index = index;
|
| 77 |
+
|
| 78 |
+
const url = URL.createObjectURL(file);
|
| 79 |
+
card.innerHTML = `
|
| 80 |
+
<img class="image-thumb" src="${url}" alt="${file.name}" />
|
| 81 |
+
<div class="image-meta">
|
| 82 |
+
<div class="image-filename">${file.name}</div>
|
| 83 |
+
<div class="image-info">
|
| 84 |
+
<span class="image-badge">#${index + 1}</span>
|
| 85 |
+
<button class="drop-zone-file-remove" data-index="${index}">×</button>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
`;
|
| 89 |
+
|
| 90 |
+
// Remove button
|
| 91 |
+
card.querySelector('.drop-zone-file-remove').addEventListener('click', (e) => {
|
| 92 |
+
e.stopPropagation();
|
| 93 |
+
img2pdfFiles.splice(index, 1);
|
| 94 |
+
renderImagePreview();
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
// Drag reorder
|
| 98 |
+
card.addEventListener('dragstart', (e) => {
|
| 99 |
+
e.dataTransfer.setData('text/plain', index);
|
| 100 |
+
card.style.opacity = '0.5';
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
card.addEventListener('dragend', () => {
|
| 104 |
+
card.style.opacity = '1';
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
card.addEventListener('dragover', (e) => {
|
| 108 |
+
e.preventDefault();
|
| 109 |
+
card.style.borderColor = 'var(--primary)';
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
card.addEventListener('dragleave', () => {
|
| 113 |
+
card.style.borderColor = '';
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
card.addEventListener('drop', (e) => {
|
| 117 |
+
e.preventDefault();
|
| 118 |
+
card.style.borderColor = '';
|
| 119 |
+
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
| 120 |
+
const toIndex = index;
|
| 121 |
+
if (fromIndex !== toIndex) {
|
| 122 |
+
const item = img2pdfFiles.splice(fromIndex, 1)[0];
|
| 123 |
+
img2pdfFiles.splice(toIndex, 0, item);
|
| 124 |
+
renderImagePreview();
|
| 125 |
+
}
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
preview.appendChild(card);
|
| 129 |
+
});
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async function createPdfFromImages() {
|
| 135 |
+
if (img2pdfFiles.length === 0) {
|
| 136 |
+
showToast('No Images', 'Add images first', 'error');
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const btn = document.getElementById('btnImg2Pdf');
|
| 141 |
+
const progress = document.getElementById('img2pdfProgress');
|
| 142 |
+
|
| 143 |
+
setButtonLoading(btn, true, 'Creating...');
|
| 144 |
+
showProgress(progress, true, true);
|
| 145 |
+
|
| 146 |
+
try {
|
| 147 |
+
const fd = new FormData();
|
| 148 |
+
img2pdfFiles.forEach((file, i) => {
|
| 149 |
+
fd.append('files', file);
|
| 150 |
+
});
|
| 151 |
+
fd.append('order', img2pdfFiles.map((_, i) => i).join(','));
|
| 152 |
+
fd.append('output_name', document.getElementById('img2pdf_output').value || 'images.pdf');
|
| 153 |
+
fd.append('page_size', document.getElementById('img2pdf_pagesize').value || 'a4');
|
| 154 |
+
|
| 155 |
+
const res = await fetch('/api/images-to-pdf', { method: 'POST', body: fd });
|
| 156 |
+
|
| 157 |
+
if (!res.ok) {
|
| 158 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 159 |
+
throw new Error(err.detail);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const blob = await res.blob();
|
| 163 |
+
const filename = document.getElementById('img2pdf_output').value || 'images.pdf';
|
| 164 |
+
downloadBlob(blob, filename.endsWith('.pdf') ? filename : filename + '.pdf');
|
| 165 |
+
|
| 166 |
+
showSuccessAnimation('PDF Created!', `${img2pdfFiles.length} images converted`);
|
| 167 |
+
|
| 168 |
+
if (typeof addToRecentFiles === 'function') {
|
| 169 |
+
addToRecentFiles(filename, 'images-to-pdf');
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
} catch (e) {
|
| 173 |
+
showToast('Error', e.message, 'error');
|
| 174 |
+
} finally {
|
| 175 |
+
setButtonLoading(btn, false);
|
| 176 |
+
showProgress(progress, false);
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
// =============== Merge PDFs ===============
|
| 182 |
+
|
| 183 |
+
let mergeFiles = [];
|
| 184 |
+
|
| 185 |
+
function initMergePdf() {
|
| 186 |
+
const dropZone = document.getElementById('mergeDropZone');
|
| 187 |
+
const fileInput = document.getElementById('merge_files');
|
| 188 |
+
const fileList = document.getElementById('mergeFileList');
|
| 189 |
+
const btnMerge = document.getElementById('btnMerge');
|
| 190 |
+
const btnClear = document.getElementById('btnMergeClear');
|
| 191 |
+
const progress = document.getElementById('mergeProgress');
|
| 192 |
+
|
| 193 |
+
if (!dropZone || !fileInput) return;
|
| 194 |
+
|
| 195 |
+
// Click to browse - handle clicks on the drop zone and its children
|
| 196 |
+
dropZone.addEventListener('click', (e) => {
|
| 197 |
+
if (e.target.classList.contains('drop-zone-file-remove')) return;
|
| 198 |
+
fileInput.click();
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
['dragenter', 'dragover'].forEach(e => {
|
| 202 |
+
dropZone.addEventListener(e, (ev) => {
|
| 203 |
+
ev.preventDefault();
|
| 204 |
+
dropZone.classList.add('drag-over');
|
| 205 |
+
});
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
['dragleave', 'drop'].forEach(e => {
|
| 209 |
+
dropZone.addEventListener(e, (ev) => {
|
| 210 |
+
ev.preventDefault();
|
| 211 |
+
dropZone.classList.remove('drag-over');
|
| 212 |
+
});
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
dropZone.addEventListener('drop', (e) => {
|
| 216 |
+
const files = Array.from(e.dataTransfer.files).filter(f => f.type === 'application/pdf');
|
| 217 |
+
addPdfs(files);
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
fileInput.addEventListener('change', () => {
|
| 221 |
+
addPdfs(Array.from(fileInput.files));
|
| 222 |
+
fileInput.value = '';
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
btnClear.addEventListener('click', () => {
|
| 226 |
+
mergeFiles = [];
|
| 227 |
+
renderMergeList();
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
btnMerge.addEventListener('click', () => mergePdfs());
|
| 231 |
+
|
| 232 |
+
function addPdfs(files) {
|
| 233 |
+
files.forEach(file => mergeFiles.push(file));
|
| 234 |
+
renderMergeList();
|
| 235 |
+
showToast('PDFs Added', `${files.length} file(s) added`, 'success', 2000);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function renderMergeList() {
|
| 239 |
+
fileList.innerHTML = '';
|
| 240 |
+
btnMerge.disabled = mergeFiles.length < 2;
|
| 241 |
+
btnClear.disabled = mergeFiles.length === 0;
|
| 242 |
+
|
| 243 |
+
mergeFiles.forEach((file, index) => {
|
| 244 |
+
const item = document.createElement('div');
|
| 245 |
+
item.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--border-light); border-radius: 8px; margin-bottom: 8px; cursor: move;';
|
| 246 |
+
item.draggable = true;
|
| 247 |
+
item.dataset.index = index;
|
| 248 |
+
|
| 249 |
+
item.innerHTML = `
|
| 250 |
+
<svg width="20" height="20" fill="none" stroke="var(--primary)" viewBox="0 0 24 24">
|
| 251 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
| 252 |
+
</svg>
|
| 253 |
+
<span style="flex: 1; font-size: 13px; font-weight: 500;">${file.name}</span>
|
| 254 |
+
<span style="font-size: 11px; color: var(--text-muted);">#${index + 1}</span>
|
| 255 |
+
<button class="drop-zone-file-remove" data-index="${index}" style="padding: 4px 8px;">×</button>
|
| 256 |
+
`;
|
| 257 |
+
|
| 258 |
+
item.querySelector('.drop-zone-file-remove').addEventListener('click', (e) => {
|
| 259 |
+
e.stopPropagation();
|
| 260 |
+
mergeFiles.splice(index, 1);
|
| 261 |
+
renderMergeList();
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
// Drag reorder
|
| 265 |
+
item.addEventListener('dragstart', (e) => {
|
| 266 |
+
e.dataTransfer.setData('text/plain', index);
|
| 267 |
+
item.style.opacity = '0.5';
|
| 268 |
+
});
|
| 269 |
+
item.addEventListener('dragend', () => item.style.opacity = '1');
|
| 270 |
+
item.addEventListener('dragover', (e) => {
|
| 271 |
+
e.preventDefault();
|
| 272 |
+
item.style.background = 'var(--primary-light)';
|
| 273 |
+
});
|
| 274 |
+
item.addEventListener('dragleave', () => item.style.background = '');
|
| 275 |
+
item.addEventListener('drop', (e) => {
|
| 276 |
+
e.preventDefault();
|
| 277 |
+
item.style.background = '';
|
| 278 |
+
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
| 279 |
+
if (fromIndex !== index) {
|
| 280 |
+
const moved = mergeFiles.splice(fromIndex, 1)[0];
|
| 281 |
+
mergeFiles.splice(index, 0, moved);
|
| 282 |
+
renderMergeList();
|
| 283 |
+
}
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
fileList.appendChild(item);
|
| 287 |
+
});
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
async function mergePdfs() {
|
| 292 |
+
if (mergeFiles.length < 2) {
|
| 293 |
+
showToast('Need More Files', 'Add at least 2 PDFs', 'error');
|
| 294 |
+
return;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
const btn = document.getElementById('btnMerge');
|
| 298 |
+
const progress = document.getElementById('mergeProgress');
|
| 299 |
+
|
| 300 |
+
setButtonLoading(btn, true, 'Merging...');
|
| 301 |
+
showProgress(progress, true, true);
|
| 302 |
+
|
| 303 |
+
try {
|
| 304 |
+
const fd = new FormData();
|
| 305 |
+
mergeFiles.forEach(file => fd.append('files', file));
|
| 306 |
+
fd.append('order', mergeFiles.map((_, i) => i).join(','));
|
| 307 |
+
fd.append('output_name', document.getElementById('merge_output').value || 'merged.pdf');
|
| 308 |
+
|
| 309 |
+
const res = await fetch('/api/merge-pdf', { method: 'POST', body: fd });
|
| 310 |
+
|
| 311 |
+
if (!res.ok) {
|
| 312 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 313 |
+
throw new Error(err.detail);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
const blob = await res.blob();
|
| 317 |
+
const filename = document.getElementById('merge_output').value || 'merged.pdf';
|
| 318 |
+
downloadBlob(blob, filename.endsWith('.pdf') ? filename : filename + '.pdf');
|
| 319 |
+
|
| 320 |
+
showSuccessAnimation('PDFs Merged!', `${mergeFiles.length} files combined`);
|
| 321 |
+
|
| 322 |
+
} catch (e) {
|
| 323 |
+
showToast('Error', e.message, 'error');
|
| 324 |
+
} finally {
|
| 325 |
+
setButtonLoading(btn, false);
|
| 326 |
+
showProgress(progress, false);
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
// =============== Split PDF ===============
|
| 332 |
+
|
| 333 |
+
let splitFile = null;
|
| 334 |
+
|
| 335 |
+
function initSplitPdf() {
|
| 336 |
+
const dropZone = document.getElementById('splitDropZone');
|
| 337 |
+
const fileInput = document.getElementById('split_file');
|
| 338 |
+
const btnSplit = document.getElementById('btnSplit');
|
| 339 |
+
const modeSelect = document.getElementById('split_mode');
|
| 340 |
+
const pagesInput = document.getElementById('split_pages');
|
| 341 |
+
const pagesLabel = document.getElementById('split_pages_label');
|
| 342 |
+
const progress = document.getElementById('splitProgress');
|
| 343 |
+
|
| 344 |
+
if (!dropZone) return;
|
| 345 |
+
|
| 346 |
+
// Initialize drop zone
|
| 347 |
+
initDropZone(dropZone, fileInput, {
|
| 348 |
+
maxSize: 100 * 1024 * 1024,
|
| 349 |
+
onFile: (file) => {
|
| 350 |
+
splitFile = file;
|
| 351 |
+
btnSplit.disabled = !file;
|
| 352 |
+
if (file) {
|
| 353 |
+
showToast('PDF Selected', file.name, 'success', 2000);
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
});
|
| 357 |
+
|
| 358 |
+
// Mode change - update label
|
| 359 |
+
modeSelect.addEventListener('change', () => {
|
| 360 |
+
const mode = modeSelect.value;
|
| 361 |
+
if (mode === 'all') {
|
| 362 |
+
pagesLabel.textContent = 'Not needed for this mode';
|
| 363 |
+
pagesInput.placeholder = 'Not needed';
|
| 364 |
+
pagesInput.disabled = true;
|
| 365 |
+
} else if (mode === 'range') {
|
| 366 |
+
pagesLabel.textContent = 'Pages to Extract';
|
| 367 |
+
pagesInput.placeholder = 'e.g., 1,3,5-8';
|
| 368 |
+
pagesInput.disabled = false;
|
| 369 |
+
} else if (mode === 'chunks') {
|
| 370 |
+
pagesLabel.textContent = 'Pages per Chunk';
|
| 371 |
+
pagesInput.placeholder = 'e.g., 5';
|
| 372 |
+
pagesInput.disabled = false;
|
| 373 |
+
}
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
// Trigger initial state
|
| 377 |
+
modeSelect.dispatchEvent(new Event('change'));
|
| 378 |
+
|
| 379 |
+
btnSplit.addEventListener('click', () => splitPdf());
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
async function splitPdf() {
|
| 383 |
+
if (!splitFile) {
|
| 384 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 385 |
+
return;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
const btn = document.getElementById('btnSplit');
|
| 389 |
+
const progress = document.getElementById('splitProgress');
|
| 390 |
+
const mode = document.getElementById('split_mode').value;
|
| 391 |
+
const pages = document.getElementById('split_pages').value;
|
| 392 |
+
|
| 393 |
+
if ((mode === 'range' || mode === 'chunks') && !pages.trim()) {
|
| 394 |
+
showToast('Missing Input', 'Enter pages or chunk size', 'error');
|
| 395 |
+
return;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
setButtonLoading(btn, true, 'Splitting...');
|
| 399 |
+
showProgress(progress, true, true);
|
| 400 |
+
|
| 401 |
+
try {
|
| 402 |
+
const fd = new FormData();
|
| 403 |
+
fd.append('file', splitFile);
|
| 404 |
+
fd.append('mode', mode);
|
| 405 |
+
fd.append('pages', pages);
|
| 406 |
+
fd.append('output_name', document.getElementById('split_output').value || 'split');
|
| 407 |
+
|
| 408 |
+
const res = await fetch('/api/split-pdf', { method: 'POST', body: fd });
|
| 409 |
+
|
| 410 |
+
if (!res.ok) {
|
| 411 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 412 |
+
throw new Error(err.detail);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
const blob = await res.blob();
|
| 416 |
+
const contentType = res.headers.get('content-type');
|
| 417 |
+
const outputName = document.getElementById('split_output').value || 'split';
|
| 418 |
+
|
| 419 |
+
if (contentType.includes('zip')) {
|
| 420 |
+
downloadBlob(blob, `${outputName}.zip`);
|
| 421 |
+
} else {
|
| 422 |
+
downloadBlob(blob, `${outputName}.pdf`);
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
showSuccessAnimation('PDF Split!', 'Download started');
|
| 426 |
+
|
| 427 |
+
} catch (e) {
|
| 428 |
+
showToast('Error', e.message, 'error');
|
| 429 |
+
} finally {
|
| 430 |
+
setButtonLoading(btn, false);
|
| 431 |
+
showProgress(progress, false);
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
// =============== Utility ===============
|
| 437 |
+
|
| 438 |
+
function downloadBlob(blob, filename) {
|
| 439 |
+
const url = URL.createObjectURL(blob);
|
| 440 |
+
const a = document.createElement('a');
|
| 441 |
+
a.href = url;
|
| 442 |
+
a.download = filename;
|
| 443 |
+
document.body.appendChild(a);
|
| 444 |
+
a.click();
|
| 445 |
+
a.remove();
|
| 446 |
+
URL.revokeObjectURL(url);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
// =============== PDF to Images ===============
|
| 451 |
+
|
| 452 |
+
let pdf2imgFile = null;
|
| 453 |
+
|
| 454 |
+
function initPdf2Img() {
|
| 455 |
+
const dropZone = document.getElementById('pdf2imgDropZone');
|
| 456 |
+
const fileInput = document.getElementById('pdf2img_file');
|
| 457 |
+
const btn = document.getElementById('btnPdf2Img');
|
| 458 |
+
|
| 459 |
+
if (!dropZone || !fileInput) return;
|
| 460 |
+
|
| 461 |
+
initDropZone(dropZone, fileInput, {
|
| 462 |
+
maxSize: 100 * 1024 * 1024,
|
| 463 |
+
onFile: (file) => {
|
| 464 |
+
pdf2imgFile = file;
|
| 465 |
+
btn.disabled = !file;
|
| 466 |
+
if (file) showToast('PDF Selected', file.name, 'success', 2000);
|
| 467 |
+
}
|
| 468 |
+
});
|
| 469 |
+
|
| 470 |
+
btn.addEventListener('click', () => convertPdfToImages());
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
async function convertPdfToImages() {
|
| 474 |
+
if (!pdf2imgFile) {
|
| 475 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 476 |
+
return;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
const btn = document.getElementById('btnPdf2Img');
|
| 480 |
+
const progress = document.getElementById('pdf2imgProgress');
|
| 481 |
+
|
| 482 |
+
setButtonLoading(btn, true, 'Converting...');
|
| 483 |
+
showProgress(progress, true, true);
|
| 484 |
+
|
| 485 |
+
try {
|
| 486 |
+
const fd = new FormData();
|
| 487 |
+
fd.append('file', pdf2imgFile);
|
| 488 |
+
fd.append('format', document.getElementById('pdf2img_format').value);
|
| 489 |
+
fd.append('dpi', document.getElementById('pdf2img_dpi').value);
|
| 490 |
+
fd.append('pages', document.getElementById('pdf2img_pages').value);
|
| 491 |
+
fd.append('output_name', document.getElementById('pdf2img_output').value || 'pages');
|
| 492 |
+
|
| 493 |
+
const res = await fetch('/api/pdf-to-images', { method: 'POST', body: fd });
|
| 494 |
+
|
| 495 |
+
if (!res.ok) {
|
| 496 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 497 |
+
throw new Error(err.detail);
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
const blob = await res.blob();
|
| 501 |
+
const outputName = document.getElementById('pdf2img_output').value || 'pages';
|
| 502 |
+
downloadBlob(blob, `${outputName}_images.zip`);
|
| 503 |
+
|
| 504 |
+
showSuccessAnimation('Conversion Complete!', 'Images downloaded as ZIP');
|
| 505 |
+
|
| 506 |
+
} catch (e) {
|
| 507 |
+
showToast('Error', e.message, 'error');
|
| 508 |
+
} finally {
|
| 509 |
+
setButtonLoading(btn, false);
|
| 510 |
+
showProgress(progress, false);
|
| 511 |
+
}
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
// =============== Compress PDF ===============
|
| 516 |
+
|
| 517 |
+
let compressFile = null;
|
| 518 |
+
|
| 519 |
+
function initCompressPdf() {
|
| 520 |
+
const dropZone = document.getElementById('compressDropZone');
|
| 521 |
+
const fileInput = document.getElementById('compress_file');
|
| 522 |
+
const btn = document.getElementById('btnCompress');
|
| 523 |
+
const btnPreview = document.getElementById('btnCompressPreview');
|
| 524 |
+
const qualitySlider = document.getElementById('compress_quality');
|
| 525 |
+
const qualityVal = document.getElementById('compress_quality_val');
|
| 526 |
+
|
| 527 |
+
if (!dropZone || !fileInput) return;
|
| 528 |
+
|
| 529 |
+
initDropZone(dropZone, fileInput, {
|
| 530 |
+
maxSize: 100 * 1024 * 1024,
|
| 531 |
+
onFile: (file) => {
|
| 532 |
+
compressFile = file;
|
| 533 |
+
btn.disabled = !file;
|
| 534 |
+
if (btnPreview) btnPreview.disabled = !file;
|
| 535 |
+
const resultEl = document.getElementById('compressResult');
|
| 536 |
+
const previewEl = document.getElementById('compressPreviewCard');
|
| 537 |
+
if (resultEl) resultEl.style.display = 'none';
|
| 538 |
+
if (previewEl) previewEl.style.display = 'none';
|
| 539 |
+
if (file) {
|
| 540 |
+
showToast('PDF Selected', file.name, 'success', 2000);
|
| 541 |
+
// Trigger initial estimate
|
| 542 |
+
updateCompressPdfEstimate();
|
| 543 |
+
}
|
| 544 |
+
}
|
| 545 |
+
});
|
| 546 |
+
|
| 547 |
+
// Quality slider with debounced estimate
|
| 548 |
+
if (qualitySlider && qualityVal) {
|
| 549 |
+
let estimateTimeout = null;
|
| 550 |
+
qualitySlider.addEventListener('input', () => {
|
| 551 |
+
qualityVal.textContent = qualitySlider.value;
|
| 552 |
+
// Debounce the estimate call
|
| 553 |
+
if (estimateTimeout) clearTimeout(estimateTimeout);
|
| 554 |
+
estimateTimeout = setTimeout(() => {
|
| 555 |
+
updateCompressPdfEstimate();
|
| 556 |
+
}, 300);
|
| 557 |
+
});
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
if (btnPreview) btnPreview.addEventListener('click', () => previewCompressPdf());
|
| 561 |
+
btn.addEventListener('click', () => compressPdf());
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
async function updateCompressPdfEstimate() {
|
| 565 |
+
if (!compressFile) return;
|
| 566 |
+
|
| 567 |
+
const qualitySlider = document.getElementById('compress_quality');
|
| 568 |
+
if (!qualitySlider) return;
|
| 569 |
+
|
| 570 |
+
const quality = qualitySlider.value;
|
| 571 |
+
|
| 572 |
+
// Create estimate display if it doesn't exist
|
| 573 |
+
let displayDiv = document.getElementById('compressPdfEstimate');
|
| 574 |
+
if (!displayDiv) {
|
| 575 |
+
displayDiv = document.createElement('div');
|
| 576 |
+
displayDiv.id = 'compressPdfEstimate';
|
| 577 |
+
displayDiv.style.cssText = 'margin-top: 12px; padding: 12px; background: var(--border-light); border-radius: var(--radius); font-size: 13px;';
|
| 578 |
+
const qualityGroup = qualitySlider.closest('.form-group');
|
| 579 |
+
if (qualityGroup) {
|
| 580 |
+
qualityGroup.appendChild(displayDiv);
|
| 581 |
+
}
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
displayDiv.innerHTML = '<span style="color: var(--text-muted);">Estimating file size...</span>';
|
| 585 |
+
|
| 586 |
+
try {
|
| 587 |
+
const fd = new FormData();
|
| 588 |
+
fd.append('file', compressFile);
|
| 589 |
+
fd.append('quality', quality);
|
| 590 |
+
|
| 591 |
+
const res = await fetch('/api/estimate/compress-pdf', { method: 'POST', body: fd });
|
| 592 |
+
|
| 593 |
+
if (!res.ok) {
|
| 594 |
+
displayDiv.innerHTML = '<span style="color: var(--text-muted);">Could not estimate</span>';
|
| 595 |
+
return;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
const data = await res.json();
|
| 599 |
+
const origKB = (data.original_size / 1024).toFixed(1);
|
| 600 |
+
const estKB = (data.estimated_size / 1024).toFixed(1);
|
| 601 |
+
const origMB = (data.original_size / 1024 / 1024).toFixed(2);
|
| 602 |
+
const estMB = (data.estimated_size / 1024 / 1024).toFixed(2);
|
| 603 |
+
const reduction = data.reduction_percent;
|
| 604 |
+
|
| 605 |
+
// Use KB for small files, MB for large
|
| 606 |
+
const useKB = data.original_size < 1024 * 1024;
|
| 607 |
+
const origDisplay = useKB ? `${origKB} KB` : `${origMB} MB`;
|
| 608 |
+
const estDisplay = useKB ? `${estKB} KB` : `${estMB} MB`;
|
| 609 |
+
|
| 610 |
+
displayDiv.innerHTML = `
|
| 611 |
+
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;">
|
| 612 |
+
<span><strong>Original:</strong> ${origDisplay}</span>
|
| 613 |
+
<span style="color: var(--primary);">→</span>
|
| 614 |
+
<span><strong>Estimated:</strong> ${estDisplay}</span>
|
| 615 |
+
<span style="color: ${reduction > 0 ? 'var(--success)' : 'var(--text-muted)'}; font-weight: 600;">
|
| 616 |
+
${reduction > 0 ? '-' + reduction + '%' : 'Minimal reduction'}
|
| 617 |
+
</span>
|
| 618 |
+
</div>
|
| 619 |
+
`;
|
| 620 |
+
} catch (e) {
|
| 621 |
+
console.error('Estimate error:', e);
|
| 622 |
+
displayDiv.innerHTML = '<span style="color: var(--text-muted);">Could not estimate</span>';
|
| 623 |
+
}
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
async function previewCompressPdf() {
|
| 627 |
+
if (!compressFile) {
|
| 628 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 629 |
+
return;
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
const btn = document.getElementById('btnCompressPreview');
|
| 633 |
+
const previewCard = document.getElementById('compressPreviewCard');
|
| 634 |
+
const originalImg = document.getElementById('compressPreviewOriginal');
|
| 635 |
+
const processedImg = document.getElementById('compressPreviewProcessed');
|
| 636 |
+
|
| 637 |
+
setButtonLoading(btn, true, 'Loading...');
|
| 638 |
+
|
| 639 |
+
try {
|
| 640 |
+
const fd = new FormData();
|
| 641 |
+
fd.append('file', compressFile);
|
| 642 |
+
fd.append('quality', document.getElementById('compress_quality').value);
|
| 643 |
+
|
| 644 |
+
const res = await fetch('/api/preview/compress-pdf', { method: 'POST', body: fd });
|
| 645 |
+
|
| 646 |
+
if (!res.ok) {
|
| 647 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 648 |
+
throw new Error(err.detail);
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
const data = await res.json();
|
| 652 |
+
originalImg.src = 'data:image/png;base64,' + data.original;
|
| 653 |
+
processedImg.src = 'data:image/png;base64,' + data.processed;
|
| 654 |
+
previewCard.style.display = 'block';
|
| 655 |
+
previewCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 656 |
+
|
| 657 |
+
showToast('Preview Ready', 'Compare original vs compressed', 'success', 2000);
|
| 658 |
+
|
| 659 |
+
} catch (e) {
|
| 660 |
+
showToast('Error', e.message, 'error');
|
| 661 |
+
} finally {
|
| 662 |
+
setButtonLoading(btn, false);
|
| 663 |
+
}
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
async function compressPdf() {
|
| 667 |
+
if (!compressFile) {
|
| 668 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 669 |
+
return;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
const btn = document.getElementById('btnCompress');
|
| 673 |
+
const progress = document.getElementById('compressProgress');
|
| 674 |
+
const resultDiv = document.getElementById('compressResult');
|
| 675 |
+
const statsDiv = document.getElementById('compressStats');
|
| 676 |
+
|
| 677 |
+
setButtonLoading(btn, true, 'Compressing...');
|
| 678 |
+
showProgress(progress, true, true);
|
| 679 |
+
resultDiv.style.display = 'none';
|
| 680 |
+
|
| 681 |
+
try {
|
| 682 |
+
const fd = new FormData();
|
| 683 |
+
fd.append('file', compressFile);
|
| 684 |
+
fd.append('quality', document.getElementById('compress_quality').value);
|
| 685 |
+
fd.append('output_name', document.getElementById('compress_output').value || 'compressed.pdf');
|
| 686 |
+
|
| 687 |
+
const res = await fetch('/api/compress-pdf', { method: 'POST', body: fd });
|
| 688 |
+
|
| 689 |
+
if (!res.ok) {
|
| 690 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 691 |
+
throw new Error(err.detail);
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
const blob = await res.blob();
|
| 695 |
+
const filename = document.getElementById('compress_output').value || 'compressed.pdf';
|
| 696 |
+
downloadBlob(blob, filename.endsWith('.pdf') ? filename : filename + '.pdf');
|
| 697 |
+
|
| 698 |
+
// Show compression stats
|
| 699 |
+
const originalSize = res.headers.get('X-Original-Size');
|
| 700 |
+
const compressedSize = res.headers.get('X-Compressed-Size');
|
| 701 |
+
const reduction = res.headers.get('X-Reduction-Percent');
|
| 702 |
+
|
| 703 |
+
if (originalSize && compressedSize) {
|
| 704 |
+
const origMB = (parseInt(originalSize) / 1024 / 1024).toFixed(2);
|
| 705 |
+
const compMB = (parseInt(compressedSize) / 1024 / 1024).toFixed(2);
|
| 706 |
+
statsDiv.textContent = `${origMB} MB → ${compMB} MB (${reduction}% smaller)`;
|
| 707 |
+
resultDiv.style.display = 'block';
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
showSuccessAnimation('PDF Compressed!', `Reduced by ${reduction}%`);
|
| 711 |
+
|
| 712 |
+
} catch (e) {
|
| 713 |
+
showToast('Error', e.message, 'error');
|
| 714 |
+
} finally {
|
| 715 |
+
setButtonLoading(btn, false);
|
| 716 |
+
showProgress(progress, false);
|
| 717 |
+
}
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
// =============== Rotate PDF ===============
|
| 722 |
+
|
| 723 |
+
let rotateFile = null;
|
| 724 |
+
|
| 725 |
+
function initRotatePdf() {
|
| 726 |
+
const dropZone = document.getElementById('rotateDropZone');
|
| 727 |
+
const fileInput = document.getElementById('rotate_file');
|
| 728 |
+
const btn = document.getElementById('btnRotate');
|
| 729 |
+
const btnPreview = document.getElementById('btnRotatePreview');
|
| 730 |
+
|
| 731 |
+
if (!dropZone || !fileInput) return;
|
| 732 |
+
|
| 733 |
+
initDropZone(dropZone, fileInput, {
|
| 734 |
+
maxSize: 100 * 1024 * 1024,
|
| 735 |
+
onFile: (file) => {
|
| 736 |
+
rotateFile = file;
|
| 737 |
+
btn.disabled = !file;
|
| 738 |
+
btnPreview.disabled = !file;
|
| 739 |
+
document.getElementById('rotatePreviewCard').style.display = 'none';
|
| 740 |
+
if (file) showToast('PDF Selected', file.name, 'success', 2000);
|
| 741 |
+
}
|
| 742 |
+
});
|
| 743 |
+
|
| 744 |
+
btnPreview.addEventListener('click', () => previewRotatePdf());
|
| 745 |
+
btn.addEventListener('click', () => rotatePdf());
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
async function previewRotatePdf() {
|
| 749 |
+
if (!rotateFile) {
|
| 750 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 751 |
+
return;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
const btn = document.getElementById('btnRotatePreview');
|
| 755 |
+
const previewCard = document.getElementById('rotatePreviewCard');
|
| 756 |
+
const originalImg = document.getElementById('rotatePreviewOriginal');
|
| 757 |
+
const processedImg = document.getElementById('rotatePreviewProcessed');
|
| 758 |
+
|
| 759 |
+
setButtonLoading(btn, true, 'Loading...');
|
| 760 |
+
|
| 761 |
+
try {
|
| 762 |
+
const fd = new FormData();
|
| 763 |
+
fd.append('file', rotateFile);
|
| 764 |
+
fd.append('rotation', document.getElementById('rotate_angle').value);
|
| 765 |
+
|
| 766 |
+
const res = await fetch('/api/preview/rotate-pdf', { method: 'POST', body: fd });
|
| 767 |
+
|
| 768 |
+
if (!res.ok) {
|
| 769 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 770 |
+
throw new Error(err.detail);
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
const data = await res.json();
|
| 774 |
+
originalImg.src = 'data:image/png;base64,' + data.original;
|
| 775 |
+
processedImg.src = 'data:image/png;base64,' + data.processed;
|
| 776 |
+
previewCard.style.display = 'block';
|
| 777 |
+
previewCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 778 |
+
|
| 779 |
+
showToast('Preview Ready', 'Compare original vs rotated', 'success', 2000);
|
| 780 |
+
|
| 781 |
+
} catch (e) {
|
| 782 |
+
showToast('Error', e.message, 'error');
|
| 783 |
+
} finally {
|
| 784 |
+
setButtonLoading(btn, false);
|
| 785 |
+
}
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
async function rotatePdf() {
|
| 789 |
+
if (!rotateFile) {
|
| 790 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 791 |
+
return;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
const btn = document.getElementById('btnRotate');
|
| 795 |
+
const progress = document.getElementById('rotateProgress');
|
| 796 |
+
|
| 797 |
+
setButtonLoading(btn, true, 'Rotating...');
|
| 798 |
+
showProgress(progress, true, true);
|
| 799 |
+
|
| 800 |
+
try {
|
| 801 |
+
const fd = new FormData();
|
| 802 |
+
fd.append('file', rotateFile);
|
| 803 |
+
fd.append('rotation', document.getElementById('rotate_angle').value);
|
| 804 |
+
fd.append('pages', document.getElementById('rotate_pages').value);
|
| 805 |
+
fd.append('output_name', document.getElementById('rotate_output').value || 'rotated.pdf');
|
| 806 |
+
|
| 807 |
+
const res = await fetch('/api/rotate-pdf', { method: 'POST', body: fd });
|
| 808 |
+
|
| 809 |
+
if (!res.ok) {
|
| 810 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 811 |
+
throw new Error(err.detail);
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
const blob = await res.blob();
|
| 815 |
+
const filename = document.getElementById('rotate_output').value || 'rotated.pdf';
|
| 816 |
+
downloadBlob(blob, filename.endsWith('.pdf') ? filename : filename + '.pdf');
|
| 817 |
+
|
| 818 |
+
showSuccessAnimation('PDF Rotated!', 'Download started');
|
| 819 |
+
|
| 820 |
+
} catch (e) {
|
| 821 |
+
showToast('Error', e.message, 'error');
|
| 822 |
+
} finally {
|
| 823 |
+
setButtonLoading(btn, false);
|
| 824 |
+
showProgress(progress, false);
|
| 825 |
+
}
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
// =============== Page Numbers ===============
|
| 830 |
+
|
| 831 |
+
let pagenumsFile = null;
|
| 832 |
+
|
| 833 |
+
function initPageNums() {
|
| 834 |
+
const dropZone = document.getElementById('pagenumsDropZone');
|
| 835 |
+
const fileInput = document.getElementById('pagenums_file');
|
| 836 |
+
const btn = document.getElementById('btnPageNums');
|
| 837 |
+
const btnPreview = document.getElementById('btnPageNumsPreview');
|
| 838 |
+
|
| 839 |
+
if (!dropZone || !fileInput) return;
|
| 840 |
+
|
| 841 |
+
initDropZone(dropZone, fileInput, {
|
| 842 |
+
maxSize: 100 * 1024 * 1024,
|
| 843 |
+
onFile: (file) => {
|
| 844 |
+
pagenumsFile = file;
|
| 845 |
+
btn.disabled = !file;
|
| 846 |
+
btnPreview.disabled = !file;
|
| 847 |
+
document.getElementById('pagenumsPreviewCard').style.display = 'none';
|
| 848 |
+
if (file) showToast('PDF Selected', file.name, 'success', 2000);
|
| 849 |
+
}
|
| 850 |
+
});
|
| 851 |
+
|
| 852 |
+
btnPreview.addEventListener('click', () => previewPageNums());
|
| 853 |
+
btn.addEventListener('click', () => addPageNumbers());
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
async function previewPageNums() {
|
| 857 |
+
if (!pagenumsFile) {
|
| 858 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 859 |
+
return;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
const btn = document.getElementById('btnPageNumsPreview');
|
| 863 |
+
const previewCard = document.getElementById('pagenumsPreviewCard');
|
| 864 |
+
const originalImg = document.getElementById('pagenumsPreviewOriginal');
|
| 865 |
+
const processedImg = document.getElementById('pagenumsPreviewProcessed');
|
| 866 |
+
|
| 867 |
+
setButtonLoading(btn, true, 'Loading...');
|
| 868 |
+
|
| 869 |
+
try {
|
| 870 |
+
const fd = new FormData();
|
| 871 |
+
fd.append('file', pagenumsFile);
|
| 872 |
+
fd.append('position', document.getElementById('pagenums_position').value);
|
| 873 |
+
fd.append('format', document.getElementById('pagenums_format').value);
|
| 874 |
+
fd.append('start_number', document.getElementById('pagenums_start').value);
|
| 875 |
+
fd.append('font_size', document.getElementById('pagenums_fontsize').value);
|
| 876 |
+
|
| 877 |
+
const res = await fetch('/api/preview/page-numbers', { method: 'POST', body: fd });
|
| 878 |
+
|
| 879 |
+
if (!res.ok) {
|
| 880 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 881 |
+
throw new Error(err.detail);
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
const data = await res.json();
|
| 885 |
+
originalImg.src = 'data:image/png;base64,' + data.original;
|
| 886 |
+
processedImg.src = 'data:image/png;base64,' + data.processed;
|
| 887 |
+
previewCard.style.display = 'block';
|
| 888 |
+
previewCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 889 |
+
|
| 890 |
+
showToast('Preview Ready', 'Compare original vs numbered', 'success', 2000);
|
| 891 |
+
|
| 892 |
+
} catch (e) {
|
| 893 |
+
showToast('Error', e.message, 'error');
|
| 894 |
+
} finally {
|
| 895 |
+
setButtonLoading(btn, false);
|
| 896 |
+
}
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
async function addPageNumbers() {
|
| 900 |
+
if (!pagenumsFile) {
|
| 901 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 902 |
+
return;
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
const btn = document.getElementById('btnPageNums');
|
| 906 |
+
const progress = document.getElementById('pagenumsProgress');
|
| 907 |
+
|
| 908 |
+
setButtonLoading(btn, true, 'Processing...');
|
| 909 |
+
showProgress(progress, true, true);
|
| 910 |
+
|
| 911 |
+
try {
|
| 912 |
+
const fd = new FormData();
|
| 913 |
+
fd.append('file', pagenumsFile);
|
| 914 |
+
fd.append('position', document.getElementById('pagenums_position').value);
|
| 915 |
+
fd.append('format', document.getElementById('pagenums_format').value);
|
| 916 |
+
fd.append('start_number', document.getElementById('pagenums_start').value);
|
| 917 |
+
fd.append('font_size', document.getElementById('pagenums_fontsize').value);
|
| 918 |
+
fd.append('output_name', document.getElementById('pagenums_output').value || 'numbered.pdf');
|
| 919 |
+
|
| 920 |
+
const res = await fetch('/api/add-page-numbers', { method: 'POST', body: fd });
|
| 921 |
+
|
| 922 |
+
if (!res.ok) {
|
| 923 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 924 |
+
throw new Error(err.detail);
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
const blob = await res.blob();
|
| 928 |
+
const filename = document.getElementById('pagenums_output').value || 'numbered.pdf';
|
| 929 |
+
downloadBlob(blob, filename.endsWith('.pdf') ? filename : filename + '.pdf');
|
| 930 |
+
|
| 931 |
+
showSuccessAnimation('Page Numbers Added!', 'Download started');
|
| 932 |
+
|
| 933 |
+
} catch (e) {
|
| 934 |
+
showToast('Error', e.message, 'error');
|
| 935 |
+
} finally {
|
| 936 |
+
setButtonLoading(btn, false);
|
| 937 |
+
showProgress(progress, false);
|
| 938 |
+
}
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
// =============== PDF OCR ===============
|
| 943 |
+
|
| 944 |
+
let ocrFile = null;
|
| 945 |
+
let ocrExtractedText = '';
|
| 946 |
+
|
| 947 |
+
function initPdfOcr() {
|
| 948 |
+
const dropZone = document.getElementById('ocrDropZone');
|
| 949 |
+
const fileInput = document.getElementById('ocr_file');
|
| 950 |
+
const btn = document.getElementById('btnOcr');
|
| 951 |
+
const copyBtn = document.getElementById('btnOcrCopy');
|
| 952 |
+
const notice = document.getElementById('ocrNotice');
|
| 953 |
+
|
| 954 |
+
if (!dropZone || !fileInput) return;
|
| 955 |
+
|
| 956 |
+
// Check if Tesseract is available
|
| 957 |
+
fetch('/api/ocr-status')
|
| 958 |
+
.then(r => r.json())
|
| 959 |
+
.then(data => {
|
| 960 |
+
if (!data.available && notice) {
|
| 961 |
+
notice.style.display = 'flex';
|
| 962 |
+
}
|
| 963 |
+
})
|
| 964 |
+
.catch(() => {});
|
| 965 |
+
|
| 966 |
+
initDropZone(dropZone, fileInput, {
|
| 967 |
+
maxSize: 100 * 1024 * 1024,
|
| 968 |
+
onFile: (file) => {
|
| 969 |
+
ocrFile = file;
|
| 970 |
+
btn.disabled = !file;
|
| 971 |
+
document.getElementById('ocrResult').style.display = 'none';
|
| 972 |
+
copyBtn.disabled = true;
|
| 973 |
+
if (file) showToast('PDF Selected', file.name, 'success', 2000);
|
| 974 |
+
}
|
| 975 |
+
});
|
| 976 |
+
|
| 977 |
+
btn.addEventListener('click', () => extractPdfText());
|
| 978 |
+
copyBtn.addEventListener('click', () => copyOcrText());
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
async function extractPdfText() {
|
| 982 |
+
if (!ocrFile) {
|
| 983 |
+
showToast('No PDF', 'Upload a PDF first', 'error');
|
| 984 |
+
return;
|
| 985 |
+
}
|
| 986 |
+
|
| 987 |
+
const btn = document.getElementById('btnOcr');
|
| 988 |
+
const copyBtn = document.getElementById('btnOcrCopy');
|
| 989 |
+
const progress = document.getElementById('ocrProgress');
|
| 990 |
+
const resultDiv = document.getElementById('ocrResult');
|
| 991 |
+
const textArea = document.getElementById('ocrText');
|
| 992 |
+
|
| 993 |
+
setButtonLoading(btn, true, 'Extracting...');
|
| 994 |
+
showProgress(progress, true, true);
|
| 995 |
+
resultDiv.style.display = 'none';
|
| 996 |
+
|
| 997 |
+
try {
|
| 998 |
+
const fd = new FormData();
|
| 999 |
+
fd.append('file', ocrFile);
|
| 1000 |
+
fd.append('language', document.getElementById('ocr_language').value);
|
| 1001 |
+
fd.append('pages', document.getElementById('ocr_pages').value);
|
| 1002 |
+
fd.append('dpi', document.getElementById('ocr_dpi').value);
|
| 1003 |
+
fd.append('output_format', 'json');
|
| 1004 |
+
|
| 1005 |
+
const res = await fetch('/api/pdf-ocr', { method: 'POST', body: fd });
|
| 1006 |
+
|
| 1007 |
+
if (!res.ok) {
|
| 1008 |
+
const err = await res.json().catch(() => ({ detail: 'Failed' }));
|
| 1009 |
+
throw new Error(err.detail);
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
const data = await res.json();
|
| 1013 |
+
ocrExtractedText = data.text;
|
| 1014 |
+
|
| 1015 |
+
textArea.value = ocrExtractedText;
|
| 1016 |
+
resultDiv.style.display = 'block';
|
| 1017 |
+
copyBtn.disabled = false;
|
| 1018 |
+
|
| 1019 |
+
showToast('Text Extracted', `${data.pages} page(s) processed`, 'success');
|
| 1020 |
+
|
| 1021 |
+
} catch (e) {
|
| 1022 |
+
showToast('Error', e.message, 'error');
|
| 1023 |
+
} finally {
|
| 1024 |
+
setButtonLoading(btn, false);
|
| 1025 |
+
showProgress(progress, false);
|
| 1026 |
+
}
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
async function copyOcrText() {
|
| 1030 |
+
if (!ocrExtractedText) return;
|
| 1031 |
+
|
| 1032 |
+
try {
|
| 1033 |
+
await navigator.clipboard.writeText(ocrExtractedText);
|
| 1034 |
+
showToast('Copied!', 'Text copied to clipboard', 'success', 2000);
|
| 1035 |
+
} catch (e) {
|
| 1036 |
+
showToast('Copy Failed', 'Please select and copy manually', 'error');
|
| 1037 |
+
}
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
|
| 1041 |
+
// =============== Initialize All ===============
|
| 1042 |
+
|
| 1043 |
+
function initPdfTools() {
|
| 1044 |
+
initImagesToPdf();
|
| 1045 |
+
initMergePdf();
|
| 1046 |
+
initSplitPdf();
|
| 1047 |
+
initPdf2Img();
|
| 1048 |
+
initCompressPdf();
|
| 1049 |
+
initRotatePdf();
|
| 1050 |
+
initPageNums();
|
| 1051 |
+
initPdfOcr();
|
| 1052 |
+
}
|
static/js/utils.js
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Utility Functions
|
| 3 |
+
* Toast, loading, validation, drag-drop, modal
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// =============== Toast Notifications ===============
|
| 7 |
+
|
| 8 |
+
const toastContainer = document.createElement('div');
|
| 9 |
+
toastContainer.className = 'toast-container';
|
| 10 |
+
document.body.appendChild(toastContainer);
|
| 11 |
+
|
| 12 |
+
const toastIcons = {
|
| 13 |
+
success: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`,
|
| 14 |
+
error: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`,
|
| 15 |
+
info: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
function showToast(title, message, type = 'info', duration = 4000) {
|
| 19 |
+
const toast = document.createElement('div');
|
| 20 |
+
toast.className = `toast toast-${type}`;
|
| 21 |
+
|
| 22 |
+
toast.innerHTML = `
|
| 23 |
+
<div class="toast-icon">${toastIcons[type]}</div>
|
| 24 |
+
<div class="toast-content">
|
| 25 |
+
<div class="toast-title">${title}</div>
|
| 26 |
+
${message ? `<div class="toast-message">${message}</div>` : ''}
|
| 27 |
+
</div>
|
| 28 |
+
<button class="toast-close">
|
| 29 |
+
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 30 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
| 31 |
+
</svg>
|
| 32 |
+
</button>
|
| 33 |
+
`;
|
| 34 |
+
|
| 35 |
+
toast.querySelector('.toast-close').addEventListener('click', () => removeToast(toast));
|
| 36 |
+
toastContainer.appendChild(toast);
|
| 37 |
+
|
| 38 |
+
if (duration > 0) {
|
| 39 |
+
setTimeout(() => removeToast(toast), duration);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return toast;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function removeToast(toast) {
|
| 46 |
+
if (!toast.parentNode) return;
|
| 47 |
+
toast.classList.add('hiding');
|
| 48 |
+
setTimeout(() => toast.remove(), 300);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// =============== Loading States ===============
|
| 52 |
+
|
| 53 |
+
function setButtonLoading(btn, loading, loadingText = 'Loading...') {
|
| 54 |
+
if (loading) {
|
| 55 |
+
btn.dataset.originalText = btn.innerHTML;
|
| 56 |
+
btn.innerHTML = `<span class="spinner"></span> ${loadingText}`;
|
| 57 |
+
btn.classList.add('loading');
|
| 58 |
+
btn.disabled = true;
|
| 59 |
+
} else {
|
| 60 |
+
btn.innerHTML = btn.dataset.originalText || btn.innerHTML;
|
| 61 |
+
btn.classList.remove('loading');
|
| 62 |
+
btn.disabled = false;
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function showProgress(container, show = true, indeterminate = true) {
|
| 67 |
+
if (!container) return;
|
| 68 |
+
|
| 69 |
+
if (show) {
|
| 70 |
+
container.classList.add('active');
|
| 71 |
+
const fill = container.querySelector('.progress-fill');
|
| 72 |
+
if (fill) {
|
| 73 |
+
if (indeterminate) {
|
| 74 |
+
fill.classList.add('indeterminate');
|
| 75 |
+
fill.style.width = '';
|
| 76 |
+
} else {
|
| 77 |
+
fill.classList.remove('indeterminate');
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
} else {
|
| 81 |
+
container.classList.remove('active');
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// =============== Form Validation ===============
|
| 86 |
+
|
| 87 |
+
function validateUrl(input, errorMsg = 'Invalid URL') {
|
| 88 |
+
const value = input.value.trim();
|
| 89 |
+
const formGroup = input.closest('.form-group');
|
| 90 |
+
|
| 91 |
+
if (!value) {
|
| 92 |
+
clearFieldError(formGroup);
|
| 93 |
+
return true;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
try {
|
| 97 |
+
new URL(value);
|
| 98 |
+
clearFieldError(formGroup);
|
| 99 |
+
return true;
|
| 100 |
+
} catch {
|
| 101 |
+
setFieldError(formGroup, errorMsg);
|
| 102 |
+
return false;
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function setFieldError(formGroup, message) {
|
| 107 |
+
if (!formGroup) return;
|
| 108 |
+
formGroup.classList.add('error');
|
| 109 |
+
|
| 110 |
+
let errorEl = formGroup.querySelector('.form-error');
|
| 111 |
+
if (!errorEl) {
|
| 112 |
+
errorEl = document.createElement('div');
|
| 113 |
+
errorEl.className = 'form-error';
|
| 114 |
+
errorEl.innerHTML = `
|
| 115 |
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 116 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 117 |
+
</svg>
|
| 118 |
+
<span></span>
|
| 119 |
+
`;
|
| 120 |
+
formGroup.appendChild(errorEl);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
errorEl.querySelector('span').textContent = message;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function clearFieldError(formGroup) {
|
| 127 |
+
if (!formGroup) return;
|
| 128 |
+
formGroup.classList.remove('error');
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// =============== Drag & Drop ===============
|
| 132 |
+
|
| 133 |
+
function initDropZone(dropZone, fileInput, options = {}) {
|
| 134 |
+
const {
|
| 135 |
+
onFile = () => {},
|
| 136 |
+
maxSize = 50 * 1024 * 1024
|
| 137 |
+
} = options;
|
| 138 |
+
|
| 139 |
+
const fileDisplay = dropZone.querySelector('.drop-zone-file');
|
| 140 |
+
const fileName = dropZone.querySelector('.drop-zone-file-name');
|
| 141 |
+
const fileSize = dropZone.querySelector('.drop-zone-file-size');
|
| 142 |
+
const removeBtn = dropZone.querySelector('.drop-zone-file-remove');
|
| 143 |
+
|
| 144 |
+
dropZone.addEventListener('click', (e) => {
|
| 145 |
+
if (e.target === removeBtn) return;
|
| 146 |
+
fileInput.click();
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
['dragenter', 'dragover'].forEach(event => {
|
| 150 |
+
dropZone.addEventListener(event, (e) => {
|
| 151 |
+
e.preventDefault();
|
| 152 |
+
dropZone.classList.add('drag-over');
|
| 153 |
+
});
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
['dragleave', 'drop'].forEach(event => {
|
| 157 |
+
dropZone.addEventListener(event, (e) => {
|
| 158 |
+
e.preventDefault();
|
| 159 |
+
dropZone.classList.remove('drag-over');
|
| 160 |
+
});
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
dropZone.addEventListener('drop', (e) => {
|
| 164 |
+
const files = e.dataTransfer.files;
|
| 165 |
+
if (files.length > 0) handleFile(files[0]);
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
fileInput.addEventListener('change', () => {
|
| 169 |
+
if (fileInput.files.length > 0) handleFile(fileInput.files[0]);
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
if (removeBtn) {
|
| 173 |
+
removeBtn.addEventListener('click', (e) => {
|
| 174 |
+
e.stopPropagation();
|
| 175 |
+
clearFile();
|
| 176 |
+
});
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function handleFile(file) {
|
| 180 |
+
if (file.size > maxSize) {
|
| 181 |
+
showToast('File Too Large', `Max size: ${formatFileSize(maxSize)}`, 'error');
|
| 182 |
+
return;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if (fileDisplay) {
|
| 186 |
+
fileDisplay.classList.add('active');
|
| 187 |
+
if (fileName) fileName.textContent = file.name;
|
| 188 |
+
if (fileSize) fileSize.textContent = formatFileSize(file.size);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
const dt = new DataTransfer();
|
| 192 |
+
dt.items.add(file);
|
| 193 |
+
fileInput.files = dt.files;
|
| 194 |
+
|
| 195 |
+
onFile(file);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
function clearFile() {
|
| 199 |
+
fileInput.value = '';
|
| 200 |
+
if (fileDisplay) fileDisplay.classList.remove('active');
|
| 201 |
+
onFile(null);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
return { handleFile, clearFile };
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function formatFileSize(bytes) {
|
| 208 |
+
if (bytes < 1024) return bytes + ' B';
|
| 209 |
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
| 210 |
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// =============== Image Modal ===============
|
| 214 |
+
|
| 215 |
+
let currentModal = null;
|
| 216 |
+
|
| 217 |
+
function showImageModal(imageUrl, filename, dimensions, size) {
|
| 218 |
+
closeImageModal();
|
| 219 |
+
|
| 220 |
+
const modal = document.createElement('div');
|
| 221 |
+
modal.className = 'modal-overlay';
|
| 222 |
+
modal.innerHTML = `
|
| 223 |
+
<div class="modal-content">
|
| 224 |
+
<button class="modal-close">
|
| 225 |
+
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 226 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
| 227 |
+
</svg>
|
| 228 |
+
</button>
|
| 229 |
+
<img class="modal-image" src="${imageUrl}" alt="${filename}" />
|
| 230 |
+
<div class="modal-footer">
|
| 231 |
+
<div class="modal-info">
|
| 232 |
+
<strong>${filename}</strong><br>
|
| 233 |
+
${dimensions || 'Unknown'} • ${size || ''}
|
| 234 |
+
</div>
|
| 235 |
+
<a href="${imageUrl}" target="_blank" class="btn btn-secondary">Open Original</a>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
`;
|
| 239 |
+
|
| 240 |
+
document.body.appendChild(modal);
|
| 241 |
+
requestAnimationFrame(() => modal.classList.add('active'));
|
| 242 |
+
|
| 243 |
+
modal.querySelector('.modal-close').addEventListener('click', closeImageModal);
|
| 244 |
+
modal.addEventListener('click', (e) => {
|
| 245 |
+
if (e.target === modal) closeImageModal();
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
document.addEventListener('keydown', handleModalEsc);
|
| 249 |
+
currentModal = modal;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function closeImageModal() {
|
| 253 |
+
if (currentModal) {
|
| 254 |
+
currentModal.classList.remove('active');
|
| 255 |
+
setTimeout(() => currentModal?.remove(), 200);
|
| 256 |
+
currentModal = null;
|
| 257 |
+
document.removeEventListener('keydown', handleModalEsc);
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function handleModalEsc(e) {
|
| 262 |
+
if (e.key === 'Escape') closeImageModal();
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// =============== Keyboard Shortcuts ===============
|
| 266 |
+
|
| 267 |
+
const shortcuts = {};
|
| 268 |
+
|
| 269 |
+
function registerShortcut(key, callback, description = '') {
|
| 270 |
+
shortcuts[key.toLowerCase()] = { callback, description };
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
document.addEventListener('keydown', (e) => {
|
| 274 |
+
if (e.target.matches('input, textarea, select')) return;
|
| 275 |
+
|
| 276 |
+
let key = '';
|
| 277 |
+
if (e.ctrlKey || e.metaKey) key += 'ctrl+';
|
| 278 |
+
if (e.shiftKey) key += 'shift+';
|
| 279 |
+
if (e.altKey) key += 'alt+';
|
| 280 |
+
key += e.key.toLowerCase();
|
| 281 |
+
|
| 282 |
+
const shortcut = shortcuts[key];
|
| 283 |
+
if (shortcut) {
|
| 284 |
+
e.preventDefault();
|
| 285 |
+
shortcut.callback();
|
| 286 |
+
}
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
// =============== Success Animation ===============
|
| 291 |
+
|
| 292 |
+
function showSuccessAnimation(title = 'Success!', message = 'Operation completed') {
|
| 293 |
+
// Create overlay
|
| 294 |
+
const overlay = document.createElement('div');
|
| 295 |
+
overlay.className = 'success-overlay';
|
| 296 |
+
overlay.innerHTML = `
|
| 297 |
+
<div class="success-content">
|
| 298 |
+
<div class="success-icon">
|
| 299 |
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 300 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
|
| 301 |
+
</svg>
|
| 302 |
+
</div>
|
| 303 |
+
<div class="success-title">${title}</div>
|
| 304 |
+
<div class="success-message">${message}</div>
|
| 305 |
+
<button class="btn btn-primary">Continue</button>
|
| 306 |
+
</div>
|
| 307 |
+
`;
|
| 308 |
+
|
| 309 |
+
document.body.appendChild(overlay);
|
| 310 |
+
|
| 311 |
+
// Add confetti
|
| 312 |
+
createConfetti(overlay);
|
| 313 |
+
|
| 314 |
+
// Show overlay
|
| 315 |
+
requestAnimationFrame(() => overlay.classList.add('active'));
|
| 316 |
+
|
| 317 |
+
// Close handlers
|
| 318 |
+
const closeBtn = overlay.querySelector('.btn');
|
| 319 |
+
closeBtn.addEventListener('click', () => closeSuccessAnimation(overlay));
|
| 320 |
+
|
| 321 |
+
overlay.addEventListener('click', (e) => {
|
| 322 |
+
if (e.target === overlay) closeSuccessAnimation(overlay);
|
| 323 |
+
});
|
| 324 |
+
|
| 325 |
+
// Auto close after 3 seconds
|
| 326 |
+
setTimeout(() => closeSuccessAnimation(overlay), 3000);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
function closeSuccessAnimation(overlay) {
|
| 330 |
+
if (!overlay) return;
|
| 331 |
+
overlay.style.opacity = '0';
|
| 332 |
+
setTimeout(() => overlay.remove(), 300);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function createConfetti(container) {
|
| 336 |
+
const colors = ['#4caf50', '#66bb6a', '#81c784', '#a5d6a7', '#c8e6c9', '#2e7d32'];
|
| 337 |
+
|
| 338 |
+
for (let i = 0; i < 50; i++) {
|
| 339 |
+
const confetti = document.createElement('div');
|
| 340 |
+
confetti.className = 'confetti';
|
| 341 |
+
confetti.style.left = Math.random() * 100 + '%';
|
| 342 |
+
confetti.style.top = '-10px';
|
| 343 |
+
confetti.style.background = colors[Math.floor(Math.random() * colors.length)];
|
| 344 |
+
confetti.style.animationDelay = Math.random() * 0.5 + 's';
|
| 345 |
+
confetti.style.animationDuration = (2 + Math.random() * 2) + 's';
|
| 346 |
+
container.appendChild(confetti);
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// =============== Step Indicator ===============
|
| 351 |
+
|
| 352 |
+
class StepIndicator {
|
| 353 |
+
constructor(containerId, steps) {
|
| 354 |
+
this.container = document.getElementById(containerId);
|
| 355 |
+
this.steps = steps;
|
| 356 |
+
this.currentStep = 0;
|
| 357 |
+
this.render();
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
render() {
|
| 361 |
+
if (!this.container) return;
|
| 362 |
+
|
| 363 |
+
let html = '';
|
| 364 |
+
this.steps.forEach((step, index) => {
|
| 365 |
+
const isActive = index === this.currentStep;
|
| 366 |
+
const isCompleted = index < this.currentStep;
|
| 367 |
+
|
| 368 |
+
html += `
|
| 369 |
+
<div class="step ${isActive ? 'active' : ''} ${isCompleted ? 'completed' : ''}">
|
| 370 |
+
<div class="step-number ${isActive ? 'pulse' : ''}">
|
| 371 |
+
${isCompleted ? '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>' : index + 1}
|
| 372 |
+
</div>
|
| 373 |
+
<span class="step-label">${step}</span>
|
| 374 |
+
</div>
|
| 375 |
+
`;
|
| 376 |
+
|
| 377 |
+
if (index < this.steps.length - 1) {
|
| 378 |
+
html += `<div class="step-connector ${isCompleted ? 'completed' : ''}"></div>`;
|
| 379 |
+
}
|
| 380 |
+
});
|
| 381 |
+
|
| 382 |
+
this.container.innerHTML = html;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
setStep(stepIndex) {
|
| 386 |
+
this.currentStep = Math.max(0, Math.min(stepIndex, this.steps.length - 1));
|
| 387 |
+
this.render();
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
next() {
|
| 391 |
+
if (this.currentStep < this.steps.length - 1) {
|
| 392 |
+
this.currentStep++;
|
| 393 |
+
this.render();
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
prev() {
|
| 398 |
+
if (this.currentStep > 0) {
|
| 399 |
+
this.currentStep--;
|
| 400 |
+
this.render();
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
complete() {
|
| 405 |
+
this.currentStep = this.steps.length;
|
| 406 |
+
this.render();
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
reset() {
|
| 410 |
+
this.currentStep = 0;
|
| 411 |
+
this.render();
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
// =============== Error States ===============
|
| 416 |
+
|
| 417 |
+
function showErrorState(container, title, message, actions = []) {
|
| 418 |
+
const errorHtml = `
|
| 419 |
+
<div class="error-state">
|
| 420 |
+
<div class="error-state-icon">
|
| 421 |
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 422 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
| 423 |
+
</svg>
|
| 424 |
+
</div>
|
| 425 |
+
<div class="error-state-title">${title}</div>
|
| 426 |
+
<div class="error-state-message">${message}</div>
|
| 427 |
+
<div class="error-state-actions">
|
| 428 |
+
${actions.map(a => `<button class="btn ${a.primary ? 'btn-primary' : 'btn-secondary'}" data-action="${a.id}">${a.label}</button>`).join('')}
|
| 429 |
+
</div>
|
| 430 |
+
</div>
|
| 431 |
+
`;
|
| 432 |
+
|
| 433 |
+
container.innerHTML = errorHtml;
|
| 434 |
+
|
| 435 |
+
// Bind action handlers
|
| 436 |
+
actions.forEach(action => {
|
| 437 |
+
const btn = container.querySelector(`[data-action="${action.id}"]`);
|
| 438 |
+
if (btn && action.handler) {
|
| 439 |
+
btn.addEventListener('click', action.handler);
|
| 440 |
+
}
|
| 441 |
+
});
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
function showInlineError(container, title, message, actionLabel, actionHandler) {
|
| 445 |
+
const errorEl = document.createElement('div');
|
| 446 |
+
errorEl.className = 'inline-error';
|
| 447 |
+
errorEl.innerHTML = `
|
| 448 |
+
<div class="inline-error-icon">
|
| 449 |
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 450 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
| 451 |
+
</svg>
|
| 452 |
+
</div>
|
| 453 |
+
<div class="inline-error-content">
|
| 454 |
+
<div class="inline-error-title">${title}</div>
|
| 455 |
+
<div class="inline-error-message">${message}</div>
|
| 456 |
+
</div>
|
| 457 |
+
${actionLabel ? `<button class="inline-error-action">${actionLabel}</button>` : ''}
|
| 458 |
+
`;
|
| 459 |
+
|
| 460 |
+
if (actionLabel && actionHandler) {
|
| 461 |
+
errorEl.querySelector('.inline-error-action').addEventListener('click', () => {
|
| 462 |
+
errorEl.remove();
|
| 463 |
+
actionHandler();
|
| 464 |
+
});
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
container.appendChild(errorEl);
|
| 468 |
+
return errorEl;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
// =============== Empty States ===============
|
| 472 |
+
|
| 473 |
+
function showEmptyState(container, icon, title, message, actions = []) {
|
| 474 |
+
const emptyHtml = `
|
| 475 |
+
<div class="empty-state">
|
| 476 |
+
<div class="empty-state-illustration">
|
| 477 |
+
${icon}
|
| 478 |
+
</div>
|
| 479 |
+
<div class="empty-state-title">${title}</div>
|
| 480 |
+
<div class="empty-state-message">${message}</div>
|
| 481 |
+
${actions.length > 0 ? `
|
| 482 |
+
<div class="empty-state-actions">
|
| 483 |
+
${actions.map(a => `<button class="btn ${a.primary ? 'btn-primary' : 'btn-secondary'}" data-action="${a.id}">${a.label}</button>`).join('')}
|
| 484 |
+
</div>
|
| 485 |
+
` : ''}
|
| 486 |
+
</div>
|
| 487 |
+
`;
|
| 488 |
+
|
| 489 |
+
container.innerHTML = emptyHtml;
|
| 490 |
+
|
| 491 |
+
// Bind action handlers
|
| 492 |
+
actions.forEach(action => {
|
| 493 |
+
const btn = container.querySelector(`[data-action="${action.id}"]`);
|
| 494 |
+
if (btn && action.handler) {
|
| 495 |
+
btn.addEventListener('click', action.handler);
|
| 496 |
+
}
|
| 497 |
+
});
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
function showPreviewEmpty(container) {
|
| 501 |
+
container.innerHTML = `
|
| 502 |
+
<div class="preview-empty">
|
| 503 |
+
<div class="preview-empty-icon">
|
| 504 |
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 505 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
| 506 |
+
</svg>
|
| 507 |
+
</div>
|
| 508 |
+
<div class="preview-empty-title">No PDF loaded</div>
|
| 509 |
+
<div class="preview-empty-message">Upload a file or enter a URL to preview</div>
|
| 510 |
+
</div>
|
| 511 |
+
`;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
function showImageGridEmpty(container) {
|
| 515 |
+
container.innerHTML = `
|
| 516 |
+
<div class="image-grid-empty">
|
| 517 |
+
<div class="image-grid-empty-icon">
|
| 518 |
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 519 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
| 520 |
+
</svg>
|
| 521 |
+
</div>
|
| 522 |
+
<div class="image-grid-empty-title">No images yet</div>
|
| 523 |
+
<div class="image-grid-empty-message">Enter a webpage URL above and click "Fetch Images" to get started</div>
|
| 524 |
+
</div>
|
| 525 |
+
`;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
// =============== Feature Hints ===============
|
| 529 |
+
|
| 530 |
+
function showFeatureHint(container, icon, title, message, dismissKey) {
|
| 531 |
+
// Check if already dismissed
|
| 532 |
+
if (localStorage.getItem(`hint_${dismissKey}`)) return;
|
| 533 |
+
|
| 534 |
+
const hint = document.createElement('div');
|
| 535 |
+
hint.className = 'feature-hint';
|
| 536 |
+
hint.innerHTML = `
|
| 537 |
+
<div class="feature-hint-icon">${icon}</div>
|
| 538 |
+
<div class="feature-hint-content">
|
| 539 |
+
<div class="feature-hint-title">${title}</div>
|
| 540 |
+
<div class="feature-hint-message">${message}</div>
|
| 541 |
+
</div>
|
| 542 |
+
<button class="feature-hint-dismiss">
|
| 543 |
+
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 544 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
| 545 |
+
</svg>
|
| 546 |
+
</button>
|
| 547 |
+
`;
|
| 548 |
+
|
| 549 |
+
hint.querySelector('.feature-hint-dismiss').addEventListener('click', () => {
|
| 550 |
+
localStorage.setItem(`hint_${dismissKey}`, 'true');
|
| 551 |
+
hint.style.opacity = '0';
|
| 552 |
+
hint.style.transform = 'translateY(-10px)';
|
| 553 |
+
setTimeout(() => hint.remove(), 300);
|
| 554 |
+
});
|
| 555 |
+
|
| 556 |
+
container.prepend(hint);
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
// =============== Tooltip Initialization ===============
|
| 560 |
+
|
| 561 |
+
function initTooltips() {
|
| 562 |
+
// Add tooltip class to elements with data-tooltip
|
| 563 |
+
document.querySelectorAll('[data-tooltip]').forEach(el => {
|
| 564 |
+
if (!el.classList.contains('tooltip')) {
|
| 565 |
+
el.classList.add('tooltip');
|
| 566 |
+
}
|
| 567 |
+
});
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
// Initialize tooltips on load
|
| 571 |
+
document.addEventListener('DOMContentLoaded', initTooltips);
|
watermark_remover.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Watermark Remover Module
|
| 3 |
+
Removes text watermarks from PDF pages using image processing
|
| 4 |
+
Optimized for file size and quality
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import io
|
| 8 |
+
import fitz # PyMuPDF
|
| 9 |
+
import numpy as np
|
| 10 |
+
from PIL import Image
|
| 11 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 12 |
+
import time
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
import cv2
|
| 16 |
+
CV2_AVAILABLE = True
|
| 17 |
+
except ImportError:
|
| 18 |
+
CV2_AVAILABLE = False
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def remove_watermark_from_pdf(
|
| 22 |
+
pdf_bytes: bytes,
|
| 23 |
+
watermark_text: str = "Educated Nepal",
|
| 24 |
+
method: str = "inpaint",
|
| 25 |
+
intensity: int = 50,
|
| 26 |
+
dpi: int = 150,
|
| 27 |
+
jpeg_quality: int = 75,
|
| 28 |
+
max_workers: int = 4
|
| 29 |
+
) -> bytes:
|
| 30 |
+
"""
|
| 31 |
+
Remove watermark from PDF pages with optimized output size.
|
| 32 |
+
Uses JPEG compression to keep file size small.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
pdf_bytes: Input PDF as bytes
|
| 36 |
+
watermark_text: Text to remove (not used in current methods)
|
| 37 |
+
method: 'inpaint', 'threshold', or 'color'
|
| 38 |
+
intensity: 0-100, higher = more aggressive removal
|
| 39 |
+
dpi: Resolution for processing (lower = smaller file, 100-150 recommended)
|
| 40 |
+
jpeg_quality: JPEG quality 10-100 (lower = smaller file, 60-80 recommended)
|
| 41 |
+
max_workers: Parallel processing threads
|
| 42 |
+
"""
|
| 43 |
+
if not CV2_AVAILABLE:
|
| 44 |
+
raise ImportError("OpenCV not installed. Run: pip install opencv-python-headless")
|
| 45 |
+
|
| 46 |
+
start_time = time.time()
|
| 47 |
+
original_size = len(pdf_bytes)
|
| 48 |
+
|
| 49 |
+
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 50 |
+
output_doc = fitz.open()
|
| 51 |
+
|
| 52 |
+
# Get original page sizes to maintain dimensions
|
| 53 |
+
page_sizes = [(doc[i].rect.width, doc[i].rect.height) for i in range(len(doc))]
|
| 54 |
+
|
| 55 |
+
def process_page(page_num):
|
| 56 |
+
page = doc[page_num]
|
| 57 |
+
orig_width, orig_height = page_sizes[page_num]
|
| 58 |
+
|
| 59 |
+
# Render at specified DPI
|
| 60 |
+
mat = fitz.Matrix(dpi / 72, dpi / 72)
|
| 61 |
+
pix = page.get_pixmap(matrix=mat)
|
| 62 |
+
|
| 63 |
+
# Convert to numpy array directly (faster than PNG encoding)
|
| 64 |
+
img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
|
| 65 |
+
|
| 66 |
+
# Convert RGB to BGR for OpenCV if needed
|
| 67 |
+
if pix.n == 4: # RGBA
|
| 68 |
+
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
|
| 69 |
+
elif pix.n == 3: # RGB
|
| 70 |
+
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
|
| 71 |
+
|
| 72 |
+
# Process to remove watermark
|
| 73 |
+
if method == "inpaint":
|
| 74 |
+
result = remove_by_inpainting(img, intensity)
|
| 75 |
+
elif method == "threshold":
|
| 76 |
+
result = remove_by_threshold(img, intensity)
|
| 77 |
+
elif method == "color":
|
| 78 |
+
result = remove_by_color(img, intensity)
|
| 79 |
+
else:
|
| 80 |
+
result = remove_by_inpainting(img, intensity)
|
| 81 |
+
|
| 82 |
+
# Convert back to RGB for PIL
|
| 83 |
+
result_rgb = cv2.cvtColor(result, cv2.COLOR_BGR2RGB)
|
| 84 |
+
|
| 85 |
+
# Encode as JPEG with specified quality
|
| 86 |
+
pil_img = Image.fromarray(result_rgb)
|
| 87 |
+
jpeg_buffer = io.BytesIO()
|
| 88 |
+
pil_img.save(jpeg_buffer, format='JPEG', quality=jpeg_quality, optimize=True)
|
| 89 |
+
jpeg_bytes = jpeg_buffer.getvalue()
|
| 90 |
+
|
| 91 |
+
return page_num, jpeg_bytes, (orig_width, orig_height)
|
| 92 |
+
|
| 93 |
+
# Process pages in parallel
|
| 94 |
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 95 |
+
results = list(executor.map(lambda i: process_page(i), range(len(doc))))
|
| 96 |
+
|
| 97 |
+
# Sort by page number
|
| 98 |
+
results.sort(key=lambda x: x[0])
|
| 99 |
+
|
| 100 |
+
# Create output PDF with original page sizes
|
| 101 |
+
for page_num, jpeg_bytes, (orig_width, orig_height) in results:
|
| 102 |
+
# Create page with original dimensions
|
| 103 |
+
pdf_page = output_doc.new_page(width=orig_width, height=orig_height)
|
| 104 |
+
|
| 105 |
+
# Insert image to fill the page
|
| 106 |
+
rect = fitz.Rect(0, 0, orig_width, orig_height)
|
| 107 |
+
pdf_page.insert_image(rect, stream=jpeg_bytes)
|
| 108 |
+
|
| 109 |
+
# Save with maximum compression
|
| 110 |
+
output_bytes = output_doc.tobytes(deflate=True, garbage=4, clean=True)
|
| 111 |
+
|
| 112 |
+
page_count = len(doc)
|
| 113 |
+
doc.close()
|
| 114 |
+
output_doc.close()
|
| 115 |
+
|
| 116 |
+
elapsed = time.time() - start_time
|
| 117 |
+
output_size = len(output_bytes)
|
| 118 |
+
ratio = output_size / original_size if original_size > 0 else 1
|
| 119 |
+
print(f"Watermark removal: {page_count} pages in {elapsed:.1f}s, "
|
| 120 |
+
f"{original_size/1024:.0f}KB -> {output_size/1024:.0f}KB ({ratio:.1%})")
|
| 121 |
+
|
| 122 |
+
return output_bytes
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def remove_by_inpainting(img: np.ndarray, intensity: int) -> np.ndarray:
|
| 126 |
+
"""Remove watermark using inpainting - best for handwritten notes."""
|
| 127 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 128 |
+
|
| 129 |
+
# Adaptive threshold based on intensity
|
| 130 |
+
# Higher intensity = lower threshold = more aggressive
|
| 131 |
+
thresh_value = 220 - int(intensity * 0.4) # Range: 220 to 180
|
| 132 |
+
|
| 133 |
+
# Find light gray areas (watermark)
|
| 134 |
+
_, mask = cv2.threshold(gray, thresh_value, 255, cv2.THRESH_BINARY)
|
| 135 |
+
|
| 136 |
+
# Exclude pure white (paper) and very light areas
|
| 137 |
+
white_mask = gray > 248
|
| 138 |
+
mask[white_mask] = 0
|
| 139 |
+
|
| 140 |
+
# Also exclude dark areas (actual content)
|
| 141 |
+
dark_mask = gray < 200
|
| 142 |
+
mask[dark_mask] = 0
|
| 143 |
+
|
| 144 |
+
# Small dilation to cover watermark edges
|
| 145 |
+
kernel = np.ones((2, 2), np.uint8)
|
| 146 |
+
mask = cv2.dilate(mask, kernel, iterations=1)
|
| 147 |
+
|
| 148 |
+
# Inpaint - use TELEA for better results
|
| 149 |
+
result = cv2.inpaint(img, mask, inpaintRadius=3, flags=cv2.INPAINT_TELEA)
|
| 150 |
+
|
| 151 |
+
return result
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def remove_by_threshold(img: np.ndarray, intensity: int) -> np.ndarray:
|
| 155 |
+
"""Remove watermark by converting light gray to white - fast method."""
|
| 156 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 157 |
+
|
| 158 |
+
# Threshold: higher intensity = more aggressive
|
| 159 |
+
thresh_value = 230 - int(intensity * 0.5) # Range: 230 to 180
|
| 160 |
+
|
| 161 |
+
# Create mask for light gray areas
|
| 162 |
+
mask = (gray > thresh_value) & (gray < 250)
|
| 163 |
+
|
| 164 |
+
result = img.copy()
|
| 165 |
+
result[mask] = [255, 255, 255]
|
| 166 |
+
|
| 167 |
+
return result
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def remove_by_color(img: np.ndarray, intensity: int) -> np.ndarray:
|
| 171 |
+
"""Remove watermark by targeting gray color range."""
|
| 172 |
+
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
| 173 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 174 |
+
|
| 175 |
+
# Target low saturation (gray) and high value (light)
|
| 176 |
+
lower_gray = np.array([0, 0, 200 - intensity // 2])
|
| 177 |
+
upper_gray = np.array([180, 40 + intensity // 3, 250])
|
| 178 |
+
|
| 179 |
+
mask = cv2.inRange(hsv, lower_gray, upper_gray)
|
| 180 |
+
|
| 181 |
+
# Don't remove dark content
|
| 182 |
+
mask[gray < 150] = 0
|
| 183 |
+
|
| 184 |
+
# Don't remove pure white
|
| 185 |
+
mask[gray > 250] = 0
|
| 186 |
+
|
| 187 |
+
kernel = np.ones((2, 2), np.uint8)
|
| 188 |
+
mask = cv2.dilate(mask, kernel, iterations=1)
|
| 189 |
+
|
| 190 |
+
result = img.copy()
|
| 191 |
+
result[mask > 0] = [255, 255, 255]
|
| 192 |
+
|
| 193 |
+
return result
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def preview_single_page(
|
| 197 |
+
pdf_bytes: bytes,
|
| 198 |
+
page_num: int = 0,
|
| 199 |
+
method: str = "inpaint",
|
| 200 |
+
intensity: int = 50,
|
| 201 |
+
dpi: int = 100
|
| 202 |
+
) -> tuple[bytes, bytes]:
|
| 203 |
+
"""
|
| 204 |
+
Preview watermark removal on a single page.
|
| 205 |
+
Returns (original_jpeg, processed_jpeg) for comparison.
|
| 206 |
+
"""
|
| 207 |
+
if not CV2_AVAILABLE:
|
| 208 |
+
raise ImportError("OpenCV not installed")
|
| 209 |
+
|
| 210 |
+
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 211 |
+
|
| 212 |
+
if page_num >= len(doc):
|
| 213 |
+
page_num = 0
|
| 214 |
+
|
| 215 |
+
page = doc[page_num]
|
| 216 |
+
mat = fitz.Matrix(dpi / 72, dpi / 72)
|
| 217 |
+
pix = page.get_pixmap(matrix=mat)
|
| 218 |
+
|
| 219 |
+
# Convert to numpy
|
| 220 |
+
img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
|
| 221 |
+
|
| 222 |
+
if pix.n == 4:
|
| 223 |
+
img_bgr = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
|
| 224 |
+
img_rgb = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)
|
| 225 |
+
else:
|
| 226 |
+
img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
|
| 227 |
+
img_rgb = img
|
| 228 |
+
|
| 229 |
+
# Original as JPEG
|
| 230 |
+
pil_original = Image.fromarray(img_rgb)
|
| 231 |
+
orig_buffer = io.BytesIO()
|
| 232 |
+
pil_original.save(orig_buffer, format='JPEG', quality=85)
|
| 233 |
+
original_bytes = orig_buffer.getvalue()
|
| 234 |
+
|
| 235 |
+
# Process
|
| 236 |
+
if method == "inpaint":
|
| 237 |
+
result = remove_by_inpainting(img_bgr, intensity)
|
| 238 |
+
elif method == "threshold":
|
| 239 |
+
result = remove_by_threshold(img_bgr, intensity)
|
| 240 |
+
else:
|
| 241 |
+
result = remove_by_color(img_bgr, intensity)
|
| 242 |
+
|
| 243 |
+
# Processed as JPEG
|
| 244 |
+
result_rgb = cv2.cvtColor(result, cv2.COLOR_BGR2RGB)
|
| 245 |
+
pil_processed = Image.fromarray(result_rgb)
|
| 246 |
+
proc_buffer = io.BytesIO()
|
| 247 |
+
pil_processed.save(proc_buffer, format='JPEG', quality=85)
|
| 248 |
+
processed_bytes = proc_buffer.getvalue()
|
| 249 |
+
|
| 250 |
+
doc.close()
|
| 251 |
+
return original_bytes, processed_bytes
|