ministerchief commited on
Commit
e84c544
Β·
verified Β·
1 Parent(s): ec787ea

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +65 -0
  2. requirements.txt +4 -0
  3. static/style.css +422 -0
  4. templates/index.html +358 -0
app.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, request, send_file, jsonify, render_template
2
+ from flask_cors import CORS
3
+ from PIL import Image
4
+ import img2pdf
5
+ import io
6
+
7
+ app = Flask(__name__)
8
+ CORS(app)
9
+
10
+ FREE_IMAGE_LIMIT = 15
11
+
12
+ # βœ… HOME PAGE (FIX FOR "NOT FOUND")
13
+ @app.route("/")
14
+ def home():
15
+ return render_template("index.html")
16
+
17
+
18
+ @app.route("/convert", methods=["POST"])
19
+ def convert():
20
+ files = request.files.getlist("images")
21
+
22
+ if not files or len(files) == 0:
23
+ return jsonify({"error": "No images provided."}), 400
24
+
25
+ if len(files) > FREE_IMAGE_LIMIT:
26
+ return jsonify({
27
+ "error": "limit_exceeded",
28
+ "message": f"Free plan allows up to {FREE_IMAGE_LIMIT} images.",
29
+ "count": len(files),
30
+ "limit": FREE_IMAGE_LIMIT
31
+ }), 403
32
+
33
+ image_bytes_list = []
34
+
35
+ for f in files:
36
+ try:
37
+ img = Image.open(f.stream)
38
+
39
+ if img.mode in ("RGBA", "P", "LA"):
40
+ img = img.convert("RGB")
41
+
42
+ buf = io.BytesIO()
43
+ img.save(buf, format="JPEG", quality=95)
44
+ buf.seek(0)
45
+
46
+ image_bytes_list.append(buf.read())
47
+
48
+ except Exception as e:
49
+ return jsonify({"error": f"Error in {f.filename}: {str(e)}"}), 400
50
+
51
+ try:
52
+ pdf_bytes = img2pdf.convert(image_bytes_list)
53
+ except Exception as e:
54
+ return jsonify({"error": f"PDF conversion failed: {str(e)}"}), 500
55
+
56
+ return send_file(
57
+ io.BytesIO(pdf_bytes),
58
+ mimetype="application/pdf",
59
+ as_attachment=True,
60
+ download_name="converted.pdf"
61
+ )
62
+
63
+
64
+ if __name__ == "__main__":
65
+ app.run(debug=True, port=5000)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ flask
2
+ flask-cors
3
+ Pillow
4
+ img2pdf
static/style.css ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Reset & Base ────────────────────────────────────────── */
2
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
3
+
4
+ :root {
5
+ --bg: #f5f4f0;
6
+ --surface: #ffffff;
7
+ --surface2: #f9f8f5;
8
+ --border: rgba(0,0,0,0.10);
9
+ --border-strong: rgba(0,0,0,0.18);
10
+ --text-primary: #1a1a1a;
11
+ --text-secondary: #6b6b68;
12
+ --text-muted: #9b9b98;
13
+ --accent: #5048c8;
14
+ --accent-light: #eeedfe;
15
+ --accent-text: #3c3489;
16
+ --danger: #e24b4a;
17
+ --danger-light: #fcebeb;
18
+ --danger-text: #a32d2d;
19
+ --success: #1d9e75;
20
+ --success-light: #e1f5ee;
21
+ --success-text: #085041;
22
+ --amber: #ba7517;
23
+ --amber-light: #faeeda;
24
+ --amber-text: #633806;
25
+ --radius-sm: 6px;
26
+ --radius-md: 8px;
27
+ --radius-lg: 14px;
28
+ --radius-xl: 20px;
29
+ --shadow: 0 2px 12px rgba(0,0,0,0.08), 0 0 0 0.5px rgba(0,0,0,0.08);
30
+ }
31
+
32
+ body {
33
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
34
+ background: var(--bg);
35
+ color: var(--text-primary);
36
+ min-height: 100vh;
37
+ display: flex;
38
+ flex-direction: column;
39
+ align-items: center;
40
+ padding: 2rem 1rem 4rem;
41
+ line-height: 1.6;
42
+ }
43
+
44
+ /* ── Header ─────────────────────────────────────────────── */
45
+ header {
46
+ text-align: center;
47
+ margin-bottom: 2.5rem;
48
+ }
49
+
50
+ .logo-wrap {
51
+ display: inline-flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ width: 60px; height: 60px;
55
+ background: var(--accent-light);
56
+ border-radius: var(--radius-lg);
57
+ border: 0.5px solid rgba(83,74,183,0.2);
58
+ margin-bottom: 1rem;
59
+ }
60
+
61
+ .logo-wrap svg { width: 30px; height: 30px; }
62
+
63
+ header h1 {
64
+ font-size: 26px;
65
+ font-weight: 600;
66
+ color: var(--text-primary);
67
+ letter-spacing: -0.3px;
68
+ }
69
+
70
+ header p {
71
+ font-size: 15px;
72
+ color: var(--text-secondary);
73
+ margin-top: 4px;
74
+ }
75
+
76
+ /* ── Main card ───────────────────────────────────────────── */
77
+ main {
78
+ width: 100%;
79
+ max-width: 720px;
80
+ }
81
+
82
+ .card {
83
+ background: var(--surface);
84
+ border-radius: var(--radius-xl);
85
+ border: 0.5px solid var(--border);
86
+ box-shadow: var(--shadow);
87
+ padding: 2rem;
88
+ }
89
+
90
+ /* ── Drop zone ───────────────────────────────────────────── */
91
+ #drop-zone {
92
+ border: 1.5px dashed var(--border-strong);
93
+ border-radius: var(--radius-lg);
94
+ padding: 3rem 2rem;
95
+ text-align: center;
96
+ cursor: pointer;
97
+ transition: border-color 0.15s, background 0.15s;
98
+ background: var(--surface2);
99
+ position: relative;
100
+ }
101
+
102
+ #drop-zone:hover, #drop-zone.drag-over {
103
+ border-color: var(--accent);
104
+ background: var(--accent-light);
105
+ }
106
+
107
+ #drop-zone input[type="file"] {
108
+ position: absolute; inset: 0;
109
+ opacity: 0; cursor: pointer; width: 100%; height: 100%;
110
+ }
111
+
112
+ .drop-icon {
113
+ width: 48px; height: 48px;
114
+ margin: 0 auto 1rem;
115
+ background: var(--accent-light);
116
+ border-radius: var(--radius-md);
117
+ display: flex; align-items: center; justify-content: center;
118
+ border: 0.5px solid rgba(83,74,183,0.2);
119
+ }
120
+
121
+ .drop-icon svg { width: 24px; height: 24px; }
122
+
123
+ #drop-zone h2 {
124
+ font-size: 16px;
125
+ font-weight: 500;
126
+ color: var(--text-primary);
127
+ margin-bottom: 4px;
128
+ }
129
+
130
+ #drop-zone p {
131
+ font-size: 13px;
132
+ color: var(--text-muted);
133
+ }
134
+
135
+ /* ── Limit badge ─────────────────────────────────────────── */
136
+ .limit-badge {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ gap: 6px;
140
+ background: var(--amber-light);
141
+ color: var(--amber-text);
142
+ font-size: 12px;
143
+ font-weight: 500;
144
+ padding: 4px 10px;
145
+ border-radius: 99px;
146
+ margin-top: 0.75rem;
147
+ border: 0.5px solid rgba(186,117,23,0.2);
148
+ }
149
+
150
+ /* ── Counter bar ─────────────────────────────────────────── */
151
+ #counter-bar {
152
+ display: none;
153
+ align-items: center;
154
+ justify-content: space-between;
155
+ margin-top: 1.25rem;
156
+ padding: 0.6rem 0.9rem;
157
+ background: var(--surface2);
158
+ border-radius: var(--radius-md);
159
+ border: 0.5px solid var(--border);
160
+ font-size: 13px;
161
+ color: var(--text-secondary);
162
+ }
163
+
164
+ #counter-bar.visible { display: flex; }
165
+
166
+ #counter-bar .count-num {
167
+ font-weight: 600;
168
+ color: var(--text-primary);
169
+ }
170
+
171
+ .count-dot {
172
+ width: 6px; height: 6px;
173
+ border-radius: 50%;
174
+ background: var(--success);
175
+ display: inline-block;
176
+ margin-right: 4px;
177
+ }
178
+
179
+ .count-dot.warn { background: var(--amber); }
180
+ .count-dot.danger { background: var(--danger); }
181
+
182
+ /* ── Image preview grid ──────────────────────────────────── */
183
+ #preview-grid {
184
+ display: grid;
185
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
186
+ gap: 10px;
187
+ margin-top: 1.25rem;
188
+ }
189
+
190
+ .preview-item {
191
+ position: relative;
192
+ aspect-ratio: 1;
193
+ border-radius: var(--radius-md);
194
+ overflow: hidden;
195
+ border: 0.5px solid var(--border);
196
+ background: var(--surface2);
197
+ }
198
+
199
+ .preview-item img {
200
+ width: 100%; height: 100%;
201
+ object-fit: cover;
202
+ display: block;
203
+ }
204
+
205
+ .preview-item .remove-btn {
206
+ position: absolute;
207
+ top: 5px; right: 5px;
208
+ width: 22px; height: 22px;
209
+ border-radius: 50%;
210
+ background: rgba(0,0,0,0.55);
211
+ border: none;
212
+ cursor: pointer;
213
+ display: flex; align-items: center; justify-content: center;
214
+ opacity: 0;
215
+ transition: opacity 0.15s;
216
+ }
217
+
218
+ .preview-item:hover .remove-btn { opacity: 1; }
219
+
220
+ .preview-item .remove-btn svg {
221
+ width: 12px; height: 12px;
222
+ stroke: #fff;
223
+ }
224
+
225
+ .preview-item .order-badge {
226
+ position: absolute;
227
+ bottom: 5px; left: 5px;
228
+ background: rgba(0,0,0,0.5);
229
+ color: #fff;
230
+ font-size: 11px;
231
+ padding: 1px 5px;
232
+ border-radius: 4px;
233
+ }
234
+
235
+ /* ── Actions ─────────────────────────────────────────────── */
236
+ .actions {
237
+ display: flex;
238
+ gap: 10px;
239
+ margin-top: 1.5rem;
240
+ }
241
+
242
+ button {
243
+ font-family: inherit;
244
+ cursor: pointer;
245
+ border-radius: var(--radius-md);
246
+ font-size: 14px;
247
+ font-weight: 500;
248
+ padding: 10px 18px;
249
+ transition: opacity 0.15s, transform 0.1s;
250
+ border: 0.5px solid var(--border-strong);
251
+ }
252
+
253
+ button:active { transform: scale(0.98); }
254
+
255
+ .btn-primary {
256
+ background: var(--accent);
257
+ color: #fff;
258
+ border-color: transparent;
259
+ flex: 1;
260
+ display: flex; align-items: center; justify-content: center; gap: 8px;
261
+ }
262
+
263
+ .btn-primary:hover { opacity: 0.88; }
264
+ .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
265
+
266
+ .btn-ghost {
267
+ background: transparent;
268
+ color: var(--text-secondary);
269
+ }
270
+
271
+ .btn-ghost:hover {
272
+ background: var(--surface2);
273
+ color: var(--text-primary);
274
+ }
275
+
276
+ /* ── Toast ───────────────────────────────────────────────── */
277
+ #toast {
278
+ position: fixed;
279
+ bottom: 2rem; left: 50%;
280
+ transform: translateX(-50%) translateY(12px);
281
+ background: var(--text-primary);
282
+ color: #fff;
283
+ font-size: 13px;
284
+ padding: 10px 18px;
285
+ border-radius: 99px;
286
+ opacity: 0;
287
+ pointer-events: none;
288
+ transition: opacity 0.2s, transform 0.2s;
289
+ white-space: nowrap;
290
+ z-index: 200;
291
+ }
292
+
293
+ #toast.show {
294
+ opacity: 1;
295
+ transform: translateX(-50%) translateY(0);
296
+ }
297
+
298
+ /* ── Overlay + Premium dialog ────────────────────────────── */
299
+ #overlay {
300
+ display: none;
301
+ position: fixed; inset: 0;
302
+ background: rgba(0,0,0,0.45);
303
+ z-index: 100;
304
+ align-items: center; justify-content: center;
305
+ padding: 1rem;
306
+ }
307
+
308
+ #overlay.show { display: flex; }
309
+
310
+ #premium-dialog {
311
+ background: var(--surface);
312
+ border-radius: var(--radius-xl);
313
+ border: 0.5px solid var(--border);
314
+ box-shadow: 0 20px 60px rgba(0,0,0,0.18);
315
+ padding: 2rem;
316
+ max-width: 400px;
317
+ width: 100%;
318
+ text-align: center;
319
+ animation: pop-in 0.22s ease;
320
+ }
321
+
322
+ @keyframes pop-in {
323
+ from { transform: scale(0.9) translateY(12px); opacity: 0; }
324
+ to { transform: scale(1) translateY(0); opacity: 1; }
325
+ }
326
+
327
+ .dialog-icon {
328
+ width: 60px; height: 60px;
329
+ border-radius: var(--radius-lg);
330
+ background: var(--amber-light);
331
+ display: flex; align-items: center; justify-content: center;
332
+ margin: 0 auto 1.25rem;
333
+ border: 0.5px solid rgba(186,117,23,0.2);
334
+ }
335
+
336
+ .dialog-icon svg { width: 28px; height: 28px; }
337
+
338
+ #premium-dialog h2 {
339
+ font-size: 19px;
340
+ font-weight: 600;
341
+ margin-bottom: 0.5rem;
342
+ }
343
+
344
+ #premium-dialog p {
345
+ font-size: 14px;
346
+ color: var(--text-secondary);
347
+ margin-bottom: 1.5rem;
348
+ line-height: 1.6;
349
+ }
350
+
351
+ .perks {
352
+ list-style: none;
353
+ text-align: left;
354
+ margin-bottom: 1.75rem;
355
+ }
356
+
357
+ .perks li {
358
+ font-size: 13px;
359
+ color: var(--text-secondary);
360
+ display: flex;
361
+ align-items: center;
362
+ gap: 8px;
363
+ padding: 5px 0;
364
+ }
365
+
366
+ .perk-check {
367
+ width: 18px; height: 18px;
368
+ border-radius: 50%;
369
+ background: var(--success-light);
370
+ display: flex; align-items: center; justify-content: center;
371
+ flex-shrink: 0;
372
+ }
373
+
374
+ .perk-check svg { width: 10px; height: 10px; stroke: var(--success); }
375
+
376
+ .dialog-actions {
377
+ display: flex; flex-direction: column; gap: 8px;
378
+ }
379
+
380
+ .btn-upgrade {
381
+ background: linear-gradient(135deg, #5048c8 0%, #7f77dd 100%);
382
+ color: #fff;
383
+ border: none;
384
+ padding: 12px;
385
+ font-size: 15px;
386
+ border-radius: var(--radius-md);
387
+ width: 100%;
388
+ }
389
+
390
+ .btn-upgrade:hover { opacity: 0.88; }
391
+
392
+ .btn-close-dialog {
393
+ background: transparent;
394
+ border: 0.5px solid var(--border-strong);
395
+ color: var(--text-secondary);
396
+ padding: 10px;
397
+ width: 100%;
398
+ border-radius: var(--radius-md);
399
+ }
400
+
401
+ /* ── Progress bar (inside btn) ───────────────────────────── */
402
+ #loading-bar {
403
+ display: none;
404
+ height: 3px;
405
+ background: rgba(255,255,255,0.3);
406
+ border-radius: 2px;
407
+ overflow: hidden;
408
+ width: 120px;
409
+ }
410
+
411
+ #loading-bar-fill {
412
+ height: 100%;
413
+ background: #fff;
414
+ border-radius: 2px;
415
+ animation: slide 1s infinite;
416
+ }
417
+
418
+ @keyframes slide {
419
+ 0% { width: 0%; margin-left: 0; }
420
+ 50% { width: 60%; margin-left: 20%; }
421
+ 100% { width: 0%; margin-left: 100%; }
422
+ }
templates/index.html ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Image to PDF Converter</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8
+
9
+ <!-- Three.js for the 3D rotating PDF icon in the header -->
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
11
+ </head>
12
+ <body>
13
+
14
+ <!-- ── 3D Canvas header ──────────────────────────────────── -->
15
+ <div id="three-header" style="width:120px;height:120px;margin:0 auto 1rem;"></div>
16
+
17
+ <header>
18
+ <h1>Image to PDF</h1>
19
+ <p>Upload images, arrange them, and download a ready PDF β€” free up to 15 images.</p>
20
+ </header>
21
+
22
+ <main>
23
+ <div class="card">
24
+
25
+ <!-- Drop zone -->
26
+ <div id="drop-zone">
27
+ <input type="file" id="file-input" multiple accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" />
28
+ <div class="drop-icon">
29
+ <!-- upload icon -->
30
+ <svg viewBox="0 0 24 24" fill="none" stroke="#5048c8" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
31
+ <polyline points="16 16 12 12 8 16"></polyline>
32
+ <line x1="12" y1="12" x2="12" y2="21"></line>
33
+ <path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path>
34
+ </svg>
35
+ </div>
36
+ <h2>Drop images here or click to browse</h2>
37
+ <p>PNG, JPG, WEBP, GIF, BMP Β· Free tier: up to 15 images</p>
38
+ <div class="limit-badge">
39
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
40
+ <circle cx="12" cy="12" r="10"></circle>
41
+ <line x1="12" y1="8" x2="12" y2="12"></line>
42
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
43
+ </svg>
44
+ Free plan: 15 images max
45
+ </div>
46
+ </div>
47
+
48
+ <!-- Counter bar -->
49
+ <div id="counter-bar">
50
+ <span>
51
+ <span class="count-dot" id="count-dot"></span>
52
+ <span class="count-num" id="count-num">0</span> image(s) selected
53
+ </span>
54
+ <span id="count-limit-text" style="font-size:12px;color:var(--text-muted);">0 / 15 free slots used</span>
55
+ </div>
56
+
57
+ <!-- Preview grid -->
58
+ <div id="preview-grid"></div>
59
+
60
+ <!-- Actions -->
61
+ <div class="actions">
62
+ <button class="btn-ghost" id="clear-btn" onclick="clearAll()" style="display:none;">Clear all</button>
63
+ <button class="btn-primary" id="convert-btn" disabled onclick="convertToPDF()">
64
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
65
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
66
+ <polyline points="14 2 14 8 20 8"></polyline>
67
+ </svg>
68
+ <span id="btn-label">Convert to PDF</span>
69
+ <div id="loading-bar"><div id="loading-bar-fill"></div></div>
70
+ </button>
71
+ </div>
72
+
73
+ </div>
74
+ </main>
75
+
76
+ <!-- ── Toast ─────────────────────────────────────────────── -->
77
+ <div id="toast"></div>
78
+
79
+ <!-- ── Premium dialog overlay ────────────────────────────── -->
80
+ <div id="overlay" onclick="closeDialog(event)">
81
+ <div id="premium-dialog">
82
+ <div class="dialog-icon">
83
+ <!-- crown / star icon -->
84
+ <svg viewBox="0 0 24 24" fill="none" stroke="#ba7517" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
85
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
86
+ </svg>
87
+ </div>
88
+ <h2>Upgrade to Premium</h2>
89
+ <p>You've reached the <strong>15-image limit</strong> for the free plan. Unlock unlimited conversions and more with Premium.</p>
90
+ <ul class="perks">
91
+ <li>
92
+ <span class="perk-check">
93
+ <svg viewBox="0 0 24 24" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
94
+ </span>
95
+ Unlimited images per PDF
96
+ </li>
97
+ <li>
98
+ <span class="perk-check">
99
+ <svg viewBox="0 0 24 24" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
100
+ </span>
101
+ Custom page sizes & orientation
102
+ </li>
103
+ <li>
104
+ <span class="perk-check">
105
+ <svg viewBox="0 0 24 24" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
106
+ </span>
107
+ Password-protected PDFs
108
+ </li>
109
+ <li>
110
+ <span class="perk-check">
111
+ <svg viewBox="0 0 24 24" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
112
+ </span>
113
+ Priority conversion speed
114
+ </li>
115
+ </ul>
116
+ <div class="dialog-actions">
117
+ <button class="btn-upgrade" onclick="showToast('Redirecting to checkout…')">Upgrade Now β€” $4.99 / mo</button>
118
+ <button class="btn-close-dialog" onclick="closeDialog()">Maybe later</button>
119
+ </div>
120
+ </div>
121
+ </div>
122
+
123
+ <!-- ── Scripts ────────────────────────────────────────────── -->
124
+ <script>
125
+ /* ──────────────────────────── Three.js 3D PDF icon ──── */
126
+ (function () {
127
+ const container = document.getElementById('three-header');
128
+ const W = 120, H = 120;
129
+
130
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
131
+ renderer.setPixelRatio(window.devicePixelRatio);
132
+ renderer.setSize(W, H);
133
+ container.appendChild(renderer.domElement);
134
+
135
+ const scene = new THREE.Scene();
136
+ const camera = new THREE.PerspectiveCamera(38, W / H, 0.1, 100);
137
+ camera.position.set(0, 0, 5);
138
+
139
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6));
140
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
141
+ dirLight.position.set(4, 6, 4);
142
+ scene.add(dirLight);
143
+
144
+ const bodyGeo = new THREE.BoxGeometry(1.4, 1.8, 0.12);
145
+ const bodyMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
146
+ const body = new THREE.Mesh(bodyGeo, bodyMat);
147
+ scene.add(body);
148
+
149
+ const cornerShape = new THREE.Shape();
150
+ cornerShape.moveTo(0, 0);
151
+ cornerShape.lineTo(0.38, 0);
152
+ cornerShape.lineTo(0, -0.38);
153
+ cornerShape.closePath();
154
+
155
+ const cornerGeo = new THREE.ExtrudeGeometry(cornerShape, { depth: 0.13, bevelEnabled: false });
156
+ const cornerMat = new THREE.MeshStandardMaterial({ color: 0x5048c8 });
157
+ const corner = new THREE.Mesh(cornerGeo, cornerMat);
158
+ corner.position.set(0.7, 0.9, 0);
159
+ scene.add(corner);
160
+
161
+ const stripeGeo = new THREE.BoxGeometry(0.9, 0.06, 0.14);
162
+ const stripeMat = new THREE.MeshStandardMaterial({ color: 0xe24b4a });
163
+
164
+ [-0.2, 0.1, 0.4].forEach(y => {
165
+ const s = new THREE.Mesh(stripeGeo, stripeMat);
166
+ s.position.set(-0.12, y, 0);
167
+ scene.add(s);
168
+ });
169
+
170
+ const planeGeo = new THREE.PlaneGeometry(1.45, 1.85);
171
+ const planeMat = new THREE.MeshStandardMaterial({ color: 0x5048c8 });
172
+ const plane = new THREE.Mesh(planeGeo, planeMat);
173
+ plane.position.set(0.10, -0.10, -0.20);
174
+ scene.add(plane);
175
+
176
+ function animate() {
177
+ requestAnimationFrame(animate);
178
+ body.rotation.y += 0.012;
179
+ corner.rotation.y = body.rotation.y;
180
+ plane.rotation.y = body.rotation.y;
181
+ scene.children.forEach(c => { if (c.isMesh) c.rotation.y = body.rotation.y; });
182
+ renderer.render(scene, camera);
183
+ }
184
+ animate();
185
+ })();
186
+
187
+ /* ──────────────────────────── App state ───────────────── */
188
+ const FREE_LIMIT = 15;
189
+ let selectedFiles = [];
190
+
191
+ const dropZone = document.getElementById('drop-zone');
192
+ const fileInput = document.getElementById('file-input');
193
+ const previewGrid = document.getElementById('preview-grid');
194
+ const counterBar = document.getElementById('counter-bar');
195
+ const countNum = document.getElementById('count-num');
196
+ const countDot = document.getElementById('count-dot');
197
+ const countLimit = document.getElementById('count-limit-text');
198
+ const convertBtn = document.getElementById('convert-btn');
199
+ const btnLabel = document.getElementById('btn-label');
200
+ const loadingBar = document.getElementById('loading-bar');
201
+ const clearBtn = document.getElementById('clear-btn');
202
+ const overlay = document.getElementById('overlay');
203
+
204
+ /* drag & drop */
205
+ dropZone.addEventListener('dragover', e => {
206
+ e.preventDefault();
207
+ dropZone.classList.add('drag-over');
208
+ });
209
+
210
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
211
+
212
+ dropZone.addEventListener('drop', e => {
213
+ e.preventDefault();
214
+ dropZone.classList.remove('drag-over');
215
+ addFiles(e.dataTransfer.files);
216
+ });
217
+
218
+ fileInput.addEventListener('change', () => {
219
+ addFiles(fileInput.files);
220
+ fileInput.value = '';
221
+ });
222
+
223
+ function addFiles(fileList) {
224
+ const incoming = Array.from(fileList).filter(f => f.type.startsWith('image/'));
225
+ const newTotal = selectedFiles.length + incoming.length;
226
+
227
+ if (newTotal > FREE_LIMIT) {
228
+ const allowed = incoming.slice(0, FREE_LIMIT - selectedFiles.length);
229
+ allowed.forEach(pushFile);
230
+ updateUI();
231
+ showPremiumDialog();
232
+ return;
233
+ }
234
+
235
+ incoming.forEach(pushFile);
236
+ updateUI();
237
+ }
238
+
239
+ function pushFile(file) {
240
+ const url = URL.createObjectURL(file);
241
+ selectedFiles.push({ file, url });
242
+ }
243
+
244
+ function removeFile(index) {
245
+ URL.revokeObjectURL(selectedFiles[index].url);
246
+ selectedFiles.splice(index, 1);
247
+ updateUI();
248
+ }
249
+
250
+ function clearAll() {
251
+ selectedFiles.forEach(f => URL.revokeObjectURL(f.url));
252
+ selectedFiles = [];
253
+ updateUI();
254
+ }
255
+
256
+ function updateUI() {
257
+ const n = selectedFiles.length;
258
+
259
+ if (n > 0) {
260
+ counterBar.classList.add('visible');
261
+ countNum.textContent = n;
262
+ countLimit.textContent = `${n} / ${FREE_LIMIT} free slots used`;
263
+ } else {
264
+ counterBar.classList.remove('visible');
265
+ }
266
+
267
+ previewGrid.innerHTML = '';
268
+
269
+ selectedFiles.forEach(({ url }, i) => {
270
+ const item = document.createElement('div');
271
+ item.className = 'preview-item';
272
+ item.innerHTML = `
273
+ <img src="${url}" />
274
+ <span class="order-badge">${i + 1}</span>
275
+ <button class="remove-btn" onclick="removeFile(${i})">βœ•</button>
276
+ `;
277
+ previewGrid.appendChild(item);
278
+ });
279
+
280
+ convertBtn.disabled = n === 0;
281
+ clearBtn.style.display = n > 0 ? 'block' : 'none';
282
+ }
283
+
284
+ /* ──────────────────────────── FIXED CONVERT FUNCTION ───────────────── */
285
+ async function convertToPDF() {
286
+ if (selectedFiles.length === 0) return;
287
+
288
+ if (selectedFiles.length > FREE_LIMIT) {
289
+ showPremiumDialog();
290
+ return;
291
+ }
292
+
293
+ setLoading(true);
294
+
295
+ const formData = new FormData();
296
+ selectedFiles.forEach(({ file }) => {
297
+ formData.append('images', file);
298
+ });
299
+
300
+ try {
301
+ // βœ… FIXED LINE (IMPORTANT)
302
+ const res = await fetch('/convert', {
303
+ method: 'POST',
304
+ body: formData
305
+ });
306
+
307
+ if (!res.ok) {
308
+ const json = await res.json().catch(() => ({}));
309
+
310
+ if (json.error === 'limit_exceeded') {
311
+ showPremiumDialog();
312
+ } else {
313
+ showToast(json.error || 'Conversion failed');
314
+ }
315
+ return;
316
+ }
317
+
318
+ const blob = await res.blob();
319
+ const link = document.createElement('a');
320
+ link.href = URL.createObjectURL(blob);
321
+ link.download = 'converted.pdf';
322
+ link.click();
323
+
324
+ showToast('PDF downloaded successfully!');
325
+ } catch (err) {
326
+ showToast('Server not running. Run python app.py');
327
+ } finally {
328
+ setLoading(false);
329
+ }
330
+ }
331
+
332
+ function setLoading(on) {
333
+ convertBtn.disabled = on;
334
+ btnLabel.style.display = on ? 'none' : 'inline';
335
+ loadingBar.style.display = on ? 'block' : 'none';
336
+ }
337
+
338
+ /* ──────────────────────────── Premium dialog ───────────────── */
339
+ function showPremiumDialog() {
340
+ overlay.classList.add('show');
341
+ }
342
+
343
+ function closeDialog(e) {
344
+ if (!e || e.target === overlay) {
345
+ overlay.classList.remove('show');
346
+ }
347
+ }
348
+
349
+ /* ──────────────────────────── Toast ───────────────── */
350
+ function showToast(msg) {
351
+ const t = document.getElementById('toast');
352
+ t.textContent = msg;
353
+ t.classList.add('show');
354
+ setTimeout(() => t.classList.remove('show'), 3000);
355
+ }
356
+ </script>
357
+ </body>
358
+ </html>