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

feat: modernize drive sync UI and add public access links

Browse files

- Redesign drive_manager.html with modern card-based layout
- Redesign drive_browser.html with grid/list view toggle
- Add token-based public access for sharing files without auth
- Add /drive/public/<id>/list.txt endpoint for curl-friendly listings
- Add /drive/public/<id>/download endpoint for direct file downloads
- Add quick copy buttons for file links in browser view
- Add share modal with generated public URLs

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

drive_routes.py CHANGED
@@ -357,3 +357,217 @@ def api_open_file(file_id):
357
  def serve_cache_file(filename):
358
  cache_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'drive_cache')
359
  return send_from_directory(cache_dir, filename)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  def serve_cache_file(filename):
358
  cache_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'drive_cache')
359
  return send_from_directory(cache_dir, filename)
360
+
361
+
362
+ # ==================== PUBLIC ACCESS ROUTES (No Auth Required) ====================
363
+
364
+ def generate_public_token(source_id, user_id):
365
+ """Generate a simple token for public access."""
366
+ import hashlib
367
+ secret = current_app.config.get('SECRET_KEY', 'default-secret')
368
+ data = f"{source_id}:{user_id}:{secret}"
369
+ return hashlib.sha256(data.encode()).hexdigest()[:32]
370
+
371
+ def verify_public_token(source_id, token):
372
+ """Verify public access token and return the source if valid."""
373
+ conn = get_db_connection()
374
+ source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (source_id,)).fetchone()
375
+ conn.close()
376
+ if not source:
377
+ return None
378
+ expected_token = generate_public_token(source_id, source['user_id'])
379
+ if token == expected_token:
380
+ return dict(source)
381
+ return None
382
+
383
+ @drive_bp.route('/drive/public/<int:source_id>/token')
384
+ @login_required
385
+ def get_public_token(source_id):
386
+ """Generate a public access token for a source (owner only)."""
387
+ conn = get_db_connection()
388
+ source = conn.execute('SELECT * FROM drive_sources WHERE id = ?', (source_id,)).fetchone()
389
+ conn.close()
390
+ if not source or source['user_id'] != current_user.id:
391
+ return jsonify({'error': 'Unauthorized'}), 403
392
+ token = generate_public_token(source_id, current_user.id)
393
+ return jsonify({'token': token})
394
+
395
+ @drive_bp.route('/drive/public/<int:source_id>/list.txt')
396
+ def public_list_files(source_id):
397
+ """
398
+ Public endpoint: Returns a plain text list of all files in the synced folder.
399
+ Perfect for curl/wget scripts.
400
+
401
+ Usage: curl https://host/drive/public/123/list.txt?token=abc123
402
+ """
403
+ token = request.args.get('token')
404
+ if not token:
405
+ return "Missing token parameter. Add ?token=YOUR_TOKEN to the URL.", 401
406
+
407
+ source = verify_public_token(source_id, token)
408
+ if not source:
409
+ return "Invalid or expired token.", 403
410
+
411
+ base_path = get_sync_folder_path(source['local_path'])
412
+ if not os.path.exists(base_path):
413
+ return "Source not synced yet.", 404
414
+
415
+ subpath = request.args.get('path', '')
416
+ current_path = os.path.join(base_path, subpath) if subpath else base_path
417
+
418
+ if not os.path.exists(current_path):
419
+ return "Path not found.", 404
420
+
421
+ # Collect all files recursively
422
+ files_list = []
423
+ base_url = request.host_url.rstrip('/')
424
+
425
+ def scan_directory(dir_path, prefix=''):
426
+ try:
427
+ for entry in sorted(os.scandir(dir_path), key=lambda e: e.name.lower()):
428
+ rel_path = os.path.join(prefix, entry.name) if prefix else entry.name
429
+ if entry.is_dir():
430
+ files_list.append(f"[DIR] {rel_path}/")
431
+ scan_directory(entry.path, rel_path)
432
+ else:
433
+ # Add download URL for each file
434
+ download_url = f"{base_url}/drive/public/{source_id}/download/{rel_path}?token={token}"
435
+ size = entry.stat().st_size
436
+ size_str = format_file_size(size)
437
+ files_list.append(f"{rel_path}\t{size_str}\t{download_url}")
438
+ except Exception as e:
439
+ files_list.append(f"[ERROR] {str(e)}")
440
+
441
+ scan_directory(current_path)
442
+
443
+ # Build response
444
+ header = f"# Directory listing for: {source['name']}\n"
445
+ header += f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
446
+ header += f"# Format: filename\\tsize\\tdownload_url\n"
447
+ header += "#" + "=" * 60 + "\n\n"
448
+
449
+ content = header + "\n".join(files_list)
450
+
451
+ from flask import Response
452
+ return Response(content, mimetype='text/plain', headers={
453
+ 'Content-Disposition': f'inline; filename="{source["name"]}_files.txt"'
454
+ })
455
+
456
+ def format_file_size(size_bytes):
457
+ """Format bytes to human readable string."""
458
+ for unit in ['B', 'KB', 'MB', 'GB']:
459
+ if size_bytes < 1024:
460
+ return f"{size_bytes:.1f}{unit}"
461
+ size_bytes /= 1024
462
+ return f"{size_bytes:.1f}TB"
463
+
464
+ @drive_bp.route('/drive/public/<int:source_id>/download')
465
+ @drive_bp.route('/drive/public/<int:source_id>/download/<path:filepath>')
466
+ def public_download_file(source_id, filepath=None):
467
+ """
468
+ Public endpoint: Download a file without authentication.
469
+
470
+ Usage:
471
+ - Single file source: /drive/public/123/download?token=abc123
472
+ - Specific file: /drive/public/123/download/subfolder/file.pdf?token=abc123
473
+ """
474
+ token = request.args.get('token')
475
+ if not token:
476
+ return "Missing token parameter. Add ?token=YOUR_TOKEN to the URL.", 401
477
+
478
+ source = verify_public_token(source_id, token)
479
+ if not source:
480
+ return "Invalid or expired token.", 403
481
+
482
+ base_path = get_sync_folder_path(source['local_path'])
483
+
484
+ # If no filepath specified, try to find the first/only file
485
+ if not filepath:
486
+ if source['source_type'] == 'file':
487
+ # For file sources, download using gdown if not cached
488
+ if not os.path.exists(base_path):
489
+ os.makedirs(base_path)
490
+
491
+ # Find existing file or download
492
+ files = [f for f in os.listdir(base_path) if os.path.isfile(os.path.join(base_path, f))] if os.path.exists(base_path) else []
493
+ if files:
494
+ filepath = files[0]
495
+ else:
496
+ # Try to download
497
+ try:
498
+ output_path = os.path.join(base_path, 'document.pdf')
499
+ gdown.download(url=source['url'], output=output_path, quiet=True, fuzzy=True)
500
+ filepath = 'document.pdf'
501
+ except Exception as e:
502
+ return f"Error downloading file: {e}", 500
503
+ else:
504
+ return "Filepath required for folder sources. Use /download/path/to/file.pdf", 400
505
+
506
+ full_path = os.path.join(base_path, filepath)
507
+
508
+ # Security: Ensure path doesn't escape base
509
+ if not os.path.abspath(full_path).startswith(os.path.abspath(base_path)):
510
+ return "Invalid path.", 403
511
+
512
+ if not os.path.exists(full_path):
513
+ return "File not found.", 404
514
+
515
+ if not os.path.isfile(full_path):
516
+ return "Not a file.", 400
517
+
518
+ # Serve the file
519
+ directory = os.path.dirname(full_path)
520
+ filename = os.path.basename(full_path)
521
+
522
+ # Check if inline viewing is requested
523
+ inline = request.args.get('inline', '0') == '1'
524
+
525
+ return send_from_directory(
526
+ directory,
527
+ filename,
528
+ as_attachment=not inline
529
+ )
530
+
531
+ @drive_bp.route('/drive/public/<int:source_id>/browse')
532
+ def public_browse(source_id):
533
+ """
534
+ Public endpoint: Browse files in a synced folder (JSON response).
535
+ """
536
+ token = request.args.get('token')
537
+ if not token:
538
+ return jsonify({'error': 'Missing token'}), 401
539
+
540
+ source = verify_public_token(source_id, token)
541
+ if not source:
542
+ return jsonify({'error': 'Invalid token'}), 403
543
+
544
+ base_path = get_sync_folder_path(source['local_path'])
545
+ subpath = request.args.get('path', '')
546
+ current_path = os.path.join(base_path, subpath) if subpath else base_path
547
+
548
+ if not os.path.exists(current_path):
549
+ return jsonify({'error': 'Path not found'}), 404
550
+
551
+ items = []
552
+ base_url = request.host_url.rstrip('/')
553
+
554
+ try:
555
+ for entry in sorted(os.scandir(current_path), key=lambda e: (not e.is_dir(), e.name.lower())):
556
+ rel_path = os.path.join(subpath, entry.name) if subpath else entry.name
557
+ item = {
558
+ 'name': entry.name,
559
+ 'type': 'folder' if entry.is_dir() else 'file',
560
+ 'path': rel_path
561
+ }
562
+ if entry.is_file():
563
+ item['size'] = entry.stat().st_size
564
+ item['download_url'] = f"{base_url}/drive/public/{source_id}/download/{rel_path}?token={token}"
565
+ items.append(item)
566
+ except Exception as e:
567
+ return jsonify({'error': str(e)}), 500
568
+
569
+ return jsonify({
570
+ 'source': source['name'],
571
+ 'path': subpath or '/',
572
+ 'items': items
573
+ })
templates/drive_browser.html CHANGED
@@ -1,219 +1,467 @@
1
  {% extends "base.html" %}
