t Claude Opus 4.6 commited on
Commit
693a1e9
·
1 Parent(s): b564de3

feat: major UX overhaul for PDF viewer, drive browser, and NeetPrep

Browse files

PDF Viewer:
- Replace PDF.js with Adobe PDF Embed API for state-of-the-art UX
- Native smooth pinch-to-zoom centered on finger position
- Better touch handling, zoom controls, and fullscreen support
- Clean minimal header with back button and download

Drive Browser:
- Copy button now copies direct download URL (not viewer URL)
- Add support for more file types: slides, sheets, docs, video
- Add "Open in Google" button for Slides/Sheets/Docs files
- Add dedicated image viewer with zoom/pan support

NeetPrep:
- Add tag-based quiz filtering with searchable tag chips
- Extract tags from classified questions
- Pass tag filters to quiz generation for filtered quizzes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

drive_routes.py CHANGED
@@ -217,10 +217,15 @@ def browse_drive(source_id, subpath=''):
217
  for entry in os.scandir(current_path):
218
  is_dir = entry.is_dir()
219
  file_type = 'file'
 
220
  if is_dir: file_type = 'folder'
221
- elif entry.name.lower().endswith('.pdf'): file_type = 'pdf'
222
- elif entry.name.lower().endswith(('.png', '.jpg', '.jpeg')): file_type = 'image'
223
-
 
 
 
 
224
  items.append({
225
  'name': entry.name,
226
  'type': file_type,
@@ -260,9 +265,13 @@ def view_drive_file(source_id, filepath):
260
  except Exception as e: return f"Error downloading file: {e}", 500
261
 
262
  if not os.path.exists(full_path): return "File not found.", 404
263
- if full_path.lower().endswith('.pdf'):
 
264
  file_url = url_for('drive.serve_drive_file', source_id=source_id, filepath=os.path.basename(full_path))
265
  return render_template('pdfjs_viewer.html', pdf_url=file_url, pdf_title=os.path.basename(full_path))
 
 
 
266
  return send_from_directory(os.path.dirname(full_path), os.path.basename(full_path))
267
 
268
  @drive_bp.route('/drive/raw/<int:source_id>/<path:filepath>')
@@ -308,8 +317,22 @@ def browse_drive_api(folder_id):
308
  items = []
309
  for f in files:
310
  is_folder = f['mimeType'] == 'application/vnd.google-apps.folder'
311
- f_type = 'folder' if is_folder else ('pdf' if f['mimeType'] == 'application/pdf' else 'file')
312
- if 'image' in f['mimeType']: f_type = 'image'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  items.append({
314
  'name': f['name'],
315
  'type': f_type,
@@ -344,12 +367,20 @@ def api_open_file(file_id):
344
  ''', (current_user.id, file_id, filename, 'drive_api'))
345
  conn.commit()
346
  conn.close()
347
-
348
  file_url = url_for('drive.serve_cache_file', filename=safe_name)
349
  return render_template('pdfjs_viewer.html', pdf_url=file_url, pdf_title=filename)
350
- if safe_name.lower().endswith(('.png', '.jpg', '.jpeg')):
351
- return send_from_directory(cache_dir, safe_name)
352
- return "File downloaded but type not supported for viewing.", 200
 
 
 
 
 
 
 
 
353
  except Exception as e: return f"Error opening file: {e}", 500
354
 
355
  @drive_bp.route('/drive/cache/<filename>')
 
217
  for entry in os.scandir(current_path):
218
  is_dir = entry.is_dir()
219
  file_type = 'file'
220
+ name_lower = entry.name.lower()
221
  if is_dir: file_type = 'folder'
222
+ elif name_lower.endswith('.pdf'): file_type = 'pdf'
223
+ elif name_lower.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')): file_type = 'image'
224
+ elif name_lower.endswith(('.ppt', '.pptx', '.odp')): file_type = 'slides'
225
+ elif name_lower.endswith(('.xls', '.xlsx', '.ods', '.csv')): file_type = 'sheet'
226
+ elif name_lower.endswith(('.doc', '.docx', '.odt', '.txt', '.rtf')): file_type = 'doc'
227
+ elif name_lower.endswith(('.mp4', '.mov', '.avi', '.mkv', '.webm')): file_type = 'video'
228
+
229
  items.append({
230
  'name': entry.name,
231
  'type': file_type,
 
265
  except Exception as e: return f"Error downloading file: {e}", 500
266
 
267
  if not os.path.exists(full_path): return "File not found.", 404
268
+ name_lower = full_path.lower()
269
+ if name_lower.endswith('.pdf'):
270
  file_url = url_for('drive.serve_drive_file', source_id=source_id, filepath=os.path.basename(full_path))
271
  return render_template('pdfjs_viewer.html', pdf_url=file_url, pdf_title=os.path.basename(full_path))
272
+ if name_lower.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
273
+ file_url = url_for('drive.serve_drive_file', source_id=source_id, filepath=os.path.basename(full_path))
274
+ return render_template('image_viewer.html', image_url=file_url, image_title=os.path.basename(full_path))
275
  return send_from_directory(os.path.dirname(full_path), os.path.basename(full_path))
276
 
277
  @drive_bp.route('/drive/raw/<int:source_id>/<path:filepath>')
 
317
  items = []
318
  for f in files:
319
  is_folder = f['mimeType'] == 'application/vnd.google-apps.folder'
320
+ mime = f['mimeType']
321
+ f_type = 'folder' if is_folder else 'file'
322
+
323
+ if mime == 'application/pdf': f_type = 'pdf'
324
+ elif 'image' in mime: f_type = 'image'
325
+ elif mime in ['application/vnd.google-apps.presentation', 'application/vnd.ms-powerpoint',
326
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation']:
327
+ f_type = 'slides'
328
+ elif mime in ['application/vnd.google-apps.spreadsheet', 'application/vnd.ms-excel',
329
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']:
330
+ f_type = 'sheet'
331
+ elif mime in ['application/vnd.google-apps.document', 'application/msword',
332
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain']:
333
+ f_type = 'doc'
334
+ elif 'video' in mime: f_type = 'video'
335
+
336
  items.append({
337
  'name': f['name'],
338
  'type': f_type,
 
367
  ''', (current_user.id, file_id, filename, 'drive_api'))
368
  conn.commit()
369
  conn.close()
370
+
371
  file_url = url_for('drive.serve_cache_file', filename=safe_name)
372
  return render_template('pdfjs_viewer.html', pdf_url=file_url, pdf_title=filename)
373
+ if safe_name.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
374
+ return render_template('image_viewer.html', image_url=url_for('drive.serve_cache_file', filename=safe_name), image_title=filename)
375
+ if safe_name.lower().endswith(('.ppt', '.pptx', '.odp')):
376
+ # Redirect to Google Slides for presentation files
377
+ return redirect(f'https://docs.google.com/presentation/d/{file_id}/edit')
378
+ if safe_name.lower().endswith(('.xls', '.xlsx', '.ods', '.csv')):
379
+ return redirect(f'https://docs.google.com/spreadsheets/d/{file_id}/edit')
380
+ if safe_name.lower().endswith(('.doc', '.docx', '.odt')):
381
+ return redirect(f'https://docs.google.com/document/d/{file_id}/edit')
382
+ # For other files, serve directly
383
+ return send_from_directory(cache_dir, safe_name)
384
  except Exception as e: return f"Error opening file: {e}", 500
385
 
386
  @drive_bp.route('/drive/cache/<filename>')
neetprep.py CHANGED
@@ -70,7 +70,7 @@ def index():
70
  conn = get_db_connection()
71
  selected_subject = request.args.get('subject', 'All')
72
  AVAILABLE_SUBJECTS = ["All", "Biology", "Chemistry", "Physics", "Mathematics"]
73
-
74
  neetprep_topic_counts = {}
75
  unclassified_count = 0
76
  if current_user.neetprep_enabled:
@@ -88,23 +88,23 @@ def index():
88
  # Get classified question counts per chapter for the current user, filtered by subject
89
  query_params = [current_user.id]
90
  base_query = """
91
- SELECT q.chapter, COUNT(*) as count
92
  FROM questions q
93
  JOIN sessions s ON q.session_id = s.id
94
- WHERE s.user_id = ? AND q.subject IS NOT NULL AND q.chapter IS NOT NULL
95
  """
96
  if selected_subject != 'All':
97
  base_query += " AND q.subject = ? "
98
  query_params.append(selected_subject)
99
-
100
  base_query += " GROUP BY q.chapter"
101
-
102
  classified_chapters = conn.execute(base_query, query_params).fetchall()
103
  classified_chapter_counts = {row['chapter']: row['count'] for row in classified_chapters}
104
 
105
  # Combine the topics
106
  all_topics = set(neetprep_topic_counts.keys()) | set(classified_chapter_counts.keys())
107
-
108
  combined_topics = []
109
  for topic in sorted(list(all_topics)):
110
  combined_topics.append({
@@ -113,13 +113,30 @@ def index():
113
  'my_questions_count': classified_chapter_counts.get(topic, 0)
114
  })
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  conn.close()
117
- return render_template('neetprep.html',
118
- topics=combined_topics,
119
  unclassified_count=unclassified_count,
120
  available_subjects=AVAILABLE_SUBJECTS,
121
  selected_subject=selected_subject,
122
- neetprep_enabled=current_user.neetprep_enabled)
 
123
 
124
  @neetprep_bp.route('/neetprep/sync', methods=['POST'])
125
  @login_required
@@ -314,6 +331,10 @@ def generate_neetprep_pdf():
314
  topics = json.loads(topics_str) if topics_str and topics_str != '[]' else []
315
  source_filter = data.get('source', 'all') # 'all', 'neetprep', or 'classified'
316
 
 
 
 
 
317
  conn = get_db_connection()
318
  all_questions = []
319
 
@@ -353,10 +374,21 @@ def generate_neetprep_pdf():
353
  if include_classified:
354
  if topics and pdf_type in ['quiz', 'selected']:
355
  placeholders = ', '.join('?' for _ in topics)
356
- classified_questions_from_db = conn.execute(f"""
357
  SELECT q.* FROM questions q JOIN sessions s ON q.session_id = s.id
358
  WHERE q.chapter IN ({placeholders}) AND s.user_id = ?
359
- """, (*topics, current_user.id)).fetchall()
 
 
 
 
 
 
 
 
 
 
 
360
  for q in classified_questions_from_db:
361
  image_info = conn.execute("SELECT processed_filename, note_filename FROM images WHERE id = ?", (q['image_id'],)).fetchone()
362
  if image_info and image_info['processed_filename']:
 
70
  conn = get_db_connection()
71
  selected_subject = request.args.get('subject', 'All')
72
  AVAILABLE_SUBJECTS = ["All", "Biology", "Chemistry", "Physics", "Mathematics"]
73
+
74
  neetprep_topic_counts = {}
75
  unclassified_count = 0
76
  if current_user.neetprep_enabled:
 
88
  # Get classified question counts per chapter for the current user, filtered by subject
89
  query_params = [current_user.id]
90
  base_query = """
91
+ SELECT q.chapter, COUNT(*) as count
92
  FROM questions q
93
  JOIN sessions s ON q.session_id = s.id
94
+ WHERE s.user_id = ? AND q.subject IS NOT NULL AND q.chapter IS NOT NULL
95
  """
96
  if selected_subject != 'All':
97
  base_query += " AND q.subject = ? "
98
  query_params.append(selected_subject)
99
+
100
  base_query += " GROUP BY q.chapter"
101
+
102
  classified_chapters = conn.execute(base_query, query_params).fetchall()
103
  classified_chapter_counts = {row['chapter']: row['count'] for row in classified_chapters}
104
 
105
  # Combine the topics
106
  all_topics = set(neetprep_topic_counts.keys()) | set(classified_chapter_counts.keys())
107
+
108
  combined_topics = []
109
  for topic in sorted(list(all_topics)):
110
  combined_topics.append({
 
113
  'my_questions_count': classified_chapter_counts.get(topic, 0)
114
  })
115
 
116
+ # Get distinct tags from classified questions for tag-based filtering
117
+ tag_rows = conn.execute("""
118
+ SELECT DISTINCT q.tags FROM questions q
119
+ JOIN sessions s ON q.session_id = s.id
120
+ WHERE s.user_id = ? AND q.tags IS NOT NULL AND q.tags != ''
121
+ """, (current_user.id,)).fetchall()
122
+
123
+ # Parse comma-separated tags into unique set
124
+ all_tags = set()
125
+ for row in tag_rows:
126
+ if row['tags']:
127
+ for tag in row['tags'].split(','):
128
+ tag = tag.strip()
129
+ if tag:
130
+ all_tags.add(tag)
131
+
132
  conn.close()
133
+ return render_template('neetprep.html',
134
+ topics=combined_topics,
135
  unclassified_count=unclassified_count,
136
  available_subjects=AVAILABLE_SUBJECTS,
137
  selected_subject=selected_subject,
138
+ neetprep_enabled=current_user.neetprep_enabled,
139
+ available_tags=sorted(list(all_tags)))
140
 
141
  @neetprep_bp.route('/neetprep/sync', methods=['POST'])
142
  @login_required
 
331
  topics = json.loads(topics_str) if topics_str and topics_str != '[]' else []
332
  source_filter = data.get('source', 'all') # 'all', 'neetprep', or 'classified'
333
 
334
+ # Get tag filters
335
+ tags_str = data.get('tags')
336
+ filter_tags = json.loads(tags_str) if tags_str and tags_str != '[]' else []
337
+
338
  conn = get_db_connection()
339
  all_questions = []
340
 
 
374
  if include_classified:
375
  if topics and pdf_type in ['quiz', 'selected']:
376
  placeholders = ', '.join('?' for _ in topics)
377
+ query = f"""
378
  SELECT q.* FROM questions q JOIN sessions s ON q.session_id = s.id
379
  WHERE q.chapter IN ({placeholders}) AND s.user_id = ?
380
+ """
381
+ params = list(topics) + [current_user.id]
382
+
383
+ # Add tag filtering if tags are specified
384
+ if filter_tags:
385
+ tag_conditions = []
386
+ for tag in filter_tags:
387
+ tag_conditions.append("q.tags LIKE ?")
388
+ params.append(f"%{tag}%")
389
+ query += f" AND ({' OR '.join(tag_conditions)})"
390
+
391
+ classified_questions_from_db = conn.execute(query, params).fetchall()
392
  for q in classified_questions_from_db:
393
  image_info = conn.execute("SELECT processed_filename, note_filename FROM images WHERE id = ?", (q['image_id'],)).fetchone()
394
  if image_info and image_info['processed_filename']:
templates/drive_browser.html CHANGED
@@ -129,6 +129,26 @@
129
  color: #6c757d;
130
  }
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  .file-name {
133
  font-size: 0.9rem;
134
  font-weight: 500;
@@ -208,6 +228,36 @@
208
  color: white;
209
  }
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  /* Empty State */
212
  .empty-state {
213
  text-align: center;
@@ -297,29 +347,74 @@
297
  {% for item in items %}
298
  {% if item.type == 'folder' %}
299
  {% set link = '/drive/api/browse/' + item.path if is_api else '/drive/browse/' + source.id|string + '/' + item.path %}
 
300
  {% set icon_class = 'folder' %}
301
  {% set icon = 'bi-folder-fill' %}
 
302
  {% elif item.type == 'pdf' %}
303
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
 
304
  {% set icon_class = 'pdf' %}
305
  {% set icon = 'bi-file-earmark-pdf-fill' %}
 
306
  {% elif item.type == 'image' %}
307
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
 
308
  {% set icon_class = 'image' %}
309
  {% set icon = 'bi-file-earmark-image-fill' %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  {% else %}
311
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
 
312
  {% set icon_class = 'file' %}
313
  {% set icon = 'bi-file-earmark-fill' %}
 
314
  {% endif %}
315
 
316
  <div class="col">
317
- <div class="file-card" onclick="navigate('{{ link }}')">
318
  <div class="card-body">
319
  {% if item.type != 'folder' %}
320
- <button class="copy-btn" onclick="event.stopPropagation(); copyFileLink('{{ link }}', this)" title="Copy link">
321
- <i class="bi bi-link-45deg"></i>
322
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  {% endif %}
324
  <div class="file-icon {{ icon_class }}">
325
  <i class="bi {{ icon }}"></i>
@@ -347,20 +442,52 @@
347
  {% for item in items %}
348
  {% if item.type == 'folder' %}
349
  {% set link = '/drive/api/browse/' + item.path if is_api else '/drive/browse/' + source.id|string + '/' + item.path %}
 
350
  {% set icon_class = 'folder' %}
351
  {% set icon = 'bi-folder-fill' %}
 
352
  {% elif item.type == 'pdf' %}
353
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
 
354
  {% set icon_class = 'pdf' %}
355
  {% set icon = 'bi-file-earmark-pdf-fill' %}
 
356
  {% elif item.type == 'image' %}
357
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
 
358
  {% set icon_class = 'image' %}
359
  {% set icon = 'bi-file-earmark-image-fill' %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  {% else %}
361
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
 
362
  {% set icon_class = 'file' %}
363
  {% set icon = 'bi-file-earmark-fill' %}
 
364
  {% endif %}
365
 
366
  <div class="file-row" onclick="navigate('{{ link }}')">
@@ -370,9 +497,22 @@
370
  <div class="file-name">{{ item.name }}</div>
371
  <span class="badge bg-secondary">{{ item.type }}</span>
372
  {% if item.type != 'folder' %}
373
- <button class="copy-btn" onclick="event.stopPropagation(); copyFileLink('{{ link }}', this)" title="Copy link">
374
- <i class="bi bi-link-45deg"></i>
375
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  {% endif %}
377
  </div>
378
  {% endfor %}
@@ -449,8 +589,10 @@
449
  const fullUrl = window.location.origin + path;
450
  navigator.clipboard.writeText(fullUrl).then(() => {
451
  // Show success state
452
- btn.classList.add('copied');
453
  btn.innerHTML = '<i class="bi bi-check"></i>';
 
 
454
 
455
  // Show toast
456
  const toast = document.getElementById('copyToast');
@@ -458,10 +600,20 @@
458
 
459
  // Reset button after delay
460
  setTimeout(() => {
461
- btn.classList.remove('copied');
462
- btn.innerHTML = '<i class="bi bi-link-45deg"></i>';
 
463
  }, 2000);
464
  });
465
  }
 
 
 
 
 
 
 
 
 
466
  </script>
467
  {% endblock %}
 
129
  color: #6c757d;
130
  }
131
 
132
+ .file-icon.slides {
133
+ background: linear-gradient(135deg, rgba(251,188,5,0.2) 0%, rgba(234,88,12,0.2) 100%);
134
+ color: #f59e0b;
135
+ }
136
+
137
+ .file-icon.sheet {
138
+ background: linear-gradient(135deg, rgba(34,197,94,0.2) 0%, rgba(22,163,74,0.2) 100%);
139
+ color: #22c55e;
140
+ }
141
+
142
+ .file-icon.doc {
143
+ background: linear-gradient(135deg, rgba(59,130,246,0.2) 0%, rgba(37,99,235,0.2) 100%);
144
+ color: #3b82f6;
145
+ }
146
+
147
+ .file-icon.video {
148
+ background: linear-gradient(135deg, rgba(168,85,247,0.2) 0%, rgba(139,92,246,0.2) 100%);
149
+ color: #a855f7;
150
+ }
151
+
152
  .file-name {
153
  font-size: 0.9rem;
154
  font-weight: 500;
 
228
  color: white;
229
  }
230
 
231
+ /* Action buttons for files */
232
+ .file-actions {
233
+ display: flex;
234
+ gap: 4px;
235
+ flex-shrink: 0;
236
+ }
237
+
238
+ .action-btn-sm {
239
+ width: 28px;
240
+ height: 28px;
241
+ border-radius: 6px;
242
+ background: rgba(255,255,255,0.1);
243
+ border: none;
244
+ color: var(--text-muted);
245
+ display: flex;
246
+ align-items: center;
247
+ justify-content: center;
248
+ transition: all 0.2s ease;
249
+ font-size: 0.85rem;
250
+ }
251
+
252
+ .action-btn-sm:hover {
253
+ background: var(--accent-primary);
254
+ color: white;
255
+ }
256
+
257
+ .action-btn-sm.google-btn:hover {
258
+ background: #4285f4;
259
+ }
260
+
261
  /* Empty State */
262
  .empty-state {
263
  text-align: center;
 
347
  {% for item in items %}
348
  {% if item.type == 'folder' %}
349
  {% set link = '/drive/api/browse/' + item.path if is_api else '/drive/browse/' + source.id|string + '/' + item.path %}
350
+ {% set raw_link = '' %}
351
  {% set icon_class = 'folder' %}
352
  {% set icon = 'bi-folder-fill' %}
353
+ {% set google_link = '' %}
354
  {% elif item.type == 'pdf' %}
355
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
356
+ {% set raw_link = '/drive/raw/' + source.id|string + '/' + item.path if not is_api else '' %}
357
  {% set icon_class = 'pdf' %}
358
  {% set icon = 'bi-file-earmark-pdf-fill' %}
359
+ {% set google_link = '' %}
360
  {% elif item.type == 'image' %}
361
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
362
+ {% set raw_link = '/drive/raw/' + source.id|string + '/' + item.path if not is_api else '' %}
363
  {% set icon_class = 'image' %}
364
  {% set icon = 'bi-file-earmark-image-fill' %}
365
+ {% set google_link = '' %}
366
+ {% elif item.type == 'slides' %}
367
+ {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
368
+ {% set raw_link = '' %}
369
+ {% set icon_class = 'slides' %}
370
+ {% set icon = 'bi-file-earmark-slides-fill' %}
371
+ {% set google_link = 'https://docs.google.com/presentation/d/' + item.path + '/edit' if is_api else '' %}
372
+ {% elif item.type == 'sheet' %}
373
+ {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
374
+ {% set raw_link = '' %}
375
+ {% set icon_class = 'sheet' %}
376
+ {% set icon = 'bi-file-earmark-spreadsheet-fill' %}
377
+ {% set google_link = 'https://docs.google.com/spreadsheets/d/' + item.path + '/edit' if is_api else '' %}
378
+ {% elif item.type == 'doc' %}
379
+ {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
380
+ {% set raw_link = '' %}
381
+ {% set icon_class = 'doc' %}
382
+ {% set icon = 'bi-file-earmark-word-fill' %}
383
+ {% set google_link = 'https://docs.google.com/document/d/' + item.path + '/edit' if is_api else '' %}
384
+ {% elif item.type == 'video' %}
385
+ {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
386
+ {% set raw_link = '' %}
387
+ {% set icon_class = 'video' %}
388
+ {% set icon = 'bi-file-earmark-play-fill' %}
389
+ {% set google_link = '' %}
390
  {% else %}
391
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
392
+ {% set raw_link = '/drive/raw/' + source.id|string + '/' + item.path if not is_api else '' %}
393
  {% set icon_class = 'file' %}
394
  {% set icon = 'bi-file-earmark-fill' %}
395
+ {% set google_link = '' %}
396
  {% endif %}
397
 
398
  <div class="col">
399
+ <div class="file-card" onclick="navigate('{{ link }}')" data-file-type="{{ item.type }}">
400
  <div class="card-body">
401
  {% if item.type != 'folder' %}
402
+ <div class="file-actions" style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s;">
403
+ {% if raw_link %}
404
+ <button class="action-btn-sm" onclick="event.stopPropagation(); copyFileLink('{{ raw_link }}', this)" title="Copy direct download link">
405
+ <i class="bi bi-link-45deg"></i>
406
+ </button>
407
+ {% elif is_api %}
408
+ <button class="action-btn-sm" onclick="event.stopPropagation(); copyFileLink('{{ link }}', this)" title="Copy link">
409
+ <i class="bi bi-link-45deg"></i>
410
+ </button>
411
+ {% endif %}
412
+ {% if google_link %}
413
+ <a href="{{ google_link }}" target="_blank" class="action-btn-sm google-btn" onclick="event.stopPropagation()" title="Open in Google">
414
+ <i class="bi bi-google"></i>
415
+ </a>
416
+ {% endif %}
417
+ </div>
418
  {% endif %}
419
  <div class="file-icon {{ icon_class }}">
420
  <i class="bi {{ icon }}"></i>
 
442
  {% for item in items %}
443
  {% if item.type == 'folder' %}
444
  {% set link = '/drive/api/browse/' + item.path if is_api else '/drive/browse/' + source.id|string + '/' + item.path %}
445
+ {% set raw_link = '' %}
446
  {% set icon_class = 'folder' %}
447
  {% set icon = 'bi-folder-fill' %}
448
+ {% set google_link = '' %}
449
  {% elif item.type == 'pdf' %}
450
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
451
+ {% set raw_link = '/drive/raw/' + source.id|string + '/' + item.path if not is_api else '' %}
452
  {% set icon_class = 'pdf' %}
453
  {% set icon = 'bi-file-earmark-pdf-fill' %}
454
+ {% set google_link = '' %}
455
  {% elif item.type == 'image' %}
456
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
457
+ {% set raw_link = '/drive/raw/' + source.id|string + '/' + item.path if not is_api else '' %}
458
  {% set icon_class = 'image' %}
459
  {% set icon = 'bi-file-earmark-image-fill' %}
460
+ {% set google_link = '' %}
461
+ {% elif item.type == 'slides' %}
462
+ {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
463
+ {% set raw_link = '' %}
464
+ {% set icon_class = 'slides' %}
465
+ {% set icon = 'bi-file-earmark-slides-fill' %}
466
+ {% set google_link = 'https://docs.google.com/presentation/d/' + item.path + '/edit' if is_api else '' %}
467
+ {% elif item.type == 'sheet' %}
468
+ {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
469
+ {% set raw_link = '' %}
470
+ {% set icon_class = 'sheet' %}
471
+ {% set icon = 'bi-file-earmark-spreadsheet-fill' %}
472
+ {% set google_link = 'https://docs.google.com/spreadsheets/d/' + item.path + '/edit' if is_api else '' %}
473
+ {% elif item.type == 'doc' %}
474
+ {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
475
+ {% set raw_link = '' %}
476
+ {% set icon_class = 'doc' %}
477
+ {% set icon = 'bi-file-earmark-word-fill' %}
478
+ {% set google_link = 'https://docs.google.com/document/d/' + item.path + '/edit' if is_api else '' %}
479
+ {% elif item.type == 'video' %}
480
+ {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
481
+ {% set raw_link = '' %}
482
+ {% set icon_class = 'video' %}
483
+ {% set icon = 'bi-file-earmark-play-fill' %}
484
+ {% set google_link = '' %}
485
  {% else %}
486
  {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
487
+ {% set raw_link = '/drive/raw/' + source.id|string + '/' + item.path if not is_api else '' %}
488
  {% set icon_class = 'file' %}
489
  {% set icon = 'bi-file-earmark-fill' %}
490
+ {% set google_link = '' %}
491
  {% endif %}
492
 
493
  <div class="file-row" onclick="navigate('{{ link }}')">
 
497
  <div class="file-name">{{ item.name }}</div>
498
  <span class="badge bg-secondary">{{ item.type }}</span>
499
  {% if item.type != 'folder' %}
500
+ <div class="file-actions">
501
+ {% if raw_link %}
502
+ <button class="action-btn-sm" onclick="event.stopPropagation(); copyFileLink('{{ raw_link }}', this)" title="Copy direct link">
503
+ <i class="bi bi-link-45deg"></i>
504
+ </button>
505
+ {% elif is_api %}
506
+ <button class="action-btn-sm" onclick="event.stopPropagation(); copyFileLink('{{ link }}', this)" title="Copy link">
507
+ <i class="bi bi-link-45deg"></i>
508
+ </button>
509
+ {% endif %}
510
+ {% if google_link %}
511
+ <a href="{{ google_link }}" target="_blank" class="action-btn-sm google-btn" onclick="event.stopPropagation()" title="Open in Google">
512
+ <i class="bi bi-google"></i>
513
+ </a>
514
+ {% endif %}
515
+ </div>
516
  {% endif %}
517
  </div>
518
  {% endfor %}
 
589
  const fullUrl = window.location.origin + path;
590
  navigator.clipboard.writeText(fullUrl).then(() => {
591
  // Show success state
592
+ const originalHtml = btn.innerHTML;
593
  btn.innerHTML = '<i class="bi bi-check"></i>';
594
+ btn.style.background = '#198754';
595
+ btn.style.color = 'white';
596
 
597
  // Show toast
598
  const toast = document.getElementById('copyToast');
 
600
 
601
  // Reset button after delay
602
  setTimeout(() => {
603
+ btn.innerHTML = originalHtml;
604
+ btn.style.background = '';
605
+ btn.style.color = '';
606
  }, 2000);
607
  });
608
  }
609
+
610
+ // Show action buttons on hover
611
+ document.querySelectorAll('.file-card').forEach(card => {
612
+ const actions = card.querySelector('.file-actions');
613
+ if (actions) {
614
+ card.addEventListener('mouseenter', () => actions.style.opacity = '1');
615
+ card.addEventListener('mouseleave', () => actions.style.opacity = '0');
616
+ }
617
+ });
618
  </script>
619
  {% endblock %}
templates/image_viewer.html ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, maximum-scale=5, user-scalable=yes">
6
+ <title>{{ image_title }}</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
8
+ <style>
9
+ :root {
10
+ --bg-dark: #0d0d0d;
11
+ --toolbar-bg: #1a1a1a;
12
+ --accent: #3b82f6;
13
+ }
14
+
15
+ * {
16
+ box-sizing: border-box;
17
+ margin: 0;
18
+ padding: 0;
19
+ }
20
+
21
+ body {
22
+ background: var(--bg-dark);
23
+ min-height: 100vh;
24
+ display: flex;
25
+ flex-direction: column;
26
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
27
+ color: #fff;
28
+ }
29
+
30
+ .toolbar {
31
+ height: 48px;
32
+ background: var(--toolbar-bg);
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ padding: 0 16px;
37
+ border-bottom: 1px solid rgba(255,255,255,0.1);
38
+ flex-shrink: 0;
39
+ }
40
+
41
+ .toolbar-title {
42
+ font-size: 14px;
43
+ font-weight: 500;
44
+ overflow: hidden;
45
+ text-overflow: ellipsis;
46
+ white-space: nowrap;
47
+ max-width: 50%;
48
+ }
49
+
50
+ .toolbar-actions {
51
+ display: flex;
52
+ gap: 8px;
53
+ }
54
+
55
+ .toolbar-btn {
56
+ background: transparent;
57
+ border: none;
58
+ color: #999;
59
+ padding: 8px 12px;
60
+ border-radius: 8px;
61
+ cursor: pointer;
62
+ font-size: 16px;
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 6px;
66
+ transition: all 0.15s ease;
67
+ text-decoration: none;
68
+ }
69
+
70
+ .toolbar-btn:hover {
71
+ background: rgba(255,255,255,0.08);
72
+ color: #fff;
73
+ }
74
+
75
+ .image-container {
76
+ flex: 1;
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ padding: 20px;
81
+ overflow: auto;
82
+ }
83
+
84
+ .image-container img {
85
+ max-width: 100%;
86
+ max-height: 100%;
87
+ object-fit: contain;
88
+ border-radius: 4px;
89
+ box-shadow: 0 4px 20px rgba(0,0,0,0.5);
90
+ transition: transform 0.3s ease;
91
+ }
92
+
93
+ .image-container img.zoomed {
94
+ max-width: none;
95
+ max-height: none;
96
+ cursor: zoom-out;
97
+ }
98
+
99
+ .zoom-controls {
100
+ position: fixed;
101
+ bottom: 20px;
102
+ left: 50%;
103
+ transform: translateX(-50%);
104
+ background: var(--toolbar-bg);
105
+ border-radius: 12px;
106
+ padding: 8px;
107
+ display: flex;
108
+ gap: 4px;
109
+ border: 1px solid rgba(255,255,255,0.1);
110
+ }
111
+
112
+ .zoom-btn {
113
+ width: 40px;
114
+ height: 40px;
115
+ border: none;
116
+ background: transparent;
117
+ color: #999;
118
+ border-radius: 8px;
119
+ cursor: pointer;
120
+ font-size: 18px;
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ transition: all 0.15s ease;
125
+ }
126
+
127
+ .zoom-btn:hover {
128
+ background: rgba(255,255,255,0.1);
129
+ color: #fff;
130
+ }
131
+
132
+ .zoom-level {
133
+ min-width: 60px;
134
+ display: flex;
135
+ align-items: center;
136
+ justify-content: center;
137
+ font-size: 13px;
138
+ color: #999;
139
+ }
140
+ </style>
141
+ </head>
142
+ <body>
143
+ <div class="toolbar">
144
+ <a href="javascript:history.back()" class="toolbar-btn">
145
+ <i class="bi bi-arrow-left"></i>
146
+ Back
147
+ </a>
148
+ <div class="toolbar-title">{{ image_title }}</div>
149
+ <div class="toolbar-actions">
150
+ <a href="{{ image_url }}" download class="toolbar-btn">
151
+ <i class="bi bi-download"></i>
152
+ </a>
153
+ <button class="toolbar-btn" onclick="toggleFullscreen()">
154
+ <i class="bi bi-arrows-fullscreen"></i>
155
+ </button>
156
+ </div>
157
+ </div>
158
+
159
+ <div class="image-container" id="imageContainer">
160
+ <img src="{{ image_url }}" alt="{{ image_title }}" id="mainImage" onclick="toggleZoom()">
161
+ </div>
162
+
163
+ <div class="zoom-controls">
164
+ <button class="zoom-btn" onclick="zoomOut()"><i class="bi bi-dash"></i></button>
165
+ <span class="zoom-level" id="zoomLevel">100%</span>
166
+ <button class="zoom-btn" onclick="zoomIn()"><i class="bi bi-plus"></i></button>
167
+ <button class="zoom-btn" onclick="resetZoom()"><i class="bi bi-arrows-angle-contract"></i></button>
168
+ </div>
169
+
170
+ <script>
171
+ const img = document.getElementById('mainImage');
172
+ const zoomLevelEl = document.getElementById('zoomLevel');
173
+ let currentZoom = 1;
174
+
175
+ function updateZoom() {
176
+ img.style.transform = `scale(${currentZoom})`;
177
+ zoomLevelEl.textContent = Math.round(currentZoom * 100) + '%';
178
+ if (currentZoom > 1) {
179
+ img.classList.add('zoomed');
180
+ } else {
181
+ img.classList.remove('zoomed');
182
+ }
183
+ }
184
+
185
+ function zoomIn() {
186
+ currentZoom = Math.min(currentZoom * 1.25, 5);
187
+ updateZoom();
188
+ }
189
+
190
+ function zoomOut() {
191
+ currentZoom = Math.max(currentZoom / 1.25, 0.25);
192
+ updateZoom();
193
+ }
194
+
195
+ function resetZoom() {
196
+ currentZoom = 1;
197
+ updateZoom();
198
+ }
199
+
200
+ function toggleZoom() {
201
+ if (currentZoom > 1) {
202
+ resetZoom();
203
+ } else {
204
+ currentZoom = 2;
205
+ updateZoom();
206
+ }
207
+ }
208
+
209
+ function toggleFullscreen() {
210
+ if (!document.fullscreenElement) {
211
+ document.documentElement.requestFullscreen();
212
+ } else {
213
+ document.exitFullscreen();
214
+ }
215
+ }
216
+
217
+ // Keyboard shortcuts
218
+ document.addEventListener('keydown', (e) => {
219
+ if (e.key === '+' || e.key === '=') zoomIn();
220
+ else if (e.key === '-') zoomOut();
221
+ else if (e.key === '0') resetZoom();
222
+ else if (e.key === 'Escape') history.back();
223
+ });
224
+
225
+ // Pinch to zoom
226
+ let initialDistance = 0;
227
+ let initialZoom = 1;
228
+
229
+ document.addEventListener('touchstart', (e) => {
230
+ if (e.touches.length === 2) {
231
+ initialDistance = Math.hypot(
232
+ e.touches[0].clientX - e.touches[1].clientX,
233
+ e.touches[0].clientY - e.touches[1].clientY
234
+ );
235
+ initialZoom = currentZoom;
236
+ }
237
+ }, { passive: true });
238
+
239
+ document.addEventListener('touchmove', (e) => {
240
+ if (e.touches.length === 2) {
241
+ const distance = Math.hypot(
242
+ e.touches[0].clientX - e.touches[1].clientX,
243
+ e.touches[0].clientY - e.touches[1].clientY
244
+ );
245
+ const ratio = distance / initialDistance;
246
+ currentZoom = Math.min(Math.max(initialZoom * ratio, 0.25), 5);
247
+ updateZoom();
248
+ }
249
+ }, { passive: true });
250
+ </script>
251
+ </body>
252
+ </html>
templates/neetprep.html CHANGED
@@ -189,6 +189,29 @@
189
  font-weight: 500;
190
  color: var(--accent-primary);
191
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  </style>
193
  {% endblock %}
194
 
@@ -269,6 +292,28 @@
269
  </div>
270
  </div>
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  <!-- Status Messages -->
273
  <div id="sync-status"></div>
274
  <div id="pdf-link-container"></div>
@@ -397,6 +442,56 @@ topicCheckboxes.forEach(cb => {
397
  cb.addEventListener('change', updateSelectedCount);
398
  });
399
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  // Only attach event listeners if NeetPrep is enabled
401
  {% if neetprep_enabled %}
402
  document.getElementById('sync-btn').addEventListener('click', async () => {
@@ -479,6 +574,13 @@ document.getElementById('generate-pdf-form').addEventListener('submit', async (e
479
  sourceInput.value = sourceFilter;
480
  form.appendChild(sourceInput);
481
 
 
 
 
 
 
 
 
482
  document.body.appendChild(form);
483
  form.submit();
484
  return;
@@ -487,6 +589,7 @@ document.getElementById('generate-pdf-form').addEventListener('submit', async (e
487
  let payload = {
488
  type: pdfType,
489
  source: sourceFilter,
 
490
  layout: {
491
  images_per_page: settings.images_per_page,
492
  orientation: settings.orientation,
 
189
  font-weight: 500;
190
  color: var(--accent-primary);
191
  }
192
+
193
+ /* Tag chips */
194
+ .tag-chip {
195
+ border-radius: 20px;
196
+ font-size: 0.8rem;
197
+ padding: 4px 12px;
198
+ transition: all 0.15s ease;
199
+ }
200
+
201
+ .tag-chip.active {
202
+ background-color: var(--accent-primary);
203
+ border-color: var(--accent-primary);
204
+ color: white;
205
+ }
206
+
207
+ .tag-chip:hover:not(.active) {
208
+ background-color: rgba(13, 110, 253, 0.15);
209
+ border-color: var(--accent-primary);
210
+ }
211
+
212
+ .tag-search-container input::placeholder {
213
+ color: var(--text-muted);
214
+ }
215
  </style>
216
  {% endblock %}
217
 
 
292
  </div>
293
  </div>
294
 
295
+ <!-- Tag Filter -->
296
+ {% if available_tags %}
297
+ <div class="section-card">
298
+ <div class="section-title"><i class="bi bi-tags"></i>Filter by Tags</div>
299
+ <div class="tag-search-container mb-2">
300
+ <input type="text" class="form-control form-control-sm" id="tag-search" placeholder="Search tags..." style="background: var(--bg-elevated); border-color: var(--border-subtle); color: #fff;">
301
+ </div>
302
+ <div class="tag-chips-container" id="tag-chips" style="max-height: 150px; overflow-y: auto;">
303
+ {% for tag in available_tags %}
304
+ <button type="button" class="btn btn-outline-secondary btn-sm tag-chip me-1 mb-1" data-tag="{{ tag }}">
305
+ {{ tag }}
306
+ </button>
307
+ {% endfor %}
308
+ </div>
309
+ <div class="selected-tags mt-2" id="selected-tags-display" style="display: none;">
310
+ <small class="text-muted">Selected: </small>
311
+ <span id="selected-tags-list"></span>
312
+ <button type="button" class="btn btn-link btn-sm text-danger p-0 ms-2" onclick="clearAllTags()">Clear</button>
313
+ </div>
314
+ </div>
315
+ {% endif %}
316
+
317
  <!-- Status Messages -->
318
  <div id="sync-status"></div>
319
  <div id="pdf-link-container"></div>
 
442
  cb.addEventListener('change', updateSelectedCount);
443
  });
444
 
445
+ // Tag filtering functionality
446
+ const selectedTags = new Set();
447
+ const tagChips = document.querySelectorAll('.tag-chip');
448
+ const tagSearchInput = document.getElementById('tag-search');
449
+ const selectedTagsDisplay = document.getElementById('selected-tags-display');
450
+ const selectedTagsList = document.getElementById('selected-tags-list');
451
+
452
+ function updateTagsDisplay() {
453
+ if (selectedTags.size > 0) {
454
+ selectedTagsDisplay.style.display = 'block';
455
+ selectedTagsList.textContent = Array.from(selectedTags).join(', ');
456
+ } else {
457
+ selectedTagsDisplay.style.display = 'none';
458
+ }
459
+ }
460
+
461
+ tagChips.forEach(chip => {
462
+ chip.addEventListener('click', () => {
463
+ const tag = chip.dataset.tag;
464
+ if (selectedTags.has(tag)) {
465
+ selectedTags.delete(tag);
466
+ chip.classList.remove('active');
467
+ } else {
468
+ selectedTags.add(tag);
469
+ chip.classList.add('active');
470
+ }
471
+ updateTagsDisplay();
472
+ });
473
+ });
474
+
475
+ if (tagSearchInput) {
476
+ tagSearchInput.addEventListener('input', (e) => {
477
+ const searchTerm = e.target.value.toLowerCase();
478
+ tagChips.forEach(chip => {
479
+ const tag = chip.dataset.tag.toLowerCase();
480
+ if (tag.includes(searchTerm)) {
481
+ chip.style.display = '';
482
+ } else {
483
+ chip.style.display = 'none';
484
+ }
485
+ });
486
+ });
487
+ }
488
+
489
+ function clearAllTags() {
490
+ selectedTags.clear();
491
+ tagChips.forEach(chip => chip.classList.remove('active'));
492
+ updateTagsDisplay();
493
+ }
494
+
495
  // Only attach event listeners if NeetPrep is enabled
496
  {% if neetprep_enabled %}
497
  document.getElementById('sync-btn').addEventListener('click', async () => {
 
574
  sourceInput.value = sourceFilter;
575
  form.appendChild(sourceInput);
576
 
577
+ // Include selected tags
578
+ const tagsInput = document.createElement('input');
579
+ tagsInput.type = 'hidden';
580
+ tagsInput.name = 'tags';
581
+ tagsInput.value = JSON.stringify(Array.from(selectedTags));
582
+ form.appendChild(tagsInput);
583
+
584
  document.body.appendChild(form);
585
  form.submit();
586
  return;
 
589
  let payload = {
590
  type: pdfType,
591
  source: sourceFilter,
592
+ tags: JSON.stringify(Array.from(selectedTags)),
593
  layout: {
594
  images_per_page: settings.images_per_page,
595
  orientation: settings.orientation,
templates/pdfjs_viewer.html CHANGED
@@ -1,1071 +1,49 @@
1
  <!DOCTYPE html>
2
- <html lang="en" dir="ltr">
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
6
  <title>{{ pdf_title }}</title>
7
- <!-- Bootstrap Icons -->
8
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
9
- <!-- Inline Styles -->
10
  <style>
11
- /* Mozilla PDF.js Viewer Visual Replication */
12
- :root {
13
- --sidebar-width: 200px;
14
- --toolbar-height: 32px;
15
- --body-bg: #525659; /* Classic PDF.js grey background */
16
- --toolbar-bg: #2a2a2e; /* Dark Toolbar */
17
- --sidebar-bg: #383c40;
18
- --text-color: #f1f1f1;
19
- --icon-color: #c0c0c0;
20
- --icon-hover: #ffffff;
21
- --input-bg: #1d1d1f;
22
- --separator-color: #555;
23
- --toast-bg: #333;
24
- --toast-text: #fff;
25
- }
26
-
27
- * {
28
- box-sizing: border-box;
29
- user-select: none;
30
- -webkit-user-select: none;
31
- }
32
-
33
- body {
34
- background-color: var(--body-bg);
35
- height: 100vh;
36
- width: 100vw;
37
- margin: 0;
38
- padding: 0;
39
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
40
- overflow: hidden;
41
- display: flex;
42
- flex-direction: column;
43
- color: var(--text-color);
44
- /* Prevent native pull-to-refresh and other touch behaviors */
45
- overscroll-behavior: none;
46
- }
47
-
48
- #outerContainer {
49
- width: 100%;
50
- height: 100%;
51
- display: flex;
52
- }
53
-
54
- /* SIDEBAR */
55
- #sidebarContainer {
56
- width: 0;
57
- background-color: var(--sidebar-bg);
58
- border-right: 1px solid #000;
59
- transition: width 0.2s ease;
60
- display: flex;
61
- flex-direction: column;
62
- overflow: hidden;
63
- }
64
-
65
- #sidebarContainer.open {
66
- width: var(--sidebar-width);
67
- }
68
-
69
- #sidebarContent {
70
- flex: 1;
71
- overflow-y: auto;
72
- padding: 10px;
73
- position: relative;
74
- }
75
-
76
- #sidebarContent::-webkit-scrollbar { width: 10px; }
77
- #sidebarContent::-webkit-scrollbar-track { background: var(--sidebar-bg); }
78
- #sidebarContent::-webkit-scrollbar-thumb { background: #666; border-radius: 10px; border: 2px solid var(--sidebar-bg); }
79
-
80
- .thumbnail {
81
- margin-bottom: 15px;
82
- display: flex;
83
- flex-direction: column;
84
- align-items: center;
85
- cursor: pointer;
86
- }
87
-
88
- .thumbnail-image-container {
89
- border: 1px solid transparent;
90
- box-shadow: 0 2px 5px rgba(0,0,0,0.3);
91
- background: white;
92
- transition: border-color 0.2s;
93
- position: relative;
94
- }
95
-
96
- .thumbnail.selected .thumbnail-image-container {
97
- border-color: #63b3ed;
98
- box-shadow: 0 0 0 2px rgba(99, 179, 237, 0.4);
99
- }
100
-
101
- .thumbnail-canvas {
102
- display: block;
103
- width: 100px;
104
- height: auto;
105
- }
106
-
107
- .thumbnail-label {
108
- font-size: 11px;
109
- margin-top: 4px;
110
- color: #aaa;
111
- }
112
-
113
- /* MAIN */
114
- #mainContainer {
115
- flex: 1;
116
- display: flex;
117
- flex-direction: column;
118
- min-width: 0;
119
- position: relative;
120
- }
121
-
122
- /* TOOLBAR */
123
- .toolbar {
124
- height: var(--toolbar-height);
125
- background-color: var(--toolbar-bg);
126
- display: flex;
127
- align-items: center;
128
- justify-content: space-between;
129
- padding: 0 10px;
130
- box-shadow: 0 1px 0 rgba(0,0,0,0.5);
131
- z-index: 9999;
132
- font-size: 12px;
133
- }
134
-
135
- .toolbarGroup {
136
- display: flex;
137
- align-items: center;
138
- gap: 2px;
139
- }
140
-
141
- .toolbarButton {
142
- background: transparent;
143
- border: none;
144
- color: var(--icon-color);
145
- padding: 4px 8px;
146
- border-radius: 2px;
147
- cursor: pointer;
148
- font-size: 14px;
149
- display: flex;
150
- align-items: center;
151
- justify-content: center;
152
- min-width: 28px;
153
- }
154
-
155
- .toolbarButton:hover {
156
- background-color: rgba(255,255,255,0.1);
157
- color: var(--icon-hover);
158
- }
159
-
160
- .toolbarButton.toggled {
161
- background-color: rgba(0,0,0,0.3);
162
- color: var(--icon-hover);
163
- box-shadow: inset 0 1px 0 rgba(0,0,0,0.1);
164
- }
165
-
166
- .verticalSeparator {
167
- width: 1px;
168
- height: 16px;
169
- background-color: var(--separator-color);
170
- margin: 0 6px;
171
- }
172
-
173
- .toolbarField {
174
- background-color: var(--input-bg);
175
- border: 1px solid transparent;
176
- color: var(--text-color);
177
- padding: 2px 6px;
178
- border-radius: 2px;
179
- font-size: 12px;
180
- text-align: center;
181
- outline: none;
182
- }
183
-
184
- .toolbarField:focus {
185
- background-color: #000;
186
- border-color: #63b3ed;
187
- }
188
-
189
- .pageNumber { width: 40px; }
190
- .toolbarLabel { color: var(--icon-color); margin: 0 5px; }
191
-
192
- /* VIEWER AREA */
193
- #viewerContainer {
194
- flex: 1;
195
- overflow: auto;
196
- position: absolute;
197
- top: var(--toolbar-height);
198
- right: 0; bottom: 0; left: 0;
199
- background-color: var(--body-bg);
200
- background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=');
201
- /* Important for touch handling */
202
- touch-action: pan-x pan-y;
203
- -webkit-overflow-scrolling: touch;
204
- }
205
-
206
- #viewerContainer::-webkit-scrollbar { width: 14px; height: 14px; }
207
- #viewerContainer::-webkit-scrollbar-track { background: var(--body-bg); border-left: 1px solid rgba(255,255,255,0.1); }
208
- #viewerContainer::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border: 3px solid transparent; background-clip: content-box; border-radius: 8px; }
209
- #viewerContainer::-webkit-scrollbar-thumb:hover { background-color: rgba(255,255,255,0.3); }
210
-
211
- #viewer {
212
- display: flex;
213
- flex-direction: column;
214
- align-items: center;
215
- padding: 20px 0;
216
- position: relative;
217
- /* Enhance hardware acceleration for smoother pinch */
218
- will-change: transform;
219
- transform-origin: top center;
220
- }
221
-
222
- .page {
223
- direction: ltr;
224
- margin: 1px auto 10px auto;
225
- position: relative;
226
- overflow: hidden;
227
- background-color: white;
228
- box-shadow: 0px 4px 6px rgba(0,0,0,0.3);
229
- border: 9px solid transparent;
230
- background-clip: content-box;
231
- }
232
-
233
- .page canvas {
234
- display: block;
235
- width: 100%;
236
- height: 100%;
237
- }
238
-
239
- /* LOADERS */
240
- #loadingBar {
241
- position: relative; width: 100%; height: 4px;
242
- background: #333; border-bottom: 1px solid #000; display: none;
243
- }
244
- #loadingBar .bar {
245
- position: absolute; top: 0; left: 0; height: 100%;
246
- background-color: #63b3ed; width: 0%; transition: width 0.2s;
247
- }
248
-
249
- /* TOAST NOTIFICATION */
250
- .toast-container {
251
- position: fixed;
252
- bottom: 20px;
253
- right: 20px;
254
- z-index: 10000;
255
- }
256
-
257
- .toast {
258
- background-color: #2b3035;
259
- color: #fff;
260
- border-radius: 0.375rem;
261
- box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
262
- min-width: 250px;
263
- max-width: 350px;
264
- font-size: 0.875rem;
265
- opacity: 0;
266
- transform: translateY(100%);
267
- transition: all 0.3s ease-in-out;
268
- pointer-events: none;
269
- border: 1px solid rgba(255,255,255,0.1);
270
- }
271
-
272
- .toast.show {
273
- opacity: 1;
274
- transform: translateY(0);
275
- pointer-events: auto;
276
- }
277
-
278
- .toast-header {
279
- display: flex;
280
- align-items: center;
281
- padding: 0.5rem 0.75rem;
282
- color: #fff;
283
- background-color: rgba(255, 255, 255, 0.05);
284
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
285
- border-top-left-radius: calc(0.375rem - 1px);
286
- border-top-right-radius: calc(0.375rem - 1px);
287
- }
288
-
289
- .toast-body {
290
- padding: 0.75rem;
291
- word-wrap: break-word;
292
- }
293
-
294
- .btn-close-white {
295
- background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;
296
- border: 0;
297
- opacity: 0.5;
298
- padding: 0.5rem;
299
- margin-left: auto;
300
- cursor: pointer;
301
- width: 1em;
302
- height: 1em;
303
- }
304
-
305
- .btn-close-white:hover { opacity: 0.75; }
306
-
307
- @media (max-width: 600px) {
308
- .hiddenSmall { display: none; }
309
- :root { --sidebar-width: 180px; }
310
- }
311
-
312
- @media print {
313
- .toolbar, #sidebarContainer, #loadingBar { display: none; }
314
- #viewerContainer { position: static; overflow: visible; background: none; }
315
- .page { margin: 0; box-shadow: none; border: none; page-break-after: always; }
316
- }
317
  </style>
318
  </head>
319
  <body>
320
-
321
- <div id="outerContainer">
322
- <!-- SIDEBAR -->
323
- <div id="sidebarContainer">
324
- <div id="sidebarContent"></div>
325
- </div>
326
-
327
- <!-- MAIN -->
328
- <div id="mainContainer">
329
- <!-- TOOLBAR -->
330
- <div class="toolbar">
331
- <div class="toolbarGroup">
332
- <button id="sidebarToggle" class="toolbarButton" title="Toggle Sidebar">
333
- <i class="bi bi-layout-sidebar"></i>
334
- </button>
335
- <div class="verticalSeparator"></div>
336
- <button id="prev" class="toolbarButton" title="Previous Page">
337
- <i class="bi bi-chevron-up"></i>
338
- </button>
339
- <button id="next" class="toolbarButton" title="Next Page">
340
- <i class="bi bi-chevron-down"></i>
341
- </button>
342
- <input type="number" id="pageNumber" class="toolbarField pageNumber" value="1" min="1">
343
- <span id="numPages" class="toolbarLabel">of --</span>
344
- </div>
345
-
346
- <div class="toolbarGroup">
347
- <button id="zoomOut" class="toolbarButton" title="Zoom Out">
348
- <i class="bi bi-dash"></i>
349
- </button>
350
- <button id="zoomIn" class="toolbarButton" title="Zoom In">
351
- <i class="bi bi-plus"></i>
352
- </button>
353
- <select id="scaleSelect" class="toolbarField">
354
- <option value="page-actual">Actual Size</option>
355
- <option value="page-fit">Page Fit</option>
356
- <option value="page-width">Page Width</option>
357
- <option value="0.5">50%</option>
358
- <option value="0.75">75%</option>
359
- <option value="1">100%</option>
360
- <option value="1.25">125%</option>
361
- <option value="1.5">150%</option>
362
- <option value="2">200%</option>
363
- </select>
364
- </div>
365
-
366
- <div class="toolbarGroup hiddenSmall">
367
- <button id="print" class="toolbarButton" title="Print">
368
- <i class="bi bi-printer"></i>
369
- </button>
370
- <button id="download" class="toolbarButton" title="Download">
371
- <i class="bi bi-download"></i>
372
- </button>
373
- </div>
374
- </div>
375
-
376
- <!-- LOADING BAR -->
377
- <div id="loadingBar"><div class="bar" id="progressBar"></div></div>
378
-
379
- <!-- VIEWER AREA -->
380
- <div id="viewerContainer">
381
- <div id="viewer" class="pdfViewer"></div>
382
- </div>
383
- </div>
384
- </div>
385
-
386
- <!-- TOAST CONTAINER -->
387
- <div class="toast-container">
388
- <div id="cacheToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
389
- <div class="toast-header">
390
- <i class="bi bi-hdd-fill me-2" style="margin-right: 8px;"></i>
391
- <strong style="margin-right: auto;">System</strong>
392
- <button type="button" class="btn-close-white" onclick="hideToast()" aria-label="Close"></button>
393
- </div>
394
- <div class="toast-body">
395
- File loaded from local cache.
396
- </div>
397
- </div>
398
- </div>
399
-
400
- <!-- LOAD PDF.JS BEFORE MAIN SCRIPT -->
401
- <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
402
-
403
- <script>
404
- // Check if PDF.js loaded correctly
405
- if (typeof pdfjsLib === 'undefined') {
406
- alert("Error: PDF.js library failed to load. Please check your internet connection.");
407
- } else {
408
- // Set worker source
409
- pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
410
- }
411
-
412
- const config = {
413
- url: "{{ pdf_url }}",
414
- scale: 1.25,
415
- currentScaleValue: '1.25',
416
- pdfIdentifier: "{{ pdf_url }}".split('/').pop() // Use filename as identifier
417
- };
418
-
419
- const state = {
420
- pdfDoc: null,
421
- pageNum: 1,
422
- pages: [],
423
- thumbnailsRendered: false
424
- };
425
-
426
- // ==========================================
427
- // PAGE POSITION MEMORY
428
- // ==========================================
429
- const PageMemory = {
430
- storageKey: 'pdf_last_pages',
431
-
432
- getLastPage(pdfId) {
433
- try {
434
- const data = localStorage.getItem(this.storageKey);
435
- if (data) {
436
- const pages = JSON.parse(data);
437
- return pages[pdfId] || 1;
438
- }
439
- } catch (e) {
440
- console.error('Error reading last page:', e);
441
- }
442
- return 1;
443
- },
444
-
445
- saveLastPage(pdfId, pageNum) {
446
- try {
447
- const data = localStorage.getItem(this.storageKey);
448
- const pages = data ? JSON.parse(data) : {};
449
- pages[pdfId] = pageNum;
450
- localStorage.setItem(this.storageKey, JSON.stringify(pages));
451
- } catch (e) {
452
- console.error('Error saving last page:', e);
453
- }
454
- }
455
- };
456
-
457
- // DOM Elements Collection
458
- const elements = {
459
- container: document.getElementById('viewerContainer'),
460
- viewer: document.getElementById('viewer'),
461
- prev: document.getElementById('prev'),
462
- next: document.getElementById('next'),
463
- pageNum: document.getElementById('pageNumber'),
464
- pageCount: document.getElementById('numPages'),
465
- zoomIn: document.getElementById('zoomIn'),
466
- zoomOut: document.getElementById('zoomOut'),
467
- scaleSelect: document.getElementById('scaleSelect'),
468
- sidebarToggle: document.getElementById('sidebarToggle'),
469
- sidebar: document.getElementById('sidebarContainer'),
470
- sidebarContent: document.getElementById('sidebarContent'),
471
- loadingBar: document.getElementById('loadingBar'),
472
- progressBar: document.getElementById('progressBar'),
473
- print: document.getElementById('print'),
474
- download: document.getElementById('download'),
475
- toast: document.getElementById('cacheToast')
476
- };
477
-
478
- // ==========================================
479
- // PINCH TO ZOOM MODULE
480
- // ==========================================
481
- const PinchZoom = {
482
- state: {
483
- active: false,
484
- startDist: 0,
485
- startScale: 1,
486
- lastRatio: 1
487
- },
488
-
489
- init() {
490
- // Attach listeners to the container to capture gestures anywhere on the viewer
491
- elements.container.addEventListener('touchstart', this.onStart.bind(this), { passive: false });
492
- elements.container.addEventListener('touchmove', this.onMove.bind(this), { passive: false });
493
- elements.container.addEventListener('touchend', this.onEnd.bind(this));
494
- },
495
-
496
- getDistance(e) {
497
- return Math.hypot(
498
- e.touches[0].clientX - e.touches[1].clientX,
499
- e.touches[0].clientY - e.touches[1].clientY
500
- );
501
- },
502
-
503
- getCenter(e) {
504
- return {
505
- x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
506
- y: (e.touches[0].clientY + e.touches[1].clientY) / 2
507
- };
508
- },
509
-
510
- onStart(e) {
511
- if (e.touches.length === 2) {
512
- e.preventDefault(); // Prevent native browser zoom/pan
513
- this.state.active = true;
514
- this.state.startDist = this.getDistance(e);
515
- this.state.startScale = config.scale;
516
- this.state.lastRatio = 1;
517
-
518
- // Calculate transform origin based on finger position relative to viewer
519
- const center = this.getCenter(e);
520
- const rect = elements.viewer.getBoundingClientRect();
521
-
522
- // The viewer might be scrolled, getBoundingClientRect handles that
523
- const originX = center.x - rect.left;
524
- const originY = center.y - rect.top;
525
-
526
- // Set transform origin so we zoom into the space between fingers
527
- elements.viewer.style.transformOrigin = `${originX}px ${originY}px`;
528
- elements.viewer.style.transition = 'none';
529
- }
530
- },
531
-
532
- onMove(e) {
533
- if (this.state.active && e.touches.length === 2) {
534
- e.preventDefault();
535
- const dist = this.getDistance(e);
536
- const ratio = dist / this.state.startDist;
537
- this.state.lastRatio = ratio;
538
-
539
- // Apply temporary visual scale using CSS transform
540
- // This is performant (60fps) compared to re-rendering canvas
541
- elements.viewer.style.transform = `scale(${ratio})`;
542
- }
543
- },
544
-
545
- onEnd(e) {
546
- if (this.state.active && e.touches.length < 2) {
547
- this.state.active = false;
548
-
549
- // Calculate new final scale
550
- let newScale = this.state.startScale * this.state.lastRatio;
551
-
552
- // Clamp scale to reasonable limits (0.25x to 5.0x)
553
- newScale = Math.max(0.25, Math.min(newScale, 5.0));
554
-
555
- // Reset CSS transform
556
- elements.viewer.style.transform = 'none';
557
- elements.viewer.style.transformOrigin = 'center top';
558
-
559
- // Apply new scale to config
560
- config.scale = newScale;
561
- config.currentScaleValue = newScale.toString();
562
-
563
- // Update UI Dropdown
564
- this.updateScaleDropdown(newScale);
565
-
566
- // Re-render PDF at new resolution (crisp text)
567
- initViewer();
568
- }
569
- },
570
-
571
- updateScaleDropdown(scaleVal) {
572
- // Check if value exists in dropdown
573
- const rounded = Math.round(scaleVal * 100) / 100;
574
- let exists = false;
575
-
576
- // Try to find exact match
577
- for(let option of elements.scaleSelect.options) {
578
- if (Math.abs(parseFloat(option.value) - scaleVal) < 0.05) {
579
- elements.scaleSelect.value = option.value;
580
- exists = true;
581
- break;
582
- }
583
- }
584
-
585
- // If no match, add custom option
586
- if (!exists) {
587
- const opt = document.createElement('option');
588
- opt.value = scaleVal;
589
- opt.textContent = `${Math.round(scaleVal * 100)}%`;
590
- elements.scaleSelect.appendChild(opt);
591
- elements.scaleSelect.value = scaleVal;
592
- }
593
- }
594
- };
595
-
596
- // ==========================================
597
- // INDEXED DB CACHE
598
- // ==========================================
599
- const PDFCache = {
600
- dbName: 'PDFViewerCache',
601
- storeName: 'pdfs',
602
- MAX_TOTAL_SIZE: 100 * 1024 * 1024, // 100MB limit
603
-
604
- async open() {
605
- return new Promise((resolve, reject) => {
606
- const request = indexedDB.open(this.dbName, 1);
607
- request.onerror = () => reject(request.error);
608
- request.onsuccess = () => resolve(request.result);
609
- request.onupgradeneeded = (e) => {
610
- const db = e.target.result;
611
- if (!db.objectStoreNames.contains(this.storeName)) {
612
- db.createObjectStore(this.storeName);
613
- }
614
- };
615
- });
616
- },
617
-
618
- async get(url) {
619
- try {
620
- const db = await this.open();
621
- const record = await new Promise((resolve, reject) => {
622
- const tx = db.transaction(this.storeName, 'readonly');
623
- const req = tx.objectStore(this.storeName).get(url);
624
- req.onerror = () => reject(req.error);
625
- req.onsuccess = () => resolve(req.result);
626
- });
627
-
628
- if (!record) return null;
629
-
630
- const data = record.data || record;
631
- if (record.data) {
632
- this.touch(url, record);
633
- }
634
- return data;
635
- } catch (err) {
636
- console.warn('PDFCache get error:', err);
637
- return null;
638
- }
639
- },
640
-
641
- async touch(url, record) {
642
- try {
643
- const db = await this.open();
644
- const tx = db.transaction(this.storeName, 'readwrite');
645
- record.timestamp = Date.now();
646
- tx.objectStore(this.storeName).put(record, url);
647
- } catch(e) {}
648
- },
649
-
650
- async put(url, data) {
651
- try {
652
- const newSize = data.byteLength;
653
- const db = await this.open();
654
- const tx = db.transaction(this.storeName, 'readwrite');
655
- const store = tx.objectStore(this.storeName);
656
-
657
- let currentSize = 0;
658
- const entries = [];
659
-
660
- await new Promise((resolve) => {
661
- const req = store.openCursor();
662
- req.onsuccess = (e) => {
663
- const cursor = e.target.result;
664
- if (cursor) {
665
- if (cursor.key !== url) {
666
- const val = cursor.value;
667
- const itemSize = (val.data || val).byteLength || 0;
668
- const itemTime = val.timestamp || 0;
669
- currentSize += itemSize;
670
- entries.push({ key: cursor.key, size: itemSize, timestamp: itemTime });
671
- }
672
- cursor.continue();
673
- } else {
674
- resolve();
675
- }
676
- };
677
- req.onerror = () => resolve();
678
  });
679
-
680
- if (currentSize + newSize > this.MAX_TOTAL_SIZE) {
681
- entries.sort((a, b) => a.timestamp - b.timestamp);
682
- while (entries.length > 0 && (currentSize + newSize > this.MAX_TOTAL_SIZE)) {
683
- const entry = entries.shift();
684
- store.delete(entry.key);
685
- currentSize -= entry.size;
686
- console.log(`[PDFCache] Evicting ${entry.key} to free space.`);
687
- }
688
- }
689
-
690
- const record = { data: data, timestamp: Date.now() };
691
- store.put(record, url);
692
-
693
- return new Promise((resolve) => {
694
- tx.oncomplete = () => resolve();
695
- tx.onerror = () => resolve();
696
- });
697
-
698
- } catch (err) {
699
- console.warn('PDFCache put error:', err);
700
- }
701
- }
702
- };
703
-
704
- function showToast() {
705
- elements.toast.classList.add('show');
706
- setTimeout(hideToast, 4000);
707
- }
708
-
709
- function hideToast() {
710
- elements.toast.classList.remove('show');
711
- }
712
-
713
- window.hideToast = hideToast;
714
-
715
- // ==========================================
716
- // LOADING
717
- // ==========================================
718
-
719
- async function loadPDF() {
720
- if (typeof pdfjsLib === 'undefined') return;
721
-
722
- elements.loadingBar.style.display = 'block';
723
- elements.progressBar.style.width = '10%';
724
-
725
- try {
726
- let data = await PDFCache.get(config.url);
727
- let isCached = !!data;
728
-
729
- if (isCached) {
730
- showToast();
731
- }
732
-
733
- const params = {
734
- cMapUrl: 'https://cdn.jsdelivr.net/npm/pdf.js@3.11.174/dist/cmaps/',
735
- cMapPacked: true,
736
- standardFontDataUrl: 'https://cdn.jsdelivr.net/npm/pdf.js@3.11.174/dist/standard_fonts/'
737
- };
738
-
739
- if (data) {
740
- params.data = data;
741
- } else {
742
- params.url = config.url;
743
- }
744
-
745
- const loadingTask = pdfjsLib.getDocument(params);
746
-
747
- loadingTask.onProgress = (p) => {
748
- const percent = Math.round((p.loaded / p.total) * 100);
749
- elements.progressBar.style.width = `${percent}%`;
750
- };
751
-
752
- state.pdfDoc = await loadingTask.promise;
753
-
754
- if (!isCached) {
755
- state.pdfDoc.getData().then(pdfData => {
756
- if (pdfData.byteLength <= 100 * 1024 * 1024) {
757
- PDFCache.put(config.url, pdfData);
758
- } else {
759
- console.warn("PDF too large to cache (>100MB)");
760
- }
761
- }).catch(err => console.warn("Caching failed", err));
762
- }
763
-
764
- elements.pageCount.textContent = 'of ' + state.pdfDoc.numPages;
765
- elements.pageNum.max = state.pdfDoc.numPages;
766
-
767
- elements.progressBar.style.width = '100%';
768
- setTimeout(() => elements.loadingBar.style.display = 'none', 300);
769
-
770
- initViewer();
771
- } catch (error) {
772
- console.error('Error loading PDF:', error);
773
- elements.progressBar.style.background = '#ef4444';
774
- alert('Error loading PDF: ' + error.message);
775
- }
776
- }
777
-
778
- function initViewer() {
779
- elements.viewer.innerHTML = '';
780
- state.pages = [];
781
-
782
- // Get last viewed page for this PDF to restore scroll position
783
- // If just loaded, use storage. If zoomed, use current state.pageNum
784
- const lastPage = state.pageNum > 1 ? state.pageNum : PageMemory.getLastPage(config.pdfIdentifier);
785
-
786
- for (let i = 1; i <= state.pdfDoc.numPages; i++) {
787
- const pageDiv = document.createElement('div');
788
- pageDiv.className = 'page';
789
- pageDiv.setAttribute('data-page-number', i);
790
- pageDiv.id = `pageContainer${i}`;
791
-
792
- // Initial size guess (letter) until rendered
793
- pageDiv.style.width = '612px';
794
- pageDiv.style.height = '792px';
795
-
796
- elements.viewer.appendChild(pageDiv);
797
- state.pages.push(pageDiv);
798
- }
799
-
800
- setupIntersectionObserver();
801
-
802
- if (!state.thumbnailsRendered) {
803
- generateThumbnails();
804
- state.thumbnailsRendered = true;
805
- }
806
-
807
- // Restore scroll position
808
- if (lastPage > 1) {
809
- setTimeout(() => scrollToPage(lastPage), 100);
810
- }
811
- }
812
-
813
- function setupIntersectionObserver() {
814
- const observerOptions = {
815
- root: elements.container,
816
- rootMargin: '500px',
817
- threshold: 0
818
- };
819
-
820
- const observer = new IntersectionObserver((entries) => {
821
- entries.forEach(entry => {
822
- if (entry.isIntersecting) {
823
- const pageDiv = entry.target;
824
- const pageNum = parseInt(pageDiv.getAttribute('data-page-number'));
825
-
826
- if (!pageDiv.getAttribute('data-loaded')) {
827
- renderPage(pageNum, pageDiv);
828
- }
829
- }
830
- });
831
- }, observerOptions);
832
-
833
- state.pages.forEach(page => observer.observe(page));
834
-
835
- const scrollObserver = new IntersectionObserver((entries) => {
836
- entries.forEach(entry => {
837
- if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
838
- const pageNum = parseInt(entry.target.getAttribute('data-page-number'));
839
- updateUIState(pageNum);
840
- PageMemory.saveLastPage(config.pdfIdentifier, pageNum);
841
- }
842
  });
843
- }, { root: elements.container, threshold: 0.5 });
844
-
845
- state.pages.forEach(page => scrollObserver.observe(page));
846
- }
847
-
848
- async function renderPage(num, container) {
849
- container.setAttribute('data-loaded', 'true');
850
-
851
- try {
852
- const page = await state.pdfDoc.getPage(num);
853
-
854
- let scale = config.scale;
855
- const viewport = page.getViewport({ scale: 1 });
856
-
857
- if (config.currentScaleValue === 'page-width') {
858
- const containerWidth = elements.container.clientWidth - 40;
859
- scale = containerWidth / viewport.width;
860
- } else if (config.currentScaleValue === 'page-fit') {
861
- const containerHeight = elements.container.clientHeight - 40;
862
- const containerWidth = elements.container.clientWidth - 40;
863
- const scaleH = containerHeight / viewport.height;
864
- const scaleW = containerWidth / viewport.width;
865
- scale = Math.min(scaleH, scaleW);
866
- }
867
-
868
- const scaledViewport = page.getViewport({ scale: scale });
869
-
870
- container.style.width = `${scaledViewport.width}px`;
871
- container.style.height = `${scaledViewport.height}px`;
872
-
873
- const canvas = document.createElement('canvas');
874
- const context = canvas.getContext('2d');
875
- canvas.height = scaledViewport.height;
876
- canvas.width = scaledViewport.width;
877
-
878
- container.appendChild(canvas);
879
-
880
- await page.render({
881
- canvasContext: context,
882
- viewport: scaledViewport
883
- }).promise;
884
-
885
- } catch (e) {
886
- console.error(e);
887
- }
888
- }
889
-
890
- // ==========================================
891
- // THUMBNAILS
892
- // ==========================================
893
- async function generateThumbnails() {
894
- elements.sidebarContent.innerHTML = '';
895
-
896
- for (let i = 1; i <= state.pdfDoc.numPages; i++) {
897
- const thumbContainer = document.createElement('div');
898
- thumbContainer.className = 'thumbnail';
899
- thumbContainer.id = `thumbnail-${i}`;
900
- thumbContainer.onclick = () => scrollToPage(i);
901
-
902
- const imgContainer = document.createElement('div');
903
- imgContainer.className = 'thumbnail-image-container';
904
- imgContainer.id = `thumb-img-${i}`;
905
-
906
- imgContainer.style.width = '100px';
907
- imgContainer.style.height = '130px';
908
-
909
- const label = document.createElement('div');
910
- label.className = 'thumbnail-label';
911
- label.textContent = i;
912
-
913
- thumbContainer.appendChild(imgContainer);
914
- thumbContainer.appendChild(label);
915
- elements.sidebarContent.appendChild(thumbContainer);
916
- }
917
-
918
- renderThumbnailsQueue();
919
- }
920
-
921
- async function renderThumbnailsQueue() {
922
- for (let i = 1; i <= state.pdfDoc.numPages; i++) {
923
- await renderSingleThumbnail(i);
924
- }
925
- }
926
-
927
- async function renderSingleThumbnail(i) {
928
- try {
929
- const page = await state.pdfDoc.getPage(i);
930
- const viewport = page.getViewport({ scale: 0.2 });
931
- const canvas = document.createElement('canvas');
932
- canvas.className = 'thumbnail-canvas';
933
- canvas.height = viewport.height;
934
- canvas.width = viewport.width;
935
-
936
- const context = canvas.getContext('2d');
937
- await page.render({
938
- canvasContext: context,
939
- viewport: viewport
940
- }).promise;
941
-
942
- const container = document.getElementById(`thumb-img-${i}`);
943
- if (container) {
944
- container.innerHTML = '';
945
- container.style.width = 'auto';
946
- container.style.height = 'auto';
947
- container.appendChild(canvas);
948
- }
949
- } catch(e) { console.warn("Thumb render error", e); }
950
- }
951
-
952
- function scrollToPage(num) {
953
- if (num < 1 || num > state.pdfDoc.numPages) return;
954
- const pageDiv = document.getElementById(`pageContainer${num}`);
955
- if (pageDiv) {
956
- elements.container.scrollTop = pageDiv.offsetTop;
957
- }
958
- }
959
-
960
- function updateUIState(pageNum) {
961
- if (state.pageNum === pageNum) return;
962
- state.pageNum = pageNum;
963
- elements.pageNum.value = pageNum;
964
-
965
- document.querySelectorAll('.thumbnail').forEach(t => t.classList.remove('selected'));
966
- const thumb = document.getElementById(`thumbnail-${pageNum}`);
967
- if (thumb) {
968
- thumb.classList.add('selected');
969
-
970
- const thumbTop = thumb.offsetTop;
971
- const thumbHeight = thumb.clientHeight;
972
- const containerHeight = elements.sidebarContent.clientHeight;
973
- const containerScroll = elements.sidebarContent.scrollTop;
974
-
975
- if (thumbTop < containerScroll) {
976
- elements.sidebarContent.scrollTop = thumbTop - 10;
977
- }
978
- else if (thumbTop + thumbHeight > containerScroll + containerHeight) {
979
- elements.sidebarContent.scrollTop = thumbTop + thumbHeight - containerHeight + 10;
980
- }
981
- }
982
- }
983
-
984
- function changeScale(newScaleVal) {
985
- config.currentScaleValue = newScaleVal;
986
- if (!isNaN(parseFloat(newScaleVal))) {
987
- config.scale = parseFloat(newScaleVal);
988
- }
989
- initViewer();
990
- }
991
-
992
- // Event Listeners
993
- if (elements.prev) elements.prev.addEventListener('click', () => scrollToPage(state.pageNum - 1));
994
- if (elements.next) elements.next.addEventListener('click', () => scrollToPage(state.pageNum + 1));
995
-
996
- if (elements.pageNum) elements.pageNum.addEventListener('change', (e) => {
997
- let num = parseInt(e.target.value);
998
- if (num >= 1 && num <= state.pdfDoc.numPages) {
999
- scrollToPage(num);
1000
- }
1001
- });
1002
-
1003
- if (elements.sidebarToggle) elements.sidebarToggle.addEventListener('click', () => {
1004
- elements.sidebar.classList.toggle('open');
1005
- elements.sidebarToggle.classList.toggle('toggled');
1006
- });
1007
-
1008
- if (elements.zoomIn) elements.zoomIn.addEventListener('click', () => {
1009
- let current = parseFloat(elements.scaleSelect.value);
1010
- if(isNaN(current)) current = config.scale;
1011
- const newScale = (Math.floor(current * 4) + 1) / 4; // Step to next 0.25 increment
1012
-
1013
- // Check if current was page-fit or similar
1014
- if(isNaN(parseFloat(elements.scaleSelect.value))) {
1015
- config.scale = config.scale + 0.25;
1016
- } else {
1017
- config.scale = newScale;
1018
- }
1019
-
1020
- config.currentScaleValue = config.scale.toString();
1021
-
1022
- // Sync Dropdown
1023
- PinchZoom.updateScaleDropdown(config.scale);
1024
- initViewer();
1025
- });
1026
-
1027
- if (elements.zoomOut) elements.zoomOut.addEventListener('click', () => {
1028
- if (config.scale <= 0.25) return;
1029
-
1030
- let current = parseFloat(elements.scaleSelect.value);
1031
- if(isNaN(current)) current = config.scale;
1032
- const newScale = (Math.ceil(current * 4) - 1) / 4; // Step to prev 0.25 increment
1033
-
1034
- if(isNaN(parseFloat(elements.scaleSelect.value))) {
1035
- config.scale = Math.max(0.25, config.scale - 0.25);
1036
- } else {
1037
- config.scale = Math.max(0.25, newScale);
1038
- }
1039
-
1040
- config.currentScaleValue = config.scale.toString();
1041
-
1042
- // Sync Dropdown
1043
- PinchZoom.updateScaleDropdown(config.scale);
1044
- initViewer();
1045
- });
1046
-
1047
- if (elements.scaleSelect) elements.scaleSelect.addEventListener('change', (e) => {
1048
- changeScale(e.target.value);
1049
- });
1050
-
1051
- if (elements.print) elements.print.addEventListener('click', () => window.print());
1052
-
1053
- if (elements.download) elements.download.addEventListener('click', () => {
1054
- const link = document.createElement('a');
1055
- link.href = config.url;
1056
- link.download = "document.pdf";
1057
- link.click();
1058
  });
1059
-
1060
- // Initialize Pinch Zoom
1061
- PinchZoom.init();
1062
-
1063
- // Start loading
1064
- if (typeof pdfjsLib !== 'undefined') {
1065
- loadPDF();
1066
- } else {
1067
- window.addEventListener('load', loadPDF);
1068
- }
1069
  </script>
1070
  </body>
1071
- </html>
 
1
  <!DOCTYPE html>
2
+ <html>
3
  <head>
 
 
4
  <title>{{ pdf_title }}</title>
5
+ <meta charset="utf-8"/>
6
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
8
  <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10
+ html, body { height: 100%; background: #1a1a1a; }
11
+ #adobe-dc-view { height: 100%; width: 100%; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  </style>
13
  </head>
14
  <body>
15
+ <div id="adobe-dc-view"></div>
16
+ <script src="https://acrobatservices.adobe.com/view-sdk/viewer.js"></script>
17
+ <script type="text/javascript">
18
+ document.addEventListener("adobe_dc_view_sdk.ready", function() {
19
+ var adobeDCView = new AdobeDC.View({
20
+ clientId: "13f2edf18b654344bbc7e4d7b2583d4a",
21
+ divId: "adobe-dc-view"
22
+ });
23
+
24
+ // Fetch PDF and pass as ArrayBuffer
25
+ fetch("{{ pdf_url }}", { credentials: 'include' })
26
+ .then(response => response.arrayBuffer())
27
+ .then(buffer => {
28
+ adobeDCView.previewFile({
29
+ content: { promise: Promise.resolve(buffer) },
30
+ metaData: { fileName: "{{ pdf_title }}" }
31
+ }, {
32
+ embedMode: "FULL_WINDOW",
33
+ showAnnotationTools: false,
34
+ showDownloadPDF: true,
35
+ showPrintPDF: true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  });
37
+ })
38
+ .catch(err => {
39
+ console.error("Fetch error:", err);
40
+ // Fallback to URL
41
+ adobeDCView.previewFile({
42
+ content: { location: { url: window.location.origin + "{{ pdf_url }}" } },
43
+ metaData: { fileName: "{{ pdf_title }}" }
44
+ }, { showAnnotationTools: false });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  });
 
 
 
 
 
 
 
 
 
 
47
  </script>
48
  </body>
49
+ </html>