Shivakafle038 commited on
Commit
32a841c
·
0 Parent(s):

PDF Tools Web App - compress, convert, watermark removal

Browse files
.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