2
 
3
- {% block title %}Browse Drive{% endblock %}
4
 
5
  {% block styles %}
6
  <style>
7
- .view-hidden { display: none !important; }
8
- .card-checkbox {
9
- position: absolute;
10
- top: 10px;
11
- right: 10px;
12
- z-index: 10;
13
- width: 18px;
14
- height: 18px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
- .list-view-row { cursor: pointer; }
18
- .list-view-row:hover { background-color: rgba(255,255,255,0.05); }
19
- .breadcrumb { margin-bottom: 0; }
20
  </style>
21
  {% endblock %}
22
 
23
  {% block content %}
24
- <div class="container-fluid mt-4" style="width: 90%; margin: auto;">
25
-
26
- <!-- Toolbar -->
27
- <div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-3">
28
  <nav aria-label="breadcrumb">
29
  <ol class="breadcrumb">
30
- <li class="breadcrumb-item"><a href="/drive_manager">Drives</a></li>
 
 
 
31
  <li class="breadcrumb-item"><a href="/drive/browse/{{ source.id }}">{{ source.name }}</a></li>
 
32
  {% for crumb in breadcrumbs %}
33
- <li class="breadcrumb-item active">{{ crumb.name }}</li>
34
  {% endfor %}
35
  </ol>
36
  </nav>
37
 
38
- <div class="d-flex gap-2">
39
- <div class="btn-group" role="group">
40
- <button type="button" class="btn btn-outline-secondary active" id="btn-view-grid" onclick="switchView('grid')" title="Grid View">
41
- <i class="bi bi-grid-fill"></i>
42
  </button>
43
- <button type="button" class="btn btn-outline-secondary" id="btn-view-list" onclick="switchView('list')" title="List View">
44
- <i class="bi bi-list-ul"></i>
45
  </button>
46
  </div>
47
  </div>
48
  </div>
49
 
50
- <!-- Bulk Actions Bar -->
51
- <div class="d-flex align-items-center mb-3 p-2 bg-dark border border-secondary rounded">
52
- <div class="form-check ms-2">
53
- <input class="form-check-input" type="checkbox" id="select-all">
54
- <label class="form-check-label user-select-none" for="select-all">Select All</label>
 
 
 
55
  </div>
56
- <div class="ms-auto" id="bulk-actions" style="visibility: hidden;">
57
  <span class="text-muted me-2 small"><span id="selected-count">0</span> selected</span>
58
- <!-- Add bulk actions here later if needed, e.g. Download -->
59
- <button class="btn btn-sm btn-outline-light" disabled>Bulk Actions (Coming Soon)</button>
60
  </div>
61
  </div>
62
 
63
  <!-- Grid View -->
64
- <div id="view-grid" class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
65
  {% for item in items %}
66
- <div class="col item-entry" data-id="{{ item.path }}">
67
- {% if item.type == 'folder' %}
68
- {% set link = '/drive/api/browse/' + item.path if is_api else '/drive/browse/' + source.id|string + '/' + item.path %}
69
- {% set icon = 'bi-folder-fill text-warning' %}
70
- {% else %}
71
- {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
72
- {% if item.type == 'pdf' %}
73
- {% set icon = 'bi-file-earmark-pdf-fill text-danger' %}
74
- {% else %}
75
- {% set icon = 'bi-file-earmark-image-fill text-info' %}
76
- {% endif %}
77
- {% endif %}
 
 
 
 
 
78
 
79
- <div class="card h-100 bg-dark text-white border-secondary position-relative">
80
- <input type="checkbox" class="form-check-input card-checkbox item-check" value="{{ item.path }}">
81
- <div class="card-body text-center" onclick="navigate('{{ link }}')" style="cursor: pointer;">
82
- <i class="bi {{ icon }} fs-1"></i>
83
- <h6 class="card-title mt-2 text-truncate" title="{{ item.name }}">{{ item.name }}</h6>
 
 
 
 
 
 
 
84
  </div>
85
  </div>
86
  </div>
87
  {% else %}
88
- <div class="col-12 text-center text-muted">
89
- <p>Empty folder or not synced yet.</p>
 
 
 
 
 
 
 
90
  </div>
91
  {% endfor %}
92
  </div>
93
 
94
  <!-- List View -->
95
- <div id="view-list" class="table-responsive view-hidden">
96
- <table class="table table-dark table-hover align-middle border-secondary">
97
- <thead>
98
- <tr>
99
- <th style="width: 40px;"></th>
100
- <th style="width: 50px;">Type</th>
101
- <th>Name</th>
102
- <th style="width: 100px;">Action</th>
103
- </tr>
104
- </thead>
105
- <tbody>
106
- {% for item in items %}
107
- {% if item.type == 'folder' %}
108
- {% set link = '/drive/api/browse/' + item.path if is_api else '/drive/browse/' + source.id|string + '/' + item.path %}
109
- {% set icon = 'bi-folder-fill text-warning' %}
110
- {% else %}
111
- {% set link = '/drive/api/open/' + item.path if is_api else '/drive/file/' + source.id|string + '/' + item.path %}
112
- {% if item.type == 'pdf' %}
113
- {% set icon = 'bi-file-earmark-pdf-fill text-danger' %}
114
- {% else %}
115
- {% set icon = 'bi-file-earmark-image-fill text-info' %}
116
- {% endif %}
117
- {% endif %}
118
-
119
- <tr class="item-entry">
120
- <td><input type="checkbox" class="form-check-input item-check" value="{{ item.path }}"></td>
121
- <td class="text-center"><i class="bi {{ icon }} fs-5"></i></td>
122
- <td onclick="navigate('{{ link }}')" style="cursor: pointer;">
123
- {{ item.name }}
124
- </td>
125
- <td>
126
- <button class="btn btn-sm btn-outline-secondary" onclick="navigate('{{ link }}')">
127
- Open
128
- </button>
129
- </td>
130
- </tr>
131
- {% endfor %}
132
- </tbody>
133
- </table>
134
  </div>
135
  </div>
136
 
137
- <!-- Loader -->
138
- <div id="loader-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:9999; align-items:center; justify-content:center; color:white; flex-direction:column;">
139
- <div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;"></div>
140
- <div class="mt-3 fs-5">Downloading & Opening...</div>
141
  </div>
142
 
143
- <script>
144
- // Navigation & Loader
145
- function showLoader() {
146
- document.getElementById('loader-overlay').style.display = 'flex';
147
- }
 
 
 
 
 
 
 
148
 
 
 
 
149
  function navigate(url) {
150
- showLoader();
151
  window.location.href = url;
152
  }
153
 
154
- // Fix Back Button Loader Issue
155
- window.addEventListener('pageshow', function(event) {
156
- document.getElementById('loader-overlay').style.display = 'none';
157
  });
158
 
159
- // View Switching
160
  function switchView(view) {
161
- const grid = document.getElementById('view-grid');
162
- const list = document.getElementById('view-list');
163
- const btnGrid = document.getElementById('btn-view-grid');
164
- const btnList = document.getElementById('btn-view-list');
165
 
166
  if (view === 'list') {
167
- grid.classList.add('view-hidden');
168
- list.classList.remove('view-hidden');
169
  btnList.classList.add('active');
170
  btnGrid.classList.remove('active');
171
- localStorage.setItem('driveViewMode', 'list');
172
  } else {
173
- list.classList.add('view-hidden');
174
- grid.classList.remove('view-hidden');
175
  btnGrid.classList.add('active');
176
  btnList.classList.remove('active');
177
- localStorage.setItem('driveViewMode', 'grid');
178
  }
 
 
179
  }
180
 
181
- // Checkbox Logic
182
  const selectAll = document.getElementById('select-all');
183
- const checkboxes = document.querySelectorAll('.item-check');
184
  const bulkActions = document.getElementById('bulk-actions');
185
  const selectedCount = document.getElementById('selected-count');
186
 
187
- function updateSelection() {
188
- const checked = document.querySelectorAll('.item-check:checked');
189
- const count = checked.length;
190
- selectedCount.textContent = count;
191
- bulkActions.style.visibility = count > 0 ? 'visible' : 'hidden';
192
-
193
- // Sync select all checkbox state
194
- if (count === 0) selectAll.checked = false;
195
- else if (count === checkboxes.length) selectAll.checked = true;
196
- else selectAll.indeterminate = true;
197
- }
198
-
199
- selectAll.addEventListener('change', (e) => {
200
- const isChecked = e.target.checked;
201
- checkboxes.forEach(cb => {
202
- // Only toggle visible items if we had filtering, but here we toggle all
203
- cb.checked = isChecked;
204
- });
205
- updateSelection();
206
- });
207
-
208
- checkboxes.forEach(cb => {
209
- cb.addEventListener('change', updateSelection);
210
- cb.addEventListener('click', (e) => e.stopPropagation()); // Prevent card click
211
- });
212
-
213
- // Init
214
  document.addEventListener('DOMContentLoaded', () => {
215
  const savedView = localStorage.getItem('driveViewMode') || 'grid';
216
  switchView(savedView);
217
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  </script>
219
- {% endblock %}
 
1
  {% extends "base.html" %}
2
 
3
+ {% block title %}{{ source.name }} - Drive Browser{% endblock %}
4
 
5
  {% block styles %}
6
  <style>
7
+ /* Modern Drive Browser Styles */
8
+ .browser-header {
9
+ background: var(--bg-card);
10
+ border: 1px solid var(--border-subtle);
11
+ border-radius: 12px;
12
+ padding: 16px 20px;
13
+ margin-bottom: 20px;
14
+ }
15
+
16
+ .breadcrumb {
17
+ margin-bottom: 0;
18
+ padding: 0;
19
+ background: transparent;
20
+ }
21
+
22
+ .breadcrumb-item a {
23
+ color: var(--accent-primary);
24
+ text-decoration: none;
25
+ }
26
+
27
+ .breadcrumb-item a:hover {
28
+ text-decoration: underline;
29
+ }
30
+
31
+ .breadcrumb-item.active {
32
+ color: var(--text-primary);
33
+ }
34
+
35
+ .view-toggle .btn {
36
+ padding: 8px 12px;
37
+ border-radius: 8px;
38
+ }
39
+
40
+ .view-toggle .btn.active {
41
+ background: var(--accent-primary);
42
+ border-color: var(--accent-primary);
43
+ color: white;
44
+ }
45
+
46
+ /* File Cards */
47
+ .file-card {
48
+ background: var(--bg-card);
49
+ border: 1px solid var(--border-subtle);
50
+ border-radius: 12px;
51
+ transition: all 0.2s ease;
52
+ cursor: pointer;
53
+ overflow: hidden;
54
+ }
55
+
56
+ .file-card:hover {
57
+ transform: translateY(-4px);
58
+ border-color: var(--accent-primary);
59
+ box-shadow: 0 8px 20px rgba(13, 110, 253, 0.2);
60
+ }
61
+
62
+ .file-card .card-body {
63
+ padding: 20px;
64
+ text-align: center;
65
+ position: relative;
66
+ }
67
+
68
+ .file-card .copy-btn {
69
+ position: absolute;
70
+ top: 8px;
71
+ right: 8px;
72
+ width: 28px;
73
+ height: 28px;
74
+ border-radius: 6px;
75
+ background: rgba(255,255,255,0.1);
76
+ border: none;
77
+ color: var(--text-muted);
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ opacity: 0;
82
+ transition: all 0.2s ease;
83
+ z-index: 10;
84
+ }
85
+
86
+ .file-card:hover .copy-btn {
87
+ opacity: 1;
88
+ }
89
+
90
+ .file-card .copy-btn:hover {
91
+ background: var(--accent-primary);
92
+ color: white;
93
+ transform: scale(1.1);
94
+ }
95
+
96
+ .file-card .copy-btn.copied {
97
+ background: #198754;
98
+ color: white;
99
+ }
100
+
101
+ .file-icon {
102
+ width: 64px;
103
+ height: 64px;
104
+ border-radius: 16px;
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ font-size: 2rem;
109
+ margin: 0 auto 12px;
110
+ }
111
+
112
+ .file-icon.folder {
113
+ background: linear-gradient(135deg, rgba(255,193,7,0.2) 0%, rgba(255,152,0,0.2) 100%);
114
+ color: #ffc107;
115
+ }
116
+
117
+ .file-icon.pdf {
118
+ background: linear-gradient(135deg, rgba(220,53,69,0.2) 0%, rgba(255,87,34,0.2) 100%);
119
+ color: #dc3545;
120
+ }
121
+
122
+ .file-icon.image {
123
+ background: linear-gradient(135deg, rgba(13,202,240,0.2) 0%, rgba(32,201,151,0.2) 100%);
124
+ color: #0dcaf0;
125
+ }
126
+
127
+ .file-icon.file {
128
+ background: linear-gradient(135deg, rgba(108,117,125,0.2) 0%, rgba(73,80,87,0.2) 100%);
129
+ color: #6c757d;
130
+ }
131
+
132
+ .file-name {
133
+ font-size: 0.9rem;
134
+ font-weight: 500;
135
+ word-break: break-word;
136
+ line-height: 1.3;
137
+ }
138
+
139
+ /* List View */
140
+ .list-view {
141
+ display: none;
142
+ }
143
+
144
+ .list-view.active {
145
+ display: block;
146
+ }
147
+
148
+ .grid-view {
149
+ display: flex;
150
+ }
151
+
152
+ .grid-view.hidden {
153
+ display: none !important;
154
+ }
155
+
156
+ .file-row {
157
+ background: var(--bg-card);
158
+ border: 1px solid var(--border-subtle);
159
+ border-radius: 8px;
160
+ margin-bottom: 8px;
161
+ padding: 12px 16px;
162
  cursor: pointer;
163
+ transition: all 0.2s ease;
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 12px;
167
+ }
168
+
169
+ .file-row:hover {
170
+ background: rgba(13, 110, 253, 0.1);
171
+ border-color: var(--accent-primary);
172
+ }
173
+
174
+ .file-row .file-icon {
175
+ width: 40px;
176
+ height: 40px;
177
+ font-size: 1.2rem;
178
+ margin: 0;
179
+ flex-shrink: 0;
180
+ }
181
+
182
+ .file-row .file-name {
183
+ flex-grow: 1;
184
+ text-align: left;
185
+ }
186
+
187
+ .file-row .copy-btn {
188
+ width: 32px;
189
+ height: 32px;
190
+ border-radius: 6px;
191
+ background: rgba(255,255,255,0.1);
192
+ border: none;
193
+ color: var(--text-muted);
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ transition: all 0.2s ease;
198
+ flex-shrink: 0;
199
+ }
200
+
201
+ .file-row .copy-btn:hover {
202
+ background: var(--accent-primary);
203
+ color: white;
204
+ }
205
+
206
+ .file-row .copy-btn.copied {
207
+ background: #198754;
208
+ color: white;
209
+ }
210
+
211
+ /* Empty State */
212
+ .empty-state {
213
+ text-align: center;
214
+ padding: 80px 20px;
215
+ color: var(--text-muted);
216
+ }
217
+
218
+ .empty-state i {
219
+ font-size: 4rem;
220
+ opacity: 0.3;
221
+ margin-bottom: 16px;
222
+ }
223
+
224
+ /* Loader */
225
+ .loader-overlay {
226
+ display: none;
227
+ position: fixed;
228
+ inset: 0;
229
+ background: rgba(0,0,0,0.8);
230
+ z-index: 9999;
231
+ align-items: center;
232
+ justify-content: center;
233
+ flex-direction: column;
234
+ color: white;
235
+ }
236
+
237
+ .loader-overlay.active {
238
+ display: flex;
239
+ }
240
+
241
+ .selection-bar {
242
+ background: var(--bg-card);
243
+ border: 1px solid var(--border-subtle);
244
+ border-radius: 8px;
245
+ padding: 8px 16px;
246
+ margin-bottom: 16px;
247
  }
 
 
 
248
  </style>
249
  {% endblock %}
250
 
251
  {% block content %}
252
+ <div class="container-fluid px-4 py-4">
253
+ <!-- Header with Breadcrumb -->
254
+ <div class="browser-header d-flex justify-content-between align-items-center flex-wrap gap-3">
 
255
  <nav aria-label="breadcrumb">
256
  <ol class="breadcrumb">
257
+ <li class="breadcrumb-item"><a href="/drive_manager"><i class="bi bi-cloud me-1"></i>Drives</a></li>
258
+ {% if source.id == 'api' %}
259
+ <li class="breadcrumb-item active">{{ source.name }}</li>
260
+ {% else %}
261
  <li class="breadcrumb-item"><a href="/drive/browse/{{ source.id }}">{{ source.name }}</a></li>
262
+ {% endif %}
263
  {% for crumb in breadcrumbs %}
264
+ <li class="breadcrumb-item active">{{ crumb.name }}</li>
265
  {% endfor %}
266
  </ol>
267
  </nav>
268
 
269
+ <div class="d-flex gap-2 align-items-center">
270
+ <div class="btn-group view-toggle" role="group">
271
+ <button type="button" class="btn btn-outline-secondary active" id="btn-grid" onclick="switchView('grid')">
272
+ <i class="bi bi-grid-3x3-gap-fill"></i>
273
  </button>
274
+ <button type="button" class="btn btn-outline-secondary" id="btn-list" onclick="switchView('list')">
275
+ <i class="bi bi-list"></i>
276
  </button>
277
  </div>
278
  </div>
279
  </div>
280
 
281
+ <!-- Selection Bar -->
282
+ <div class="selection-bar d-flex align-items-center justify-content-between">
283
+ <div class="d-flex align-items-center gap-3">
284
+ <div class="form-check">
285
+ <input class="form-check-input" type="checkbox" id="select-all">
286
+ <label class="form-check-label small" for="select-all">Select All</label>
287
+ </div>
288
+ <span class="text-muted small"><span id="item-count">{{ items|length }}</span> items</span>
289
  </div>
290
+ <div id="bulk-actions" style="display: none;">
291
  <span class="text-muted me-2 small"><span id="selected-count">0</span> selected</span>
 
 
292
  </div>
293
  </div>
294
 
295
  <!-- Grid View -->
296
+ <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-3 grid-view" id="grid-view">
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>
326
+ </div>
327
+ <div class="file-name text-truncate" title="{{ item.name }}">{{ item.name }}</div>
328
  </div>
329
  </div>
330
  </div>
331
  {% else %}
332
+ <div class="col-12">
333
+ <div class="empty-state">
334
+ <i class="bi bi-folder2-open d-block"></i>
335
+ <h5>Empty Folder</h5>
336
+ <p class="text-muted">This folder is empty or not synced yet</p>
337
+ <a href="/drive_manager" class="btn btn-outline-primary">
338
+ <i class="bi bi-arrow-left me-1"></i>Back to Drive Manager
339
+ </a>
340
+ </div>
341
  </div>
342
  {% endfor %}
343
  </div>
344
 
345
  <!-- List View -->
346
+ <div class="list-view" id="list-view">
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 }}')">
367
+ <div class="file-icon {{ icon_class }}">
368
+ <i class="bi {{ icon }}"></i>
369
+ </div>
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 %}
 
 
 
 
 
 
379
  </div>
380
  </div>
381
 
382
+ <!-- Loader Overlay -->
383
+ <div class="loader-overlay" id="loader">
384
+ <div class="spinner-border text-primary mb-3" style="width: 3rem; height: 3rem;"></div>
385
+ <div class="fs-5">Opening file...</div>
386
  </div>
387
 
388
+ <!-- Toast -->
389
+ <div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1100">
390
+ <div class="toast align-items-center text-bg-success border-0" id="copyToast" role="alert">
391
+ <div class="d-flex">
392
+ <div class="toast-body">
393
+ <i class="bi bi-check-circle me-2"></i>Link copied!
394
+ </div>
395
+ <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ {% endblock %}
400
 
401
+ {% block scripts %}
402
+ <script>
403
+ // Navigation with loader
404
  function navigate(url) {
405
+ document.getElementById('loader').classList.add('active');
406
  window.location.href = url;
407
  }
408
 
409
+ // Hide loader on back navigation
410
+ window.addEventListener('pageshow', () => {
411
+ document.getElementById('loader').classList.remove('active');
412
  });
413
 
414
+ // View switching
415
  function switchView(view) {
416
+ const gridView = document.getElementById('grid-view');
417
+ const listView = document.getElementById('list-view');
418
+ const btnGrid = document.getElementById('btn-grid');
419
+ const btnList = document.getElementById('btn-list');
420
 
421
  if (view === 'list') {
422
+ gridView.classList.add('hidden');
423
+ listView.classList.add('active');
424
  btnList.classList.add('active');
425
  btnGrid.classList.remove('active');
 
426
  } else {
427
+ gridView.classList.remove('hidden');
428
+ listView.classList.remove('active');
429
  btnGrid.classList.add('active');
430
  btnList.classList.remove('active');
 
431
  }
432
+
433
+ localStorage.setItem('driveViewMode', view);
434
  }
435
 
436
+ // Selection logic
437
  const selectAll = document.getElementById('select-all');
 
438
  const bulkActions = document.getElementById('bulk-actions');
439
  const selectedCount = document.getElementById('selected-count');
440
 
441
+ // Initialize
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  document.addEventListener('DOMContentLoaded', () => {
443
  const savedView = localStorage.getItem('driveViewMode') || 'grid';
444
  switchView(savedView);
445
  });
446
+
447
+ // Copy file link
448
+ function copyFileLink(path, btn) {
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');
457
+ new bootstrap.Toast(toast).show();
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 %}
templates/drive_manager.html CHANGED
@@ -4,148 +4,368 @@
4
 
5
  {% block styles %}
6
  <style>
 
 
 
 
 
 
 
 
 
7
  .source-card {
8
- cursor: pointer;
9
- transition: all 0.3s ease;
 
 
 
10
  }
 
11
  .source-card:hover {
12
- transform: translateY(-5px);
13
- border-color: #0d6efd !important;
14
- box-shadow: 0 8px 16px rgba(13, 110, 253, 0.3);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  }
 
 
 
 
 
 
 
 
 
 
16
  .recent-pdf-card:hover {
17
- transform: translateY(-3px) scale(1.02);
18
- box-shadow: 0 6px 20px rgba(13, 110, 253, 0.4);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  }
20
  </style>
21
  {% endblock %}
22
 
23
  {% block content %}
24
- <div class="container-fluid mt-4" style="width: 90%; margin: auto;">
25
- <div class="d-flex justify-content-between align-items-center mb-4">
26
- <h1>Drive Sync Manager</h1>
27
  <div>
 
 
 
 
28
  {% if not drive_connected %}
29
- <a href="/drive/connect" class="btn btn-outline-warning me-2">
30
- <i class="bi bi-google me-1"></i> Connect Drive
31
  </a>
 
 
 
 
32
  {% endif %}
33
  <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addSourceModal">
34
- <i class="bi bi-plus-lg me-1"></i> Add Drive Source
35
  </button>
36
  </div>
37
  </div>
38
 
 
39
  {% if recent_pdfs %}
40
- <div class="card border-secondary mb-4" style="background: linear-gradient(135deg, #1a1d20 0%, #212529 100%);">
41
- <div class="card-header border-secondary" style="background: linear-gradient(90deg, rgba(13,110,253,0.15) 0%, rgba(13,110,253,0.05) 100%);">
42
- <h5 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Recently Opened PDFs</h5>
43
- </div>
44
- <div class="card-body">
45
- <div class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-3">
46
- {% for pdf in recent_pdfs %}
47
- <div class="col">
48
- <div class="card h-100 border-primary source-card" onclick="location.href='/drive/api/open/{{ pdf.file_id }}'" style="background: linear-gradient(135deg, #212529 0%, #2b3035 100%); transition: all 0.3s ease;">
49
- <div class="card-body d-flex flex-column">
50
- <div class="mb-2 flex-grow-1">
51
- <h6 class="card-title text-primary mb-2" style="word-wrap: break-word; line-height: 1.4;">
52
- <i class="bi bi-file-pdf-fill me-2"></i>{{ pdf.filename }}
53
- </h6>
54
- </div>
55
- <div class="mt-auto pt-2 border-top border-secondary">
56
- <small class="text-muted">
57
- <i class="bi bi-clock me-1"></i>{{ pdf.opened_at }}
58
- </small>
59
- </div>
60
  </div>
61
  </div>
62
  </div>
63
- {% endfor %}
64
  </div>
 
65
  </div>
66
  </div>
67
  {% endif %}
68
 
 
69
  {% if drive_connected %}
70
- <div class="card bg-dark text-white border-warning mb-4 source-card" onclick="location.href='/drive/api/browse/root'">
71
- <div class="card-body d-flex justify-content-between align-items-center">
72
- <div>
73
- <h5 class="card-title text-warning mb-0"><i class="bi bi-google me-2"></i>My Drive</h5>
74
- <small class="text-muted">Browse your personal Google Drive (API)</small>
 
 
 
 
 
75
  </div>
76
- <i class="bi bi-chevron-right text-muted"></i>
77
  </div>
78
  </div>
79
  {% endif %}
80
 
 
 
81
  <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4" id="sources-grid">
82
  {% for source in sources %}
83
  <div class="col">
84
- <div class="card h-100 bg-dark text-white border-secondary source-card">
85
- <div class="card-body" onclick="location.href='/drive/browse/{{ source.id }}'">
86
- <div class="d-flex justify-content-between align-items-start mb-2">
87
- <h5 class="card-title text-primary">
88
- {% if source.source_type == 'file' %}
89
- <i class="bi bi-file-earmark-arrow-down me-2"></i>
90
- {% else %}
91
- <i class="bi bi-hdd-network me-2"></i>
92
- {% endif %}
93
- {{ source.name }}
94
- </h5>
95
- <div class="dropdown" onclick="event.stopPropagation()">
96
  <button class="btn btn-link text-muted p-0" data-bs-toggle="dropdown">
97
- <i class="bi bi-three-dots-vertical"></i>
98
  </button>
99
- <ul class="dropdown-menu dropdown-menu-dark">
100
  <li><button class="dropdown-item sync-btn" data-id="{{ source.id }}"><i class="bi bi-arrow-repeat me-2"></i>Sync Now</button></li>
 
101
  <li><hr class="dropdown-divider"></li>
102
  <li><button class="dropdown-item text-danger delete-btn" data-id="{{ source.id }}"><i class="bi bi-trash me-2"></i>Delete</button></li>
103
  </ul>
104
  </div>
105
  </div>
106
- <p class="card-text text-muted small text-truncate">{{ source.url }}</p>
107
- <div class="mt-3">
108
- <small class="text-secondary">
109
- Last Synced: {{ source.last_synced or 'Never' }}
110
- </small>
 
 
 
 
 
 
111
  </div>
112
  </div>
113
  </div>
114
  </div>
115
  {% else %}
116
- <div class="col-12 text-center text-muted py-5">
117
- <i class="bi bi-cloud-slash display-4 mb-3 d-block"></i>
118
- <p>No drive sources added yet.</p>
 
 
 
 
 
 
119
  </div>
120
  {% endfor %}
121
  </div>
122
  </div>
123
 
124
- <!-- Add Modal -->
125
- <div class="modal fade" id="addSourceModal" tabindex="-1">
126
  <div class="modal-dialog">
127
- <div class="modal-content bg-dark text-white">
128
  <div class="modal-header">
129
- <h5 class="modal-title">Add Public Drive Folder</h5>
130
  <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
131
  </div>
132
  <div class="modal-body">
133
  <form id="add-source-form">
134
  <div class="mb-3">
135
- <label class="form-label">Name (Local Folder Name)</label>
136
  <input type="text" class="form-control" name="name" required placeholder="e.g. Question Papers 2024">
 
137
  </div>
138
  <div class="mb-3">
139
- <label class="form-label">Drive Folder URL</label>
140
  <input type="url" class="form-control" name="url" required placeholder="https://drive.google.com/drive/folders/...">
141
- <div class="form-text text-muted">Must be a publicly accessible Google Drive folder link.</div>
142
  </div>
143
  </form>
144
  </div>
145
  <div class="modal-footer">
146
  <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
147
- <button type="button" class="btn btn-primary" id="save-source-btn">Add Source</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  </div>
 
149
  </div>
150
  </div>
151
  </div>
@@ -154,13 +374,14 @@
154
 
155
  {% block scripts %}
156
  <script>
 
157
  document.getElementById('save-source-btn').addEventListener('click', async () => {
158
  const form = document.getElementById('add-source-form');
159
  if (!form.checkValidity()) {
160
  form.reportValidity();
161
  return;
162
  }
163
-
164
  const formData = new FormData(form);
165
  try {
166
  const res = await fetch('/drive/add', {
@@ -178,27 +399,38 @@
178
  }
179
  });
180
 
 
181
  document.querySelectorAll('.sync-btn').forEach(btn => {
182
- btn.addEventListener('click', async () => {
 
183
  const id = btn.dataset.id;
184
- if(!confirm('Start sync in background? Large folders may take time.')) return;
185
-
 
186
  try {
187
  const res = await fetch(`/drive/sync/${id}`, { method: 'POST' });
188
  const data = await res.json();
189
- if (data.success) alert('Sync started!');
190
- else alert('Error: ' + data.error);
 
 
 
191
  } catch (e) {
192
  alert('Request failed');
 
 
 
193
  }
194
  });
195
  });
196
 
 
197
  document.querySelectorAll('.delete-btn').forEach(btn => {
198
- btn.addEventListener('click', async () => {
 
199
  const id = btn.dataset.id;
200
- if(!confirm('Delete this source and all downloaded files locally?')) return;
201
-
202
  try {
203
  const res = await fetch(`/drive/delete/${id}`, { method: 'POST' });
204
  const data = await res.json();
@@ -209,5 +441,46 @@
209
  }
210
  });
211
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  </script>
213
  {% endblock %}
 
4
 
5
  {% block styles %}
6
  <style>
7
+ /* Modern Drive Manager Styles */
8
+ .drive-header {
9
+ background: linear-gradient(135deg, rgba(13,110,253,0.1) 0%, rgba(111,66,193,0.1) 100%);
10
+ border-radius: 16px;
11
+ padding: 24px 32px;
12
+ margin-bottom: 24px;
13
+ border: 1px solid var(--border-subtle);
14
+ }
15
+
16
  .source-card {
17
+ background: var(--bg-card);
18
+ border: 1px solid var(--border-subtle);
19
+ border-radius: 12px;
20
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
21
+ overflow: hidden;
22
  }
23
+
24
  .source-card:hover {
25
+ transform: translateY(-4px);
26
+ border-color: var(--accent-primary);
27
+ box-shadow: 0 12px 24px rgba(13, 110, 253, 0.2);
28
+ }
29
+
30
+ .source-card .card-body {
31
+ padding: 20px;
32
+ }
33
+
34
+ .source-icon {
35
+ width: 48px;
36
+ height: 48px;
37
+ border-radius: 12px;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ font-size: 1.5rem;
42
+ margin-bottom: 12px;
43
+ }
44
+
45
+ .source-icon.folder {
46
+ background: linear-gradient(135deg, rgba(255,193,7,0.2) 0%, rgba(255,152,0,0.2) 100%);
47
+ color: #ffc107;
48
+ }
49
+
50
+ .source-icon.file {
51
+ background: linear-gradient(135deg, rgba(220,53,69,0.2) 0%, rgba(255,87,34,0.2) 100%);
52
+ color: #dc3545;
53
  }
54
+
55
+ .recent-pdf-card {
56
+ background: var(--bg-card);
57
+ border: 1px solid var(--border-subtle);
58
+ border-radius: 12px;
59
+ padding: 16px;
60
+ cursor: pointer;
61
+ transition: all 0.3s ease;
62
+ }
63
+
64
  .recent-pdf-card:hover {
65
+ transform: translateY(-3px);
66
+ border-color: var(--accent-primary);
67
+ box-shadow: 0 8px 20px rgba(13, 110, 253, 0.25);
68
+ }
69
+
70
+ .my-drive-card {
71
+ background: linear-gradient(135deg, rgba(255,193,7,0.1) 0%, rgba(255,152,0,0.05) 100%);
72
+ border: 1px solid rgba(255,193,7,0.3);
73
+ border-radius: 12px;
74
+ padding: 20px 24px;
75
+ cursor: pointer;
76
+ transition: all 0.3s ease;
77
+ }
78
+
79
+ .my-drive-card:hover {
80
+ transform: translateY(-2px);
81
+ box-shadow: 0 8px 20px rgba(255,193,7,0.2);
82
+ }
83
+
84
+ .action-btn {
85
+ width: 36px;
86
+ height: 36px;
87
+ border-radius: 8px;
88
+ display: inline-flex;
89
+ align-items: center;
90
+ justify-content: center;
91
+ transition: all 0.2s ease;
92
+ }
93
+
94
+ .action-btn:hover {
95
+ transform: scale(1.1);
96
+ }
97
+
98
+ .share-link-input {
99
+ background: var(--bg-dark);
100
+ border: 1px solid var(--border-subtle);
101
+ border-radius: 8px;
102
+ padding: 8px 12px;
103
+ color: var(--text-secondary);
104
+ font-family: monospace;
105
+ font-size: 0.85rem;
106
+ }
107
+
108
+ .empty-state {
109
+ text-align: center;
110
+ padding: 60px 20px;
111
+ color: var(--text-muted);
112
+ }
113
+
114
+ .empty-state i {
115
+ font-size: 4rem;
116
+ opacity: 0.3;
117
+ margin-bottom: 16px;
118
+ }
119
+
120
+ /* Modal styles */
121
+ .modal-modern .modal-content {
122
+ background: var(--bg-card);
123
+ border: 1px solid var(--border-subtle);
124
+ border-radius: 16px;
125
+ }
126
+
127
+ .modal-modern .modal-header {
128
+ border-bottom: 1px solid var(--border-subtle);
129
+ padding: 20px 24px;
130
+ }
131
+
132
+ .modal-modern .modal-body {
133
+ padding: 24px;
134
+ }
135
+
136
+ .modal-modern .modal-footer {
137
+ border-top: 1px solid var(--border-subtle);
138
+ padding: 16px 24px;
139
+ }
140
+
141
+ .section-title {
142
+ font-size: 0.85rem;
143
+ font-weight: 600;
144
+ text-transform: uppercase;
145
+ letter-spacing: 0.5px;
146
+ color: var(--text-muted);
147
+ margin-bottom: 16px;
148
+ }
149
+
150
+ .toast-container {
151
+ position: fixed;
152
+ bottom: 20px;
153
+ right: 20px;
154
+ z-index: 1100;
155
  }
156
  </style>
157
  {% endblock %}
158
 
159
  {% block content %}
160
+ <div class="container-fluid px-4 py-4">
161
+ <!-- Header -->
162
+ <div class="drive-header d-flex justify-content-between align-items-center flex-wrap gap-3">
163
  <div>
164
+ <h1 class="h3 mb-1"><i class="bi bi-cloud-fill me-2"></i>Drive Sync Manager</h1>
165
+ <p class="text-muted mb-0 small">Sync and manage Google Drive folders locally</p>
166
+ </div>
167
+ <div class="d-flex gap-2">
168
  {% if not drive_connected %}
169
+ <a href="/drive/connect" class="btn btn-outline-warning">
170
+ <i class="bi bi-google me-2"></i>Connect Drive
171
  </a>
172
+ {% else %}
173
+ <span class="badge bg-success d-flex align-items-center px-3 py-2">
174
+ <i class="bi bi-check-circle me-1"></i>Drive Connected
175
+ </span>
176
  {% endif %}
177
  <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addSourceModal">
178
+ <i class="bi bi-plus-lg me-2"></i>Add Source
179
  </button>
180
  </div>
181
  </div>
182
 
183
+ <!-- Recently Opened PDFs -->
184
  {% if recent_pdfs %}
185
+ <div class="mb-4">
186
+ <div class="section-title"><i class="bi bi-clock-history me-2"></i>Recently Opened</div>
187
+ <div class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-3">
188
+ {% for pdf in recent_pdfs %}
189
+ <div class="col">
190
+ <div class="recent-pdf-card" onclick="location.href='/drive/api/open/{{ pdf.file_id }}'">
191
+ <div class="d-flex align-items-start gap-3">
192
+ <div class="source-icon file flex-shrink-0" style="width:40px;height:40px;font-size:1.2rem;">
193
+ <i class="bi bi-file-pdf-fill"></i>
194
+ </div>
195
+ <div class="flex-grow-1 min-w-0">
196
+ <h6 class="mb-1 text-truncate" title="{{ pdf.filename }}">{{ pdf.filename }}</h6>
197
+ <small class="text-muted"><i class="bi bi-clock me-1"></i>{{ pdf.opened_at }}</small>
 
 
 
 
 
 
 
198
  </div>
199
  </div>
200
  </div>
 
201
  </div>
202
+ {% endfor %}
203
  </div>
204
  </div>
205
  {% endif %}
206
 
207
+ <!-- My Drive Quick Access -->
208
  {% if drive_connected %}
209
+ <div class="mb-4">
210
+ <div class="my-drive-card d-flex justify-content-between align-items-center" onclick="location.href='/drive/api/browse/root'">
211
+ <div class="d-flex align-items-center gap-3">
212
+ <div class="source-icon folder">
213
+ <i class="bi bi-google"></i>
214
+ </div>
215
+ <div>
216
+ <h5 class="mb-0 text-warning">My Drive</h5>
217
+ <small class="text-muted">Browse your personal Google Drive</small>
218
+ </div>
219
  </div>
220
+ <i class="bi bi-chevron-right text-muted fs-4"></i>
221
  </div>
222
  </div>
223
  {% endif %}
224
 
225
+ <!-- Drive Sources -->
226
+ <div class="section-title"><i class="bi bi-hdd-network me-2"></i>Synced Sources</div>
227
  <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4" id="sources-grid">
228
  {% for source in sources %}
229
  <div class="col">
230
+ <div class="source-card h-100">
231
+ <div class="card-body">
232
+ <div class="d-flex justify-content-between align-items-start mb-3">
233
+ <div class="source-icon {{ 'file' if source.source_type == 'file' else 'folder' }}">
234
+ <i class="bi {{ 'bi-file-earmark-arrow-down' if source.source_type == 'file' else 'bi-folder-fill' }}"></i>
235
+ </div>
236
+ <div class="dropdown">
 
 
 
 
 
237
  <button class="btn btn-link text-muted p-0" data-bs-toggle="dropdown">
238
+ <i class="bi bi-three-dots-vertical fs-5"></i>
239
  </button>
240
+ <ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end">
241
  <li><button class="dropdown-item sync-btn" data-id="{{ source.id }}"><i class="bi bi-arrow-repeat me-2"></i>Sync Now</button></li>
242
+ <li><button class="dropdown-item" onclick="showShareModal({{ source.id }}, '{{ source.name }}')"><i class="bi bi-share me-2"></i>Get Share Links</button></li>
243
  <li><hr class="dropdown-divider"></li>
244
  <li><button class="dropdown-item text-danger delete-btn" data-id="{{ source.id }}"><i class="bi bi-trash me-2"></i>Delete</button></li>
245
  </ul>
246
  </div>
247
  </div>
248
+ <div onclick="location.href='/drive/browse/{{ source.id }}'" style="cursor: pointer;">
249
+ <h5 class="mb-2">{{ source.name }}</h5>
250
+ <p class="text-muted small text-truncate mb-3" title="{{ source.url }}">{{ source.url }}</p>
251
+ <div class="d-flex justify-content-between align-items-center">
252
+ <small class="text-secondary">
253
+ <i class="bi bi-clock me-1"></i>{{ source.last_synced or 'Never synced' }}
254
+ </small>
255
+ <span class="badge bg-{{ 'success' if source.last_synced else 'secondary' }}">
256
+ {{ 'Synced' if source.last_synced else 'Pending' }}
257
+ </span>
258
+ </div>
259
  </div>
260
  </div>
261
  </div>
262
  </div>
263
  {% else %}
264
+ <div class="col-12">
265
+ <div class="empty-state">
266
+ <i class="bi bi-cloud-slash d-block"></i>
267
+ <h5>No drive sources added yet</h5>
268
+ <p class="text-muted">Add a public Google Drive folder to get started</p>
269
+ <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addSourceModal">
270
+ <i class="bi bi-plus-lg me-2"></i>Add Source
271
+ </button>
272
+ </div>
273
  </div>
274
  {% endfor %}
275
  </div>
276
  </div>
277
 
278
+ <!-- Add Source Modal -->
279
+ <div class="modal fade modal-modern" id="addSourceModal" tabindex="-1">
280
  <div class="modal-dialog">
281
+ <div class="modal-content text-white">
282
  <div class="modal-header">
283
+ <h5 class="modal-title"><i class="bi bi-plus-circle me-2"></i>Add Drive Source</h5>
284
  <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
285
  </div>
286
  <div class="modal-body">
287
  <form id="add-source-form">
288
  <div class="mb-3">
289
+ <label class="form-label">Source Name</label>
290
  <input type="text" class="form-control" name="name" required placeholder="e.g. Question Papers 2024">
291
+ <div class="form-text">This will be the local folder name</div>
292
  </div>
293
  <div class="mb-3">
294
+ <label class="form-label">Google Drive URL</label>
295
  <input type="url" class="form-control" name="url" required placeholder="https://drive.google.com/drive/folders/...">
296
+ <div class="form-text">Must be a publicly accessible folder or file link</div>
297
  </div>
298
  </form>
299
  </div>
300
  <div class="modal-footer">
301
  <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
302
+ <button type="button" class="btn btn-primary" id="save-source-btn">
303
+ <i class="bi bi-plus-lg me-1"></i>Add Source
304
+ </button>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ </div>
309
+
310
+ <!-- Share Links Modal -->
311
+ <div class="modal fade modal-modern" id="shareModal" tabindex="-1">
312
+ <div class="modal-dialog modal-lg">
313
+ <div class="modal-content text-white">
314
+ <div class="modal-header">
315
+ <h5 class="modal-title"><i class="bi bi-share me-2"></i>Share Links</h5>
316
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
317
+ </div>
318
+ <div class="modal-body">
319
+ <p class="text-muted mb-4">Generate public links for sharing files without authentication</p>
320
+
321
+ <!-- Direct PDF Link -->
322
+ <div class="mb-4">
323
+ <label class="form-label fw-semibold"><i class="bi bi-file-pdf me-2"></i>Direct PDF Download Link</label>
324
+ <p class="text-muted small mb-2">Anyone with this link can download PDFs directly</p>
325
+ <div class="input-group">
326
+ <input type="text" class="form-control share-link-input" id="share-pdf-link" readonly>
327
+ <button class="btn btn-outline-primary" onclick="copyLink('share-pdf-link')">
328
+ <i class="bi bi-clipboard"></i>
329
+ </button>
330
+ </div>
331
+ </div>
332
+
333
+ <!-- Directory Listing Link -->
334
+ <div class="mb-4">
335
+ <label class="form-label fw-semibold"><i class="bi bi-list-ul me-2"></i>Directory Listing (curl-friendly)</label>
336
+ <p class="text-muted small mb-2">Returns a plain text list of all files - perfect for scripts</p>
337
+ <div class="input-group">
338
+ <input type="text" class="form-control share-link-input" id="share-list-link" readonly>
339
+ <button class="btn btn-outline-primary" onclick="copyLink('share-list-link')">
340
+ <i class="bi bi-clipboard"></i>
341
+ </button>
342
+ </div>
343
+ <div class="mt-2">
344
+ <code class="small text-info">curl <span id="curl-example"></span></code>
345
+ </div>
346
+ </div>
347
+
348
+ <!-- Access Token Info -->
349
+ <div class="alert alert-warning border-0 small">
350
+ <i class="bi bi-shield-exclamation me-2"></i>
351
+ These links include an access token. Share carefully - anyone with the link can access the files.
352
+ </div>
353
+ </div>
354
+ <div class="modal-footer">
355
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
356
+ </div>
357
+ </div>
358
+ </div>
359
+ </div>
360
+
361
+ <!-- Toast Container -->
362
+ <div class="toast-container">
363
+ <div class="toast align-items-center text-bg-success border-0" id="copyToast" role="alert">
364
+ <div class="d-flex">
365
+ <div class="toast-body">
366
+ <i class="bi bi-check-circle me-2"></i>Link copied to clipboard!
367
  </div>
368
+ <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
369
  </div>
370
  </div>
371
  </div>
 
374
 
375
  {% block scripts %}
376
  <script>
377
+ // Add Source
378
  document.getElementById('save-source-btn').addEventListener('click', async () => {
379
  const form = document.getElementById('add-source-form');
380
  if (!form.checkValidity()) {
381
  form.reportValidity();
382
  return;
383
  }
384
+
385
  const formData = new FormData(form);
386
  try {
387
  const res = await fetch('/drive/add', {
 
399
  }
400
  });
401
 
402
+ // Sync
403
  document.querySelectorAll('.sync-btn').forEach(btn => {
404
+ btn.addEventListener('click', async (e) => {
405
+ e.stopPropagation();
406
  const id = btn.dataset.id;
407
+ btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Starting...';
408
+ btn.disabled = true;
409
+
410
  try {
411
  const res = await fetch(`/drive/sync/${id}`, { method: 'POST' });
412
  const data = await res.json();
413
+ if (data.success) {
414
+ showToast('Sync started in background');
415
+ } else {
416
+ alert('Error: ' + data.error);
417
+ }
418
  } catch (e) {
419
  alert('Request failed');
420
+ } finally {
421
+ btn.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i>Sync Now';
422
+ btn.disabled = false;
423
  }
424
  });
425
  });
426
 
427
+ // Delete
428
  document.querySelectorAll('.delete-btn').forEach(btn => {
429
+ btn.addEventListener('click', async (e) => {
430
+ e.stopPropagation();
431
  const id = btn.dataset.id;
432
+ if (!confirm('Delete this source and all downloaded files?')) return;
433
+
434
  try {
435
  const res = await fetch(`/drive/delete/${id}`, { method: 'POST' });
436
  const data = await res.json();
 
441
  }
442
  });
443
  });
444
+
445
+ // Share Modal
446
+ async function showShareModal(sourceId, sourceName) {
447
+ try {
448
+ // Get the public access token
449
+ const res = await fetch(`/drive/public/${sourceId}/token`);
450
+ const data = await res.json();
451
+ if (!data.token) {
452
+ alert('Error getting share token');
453
+ return;
454
+ }
455
+
456
+ const baseUrl = window.location.origin;
457
+ const token = data.token;
458
+ const pdfLink = `${baseUrl}/drive/public/${sourceId}/download?token=${token}`;
459
+ const listLink = `${baseUrl}/drive/public/${sourceId}/list.txt?token=${token}`;
460
+
461
+ document.getElementById('share-pdf-link').value = pdfLink;
462
+ document.getElementById('share-list-link').value = listLink;
463
+ document.getElementById('curl-example').textContent = listLink;
464
+
465
+ new bootstrap.Modal(document.getElementById('shareModal')).show();
466
+ } catch (e) {
467
+ alert('Error: ' + e.message);
468
+ }
469
+ }
470
+
471
+ // Copy Link
472
+ function copyLink(inputId) {
473
+ const input = document.getElementById(inputId);
474
+ input.select();
475
+ navigator.clipboard.writeText(input.value);
476
+ showToast('Link copied to clipboard!');
477
+ }
478
+
479
+ // Toast
480
+ function showToast(message) {
481
+ const toast = document.getElementById('copyToast');
482
+ toast.querySelector('.toast-body').innerHTML = `<i class="bi bi-check-circle me-2"></i>${message}`;
483
+ new bootstrap.Toast(toast).show();
484
+ }
485
  </script>
486
  {% endblock %}