@intranefr/superbackend 1.7.9 → 1.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -167,6 +167,85 @@
167
167
  </div>
168
168
  </div>
169
169
 
170
+ <!-- Public Exports Management -->
171
+ <div class="bg-white rounded-lg shadow p-6 mb-8">
172
+ <div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
173
+ <div>
174
+ <h2 class="text-xl font-bold text-gray-900">Public Exports</h2>
175
+ <p class="text-sm text-gray-600 mt-1">Create and manage shareable export links</p>
176
+ </div>
177
+ <div class="flex gap-2">
178
+ <button id="btn-create-export" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Create Export</button>
179
+ <button id="btn-refresh-exports" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Refresh</button>
180
+ </div>
181
+ </div>
182
+
183
+ <!-- Create Export Form (Hidden by default) -->
184
+ <div id="create-export-form" class="hidden mt-6 p-4 border rounded-lg bg-gray-50">
185
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">Create New Export</h3>
186
+ <form id="export-form" class="grid grid-cols-1 md:grid-cols-2 gap-4">
187
+ <div>
188
+ <label class="block text-sm font-medium text-gray-700 mb-1">Export Name *</label>
189
+ <div class="flex gap-2">
190
+ <input id="export-name" type="text" class="flex-1 border rounded px-3 py-2" placeholder="e.g., cerise" required>
191
+ <button type="button" id="btn-generate-name" class="bg-gray-200 text-gray-700 px-3 py-2 rounded hover:bg-gray-300 text-sm">Generate</button>
192
+ </div>
193
+ </div>
194
+ <div>
195
+ <label class="block text-sm font-medium text-gray-700 mb-1">Waitlist Type *</label>
196
+ <select id="export-type" class="w-full border rounded px-3 py-2" required>
197
+ <option value="">Select type...</option>
198
+ </select>
199
+ </div>
200
+ <div>
201
+ <label class="block text-sm font-medium text-gray-700 mb-1">Password (Optional)</label>
202
+ <input id="export-password" type="password" class="w-full border rounded px-3 py-2" placeholder="Leave empty for no protection">
203
+ </div>
204
+ <div>
205
+ <label class="block text-sm font-medium text-gray-700 mb-1">Default Format</label>
206
+ <select id="export-format" class="w-full border rounded px-3 py-2">
207
+ <option value="csv">CSV</option>
208
+ <option value="json">JSON</option>
209
+ </select>
210
+ </div>
211
+ </form>
212
+ <div class="mt-4 flex gap-2 justify-end">
213
+ <button type="button" id="btn-cancel-export" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Cancel</button>
214
+ <button type="submit" form="export-form" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Create Export</button>
215
+ </div>
216
+ </div>
217
+
218
+ <!-- Exports Table -->
219
+ <div class="mt-6">
220
+ <div id="exports-loading" class="hidden text-center py-8">
221
+ <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
222
+ <p class="mt-2 text-gray-600">Loading exports...</p>
223
+ </div>
224
+ <div id="exports-container" class="overflow-x-auto">
225
+ <table class="min-w-full divide-y divide-gray-200">
226
+ <thead class="bg-gray-50">
227
+ <tr>
228
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
229
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
230
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Format</th>
231
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Password</th>
232
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Access Count</th>
233
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Accessed</th>
234
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
235
+ </tr>
236
+ </thead>
237
+ <tbody id="exports-tbody" class="bg-white divide-y divide-gray-200">
238
+ <!-- Exports will be loaded here -->
239
+ </tbody>
240
+ </table>
241
+ <div id="no-exports" class="text-center py-8 text-gray-500">
242
+ <p>No public exports created yet</p>
243
+ <p class="text-sm mt-2">Click "Create Export" to get started</p>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ </div>
248
+
170
249
  <!-- Subscribe tester -->
171
250
  <div class="bg-white rounded-lg shadow p-6">
172
251
  <div>
@@ -254,6 +333,8 @@
254
333
  const WAITING_LIST_STATS_PATH = '/api/waiting-list/stats';
255
334
  const WAITING_LIST_SUBSCRIBE_PATH = '/api/waiting-list/subscribe';
256
335
  const WAITING_LIST_ADMIN_LIST_PATH = '/api/admin/waiting-list';
336
+ const WAITING_LIST_PUBLIC_EXPORTS_PATH = '/api/admin/waiting-list/public-exports';
337
+ const WAITING_LIST_PUBLIC_EXPORT_URL = '/api/waiting-list/share/export';
257
338
 
258
339
  // Generate cURL example with all fields
259
340
  function generateCurlExample() {
@@ -349,22 +430,250 @@
349
430
  async function copyText(text) {
350
431
  try {
351
432
  await navigator.clipboard.writeText(text);
352
- showToast('Copied to clipboard', 'success');
353
- } catch (e) {
433
+ showToast('Copied to clipboard!', 'success');
434
+ } catch (err) {
435
+ console.error('Failed to copy text: ', err);
436
+ // Fallback for older browsers
437
+ const ta = document.createElement('textarea');
438
+ ta.value = text;
439
+ document.body.appendChild(ta);
440
+ ta.select();
354
441
  try {
355
- const ta = document.createElement('textarea');
356
- ta.value = text;
357
- ta.style.position = 'fixed';
358
- ta.style.left = '-9999px';
359
- document.body.appendChild(ta);
360
- ta.focus();
361
- ta.select();
362
442
  document.execCommand('copy');
363
- ta.remove();
364
443
  showToast('Copied to clipboard', 'success');
365
- } catch (err) {
444
+ } catch (fallbackErr) {
366
445
  showToast('Failed to copy', 'error');
367
446
  }
447
+ ta.remove();
448
+ }
449
+ }
450
+
451
+ // Public Exports Management
452
+ let publicExports = [];
453
+ let availableTypes = [];
454
+
455
+ async function loadPublicExports() {
456
+ try {
457
+ const loadingEl = document.getElementById('exports-loading');
458
+ const containerEl = document.getElementById('exports-container');
459
+ const noExportsEl = document.getElementById('no-exports');
460
+
461
+ if (loadingEl) loadingEl.classList.remove('hidden');
462
+ if (containerEl) containerEl.classList.add('hidden');
463
+ if (noExportsEl) noExportsEl.classList.add('hidden');
464
+
465
+ const res = await fetch(`${API_BASE}${WAITING_LIST_PUBLIC_EXPORTS_PATH}`, {
466
+ headers: { 'Accept': 'application/json' }
467
+ });
468
+ const data = await res.json();
469
+
470
+ if (!res.ok) {
471
+ console.error('Failed to load public exports:', data?.error);
472
+ showToast('Failed to load public exports', 'error');
473
+ return;
474
+ }
475
+
476
+ publicExports = data?.exports || [];
477
+ renderPublicExports();
478
+
479
+ if (loadingEl) loadingEl.classList.add('hidden');
480
+ if (containerEl) containerEl.classList.remove('hidden');
481
+
482
+ if (publicExports.length === 0) {
483
+ if (containerEl) containerEl.classList.add('hidden');
484
+ if (noExportsEl) noExportsEl.classList.remove('hidden');
485
+ } else {
486
+ if (noExportsEl) noExportsEl.classList.add('hidden');
487
+ }
488
+ } catch (e) {
489
+ console.error('Error loading public exports:', e);
490
+ showToast('Error loading public exports', 'error');
491
+ }
492
+ }
493
+
494
+ function renderPublicExports() {
495
+ const tbody = document.getElementById('exports-tbody');
496
+ if (!tbody) return;
497
+
498
+ const html = publicExports.map(export_ => {
499
+ const exportUrl = `${API_BASE}${WAITING_LIST_PUBLIC_EXPORT_URL}?type=${encodeURIComponent(export_.name)}&format=${export_.format}`;
500
+
501
+ return `
502
+ <tr>
503
+ <td class="px-4 py-3 text-sm">
504
+ <div class="font-medium text-gray-900">${escapeHtml(export_.name)}</div>
505
+ <div class="text-gray-500 text-xs mt-1">Created ${formatDateTime(export_.createdAt)}</div>
506
+ </td>
507
+ <td class="px-4 py-3 text-sm text-gray-900">${escapeHtml(export_.type)}</td>
508
+ <td class="px-4 py-3 text-sm">
509
+ <span class="px-2 py-1 text-xs font-medium rounded-full ${
510
+ export_.format === 'csv'
511
+ ? 'bg-green-100 text-green-800'
512
+ : 'bg-blue-100 text-blue-800'
513
+ }">
514
+ ${export_.format.toUpperCase()}
515
+ </span>
516
+ </td>
517
+ <td class="px-4 py-3 text-sm">
518
+ ${export_.password
519
+ ? '<span class="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800">Protected</span>'
520
+ : '<span class="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800">Public</span>'
521
+ }
522
+ </td>
523
+ <td class="px-4 py-3 text-sm text-gray-900">${export_.accessCount || 0}</td>
524
+ <td class="px-4 py-3 text-sm text-gray-500">
525
+ ${export_.lastAccessed ? formatDateTime(export_.lastAccessed) : 'Never'}
526
+ </td>
527
+ <td class="px-4 py-3 text-sm">
528
+ <div class="flex gap-1 flex-wrap">
529
+ <button onclick="copyExportUrl('${escapeHtml(export_.name)}', '${export_.format}')" class="bg-blue-500 text-white px-2 py-1 rounded text-xs hover:bg-blue-600">Copy Link</button>
530
+ <button onclick="copyExportCurl('${escapeHtml(export_.name)}', '${export_.format}')" class="bg-gray-500 text-white px-2 py-1 rounded text-xs hover:bg-gray-600">Copy cURL</button>
531
+ <button onclick="deletePublicExport('${export_.id}', '${escapeHtml(export_.name)}')" class="bg-red-500 text-white px-2 py-1 rounded text-xs hover:bg-red-600">Delete</button>
532
+ </div>
533
+ </td>
534
+ </tr>
535
+ `;
536
+ }).join('');
537
+
538
+ tbody.innerHTML = html;
539
+ }
540
+
541
+ async function copyExportUrl(name, format) {
542
+ const baseUrl = `${window.location.origin}/share/export/${encodeURIComponent(name)}?format=${format}`;
543
+ await copyText(baseUrl);
544
+ }
545
+
546
+ async function copyExportCurl(name, format) {
547
+ const exportConfig = publicExports.find(e => e.name === name);
548
+ const apiUrl = `${API_BASE}${WAITING_LIST_PUBLIC_EXPORT_URL}?type=${encodeURIComponent(name)}&format=${format}`;
549
+
550
+ if (exportConfig && exportConfig.password) {
551
+ // For password-protected exports, provide template with password placeholder
552
+ await copyText(`curl -u :PASSWORD "${apiUrl}" # Replace PASSWORD with the actual password`);
553
+ } else {
554
+ // For public exports, simple curl
555
+ await copyText(`curl "${apiUrl}"`);
556
+ }
557
+ }
558
+
559
+ async function deletePublicExport(id, name) {
560
+ if (!confirm(`Are you sure you want to delete the export "${name}"? This action cannot be undone.`)) {
561
+ return;
562
+ }
563
+
564
+ try {
565
+ const res = await fetch(`${API_BASE}${WAITING_LIST_PUBLIC_EXPORTS_PATH}/${id}`, {
566
+ method: 'DELETE',
567
+ headers: { 'Accept': 'application/json' }
568
+ });
569
+ const data = await res.json();
570
+
571
+ if (!res.ok) {
572
+ showToast(data?.error || 'Failed to delete export', 'error');
573
+ return;
574
+ }
575
+
576
+ showToast('Export deleted successfully', 'success');
577
+ await loadPublicExports();
578
+ } catch (e) {
579
+ console.error('Error deleting public export:', e);
580
+ showToast('Error deleting public export', 'error');
581
+ }
582
+ }
583
+
584
+ async function createPublicExport(formData) {
585
+ try {
586
+ const res = await fetch(`${API_BASE}${WAITING_LIST_PUBLIC_EXPORTS_PATH}`, {
587
+ method: 'POST',
588
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
589
+ body: JSON.stringify(formData)
590
+ });
591
+ const data = await res.json();
592
+
593
+ if (!res.ok) {
594
+ showToast(data?.error || 'Failed to create export', 'error');
595
+ return;
596
+ }
597
+
598
+ showToast('Export created successfully', 'success');
599
+ hideCreateExportForm();
600
+ await loadPublicExports();
601
+ } catch (e) {
602
+ console.error('Error creating public export:', e);
603
+ showToast('Error creating public export', 'error');
604
+ }
605
+ }
606
+
607
+ function showCreateExportForm() {
608
+ const formEl = document.getElementById('create-export-form');
609
+ if (formEl) formEl.classList.remove('hidden');
610
+ loadTypesForExportForm();
611
+ }
612
+
613
+ function hideCreateExportForm() {
614
+ const formEl = document.getElementById('create-export-form');
615
+ if (formEl) formEl.classList.add('hidden');
616
+ // Reset form
617
+ const form = document.getElementById('export-form');
618
+ if (form) form.reset();
619
+ }
620
+
621
+ async function loadTypesForExportForm() {
622
+ try {
623
+ const res = await fetch(`${API_BASE}/api/admin/waiting-list/types`, {
624
+ headers: { 'Accept': 'application/json' }
625
+ });
626
+ const data = await res.json();
627
+
628
+ if (!res.ok) {
629
+ console.error('Failed to load types:', data?.error);
630
+ return;
631
+ }
632
+
633
+ const select = document.getElementById('export-type');
634
+ if (!select) return;
635
+
636
+ const types = data?.types || [];
637
+ let html = '<option value="">Select type...</option>';
638
+
639
+ types.forEach(({ type }) => {
640
+ const safeType = escapeHtml(type);
641
+ html += `<option value="${safeType}">${safeType}</option>`;
642
+ });
643
+
644
+ select.innerHTML = html;
645
+ } catch (e) {
646
+ console.error('Error loading types for export form:', e);
647
+ }
648
+ }
649
+
650
+ async function generateExportName() {
651
+ try {
652
+ const adjectives = ['black', 'white', 'golden', 'silver', 'red', 'blue', 'green', 'purple', 'silent', 'loud', 'fast', 'slow', 'big', 'small', 'tall', 'short', 'brave', 'shy', 'wise', 'clever', 'strong', 'gentle', 'wild', 'calm', 'happy', 'sad', 'angry', 'peaceful', 'bright', 'dark', 'light', 'heavy'];
653
+ const animals = ['bear', 'eagle', 'wolf', 'lion', 'tiger', 'elephant', 'giraffe', 'zebra', 'monkey', 'dolphin', 'whale', 'shark', 'hawk', 'owl', 'falcon', 'fox', 'deer', 'rabbit', 'squirrel', 'mouse', 'cat', 'dog', 'horse', 'cow', 'pig', 'sheep', 'goat', 'chicken', 'duck', 'goose'];
654
+
655
+ let attempts = 0;
656
+ const maxAttempts = 50;
657
+
658
+ while (attempts < maxAttempts) {
659
+ const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
660
+ const animal = animals[Math.floor(Math.random() * animals.length)];
661
+ const name = `${adjective}-${animal}`;
662
+
663
+ // Check if name already exists
664
+ if (!publicExports.some(e => e.name === name)) {
665
+ const input = document.getElementById('export-name');
666
+ if (input) input.value = name;
667
+ return;
668
+ }
669
+
670
+ attempts++;
671
+ }
672
+
673
+ showToast('Failed to generate unique name', 'error');
674
+ } catch (e) {
675
+ console.error('Error generating export name:', e);
676
+ showToast('Error generating export name', 'error');
368
677
  }
369
678
  }
370
679
 
@@ -910,12 +1219,53 @@
910
1219
  }
911
1220
  };
912
1221
  }
1222
+
1223
+ // Public Exports event bindings
1224
+ const createExportBtn = document.getElementById('btn-create-export');
1225
+ if (createExportBtn) {
1226
+ createExportBtn.onclick = showCreateExportForm;
1227
+ }
1228
+
1229
+ const refreshExportsBtn = document.getElementById('btn-refresh-exports');
1230
+ if (refreshExportsBtn) {
1231
+ refreshExportsBtn.onclick = loadPublicExports;
1232
+ }
1233
+
1234
+ const cancelExportBtn = document.getElementById('btn-cancel-export');
1235
+ if (cancelExportBtn) {
1236
+ cancelExportBtn.onclick = hideCreateExportForm;
1237
+ }
1238
+
1239
+ const generateNameBtn = document.getElementById('btn-generate-name');
1240
+ if (generateNameBtn) {
1241
+ generateNameBtn.onclick = generateExportName;
1242
+ }
1243
+
1244
+ const exportForm = document.getElementById('export-form');
1245
+ if (exportForm) {
1246
+ exportForm.onsubmit = async (event) => {
1247
+ event.preventDefault();
1248
+
1249
+ const name = document.getElementById('export-name')?.value?.trim();
1250
+ const type = document.getElementById('export-type')?.value;
1251
+ const password = document.getElementById('export-password')?.value;
1252
+ const format = document.getElementById('export-format')?.value;
1253
+
1254
+ if (!name || !type) {
1255
+ showToast('Name and type are required', 'error');
1256
+ return;
1257
+ }
1258
+
1259
+ await createPublicExport({ name, type, password, format });
1260
+ };
1261
+ }
913
1262
  }
914
1263
 
915
1264
  bindEvents();
916
1265
  loadStats();
917
1266
  loadTypes();
918
1267
  loadEntries();
1268
+ loadPublicExports();
919
1269
  renderCurlExample();
920
1270
  </script>
921
1271
  <script>
@@ -0,0 +1,192 @@
1
+ <!DOCTYPE html>
2
+ <html class="light" lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta content="width=device-width, initial-scale=1.0" name="viewport" />
6
+ <title>Export Access - <%= exportName %></title>
7
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&display=swap" rel="stylesheet" />
9
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
10
+ <script>
11
+ tailwind.config = {
12
+ darkMode: "class",
13
+ theme: {
14
+ extend: {
15
+ colors: {
16
+ "primary-charcoal": "#1a1a1a",
17
+ "electric-teal": "#00e6e6",
18
+ "bright-orange": "#FF6B00",
19
+ "background-light": "#f5f5f5",
20
+ "background-dark": "#0f1923",
21
+ "text-light": "#e0e0e0",
22
+ "text-dark": "#f5f5f5",
23
+ "text-muted": "#9e9e9e"
24
+ },
25
+ fontFamily: {
26
+ "display": ["Inter", "sans-serif"]
27
+ },
28
+ borderRadius: { "DEFAULT": "4px", "lg": "8px", "xl": "12px", "full": "9999px" },
29
+ },
30
+ },
31
+ }
32
+ </script>
33
+ <style>
34
+ .material-symbols-outlined {
35
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
36
+ }
37
+ </style>
38
+ </head>
39
+ <body class="bg-gray-50 min-h-screen flex items-center justify-center p-4">
40
+ <div class="max-w-md w-full">
41
+ <!-- Header -->
42
+ <div class="text-center mb-8">
43
+ <div class="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4">
44
+ <span class="material-symbols-outlined text-blue-600 text-2xl">download</span>
45
+ </div>
46
+ <h1 class="text-2xl font-bold text-gray-900 mb-2">Export Access</h1>
47
+ <p class="text-gray-600">This export is password protected</p>
48
+ </div>
49
+
50
+ <!-- Export Info -->
51
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
52
+ <div class="space-y-3">
53
+ <div class="flex justify-between items-center">
54
+ <span class="text-sm font-medium text-gray-500">Export Name</span>
55
+ <span class="text-sm text-gray-900 font-medium"><%= exportName %></span>
56
+ </div>
57
+ <div class="flex justify-between items-center">
58
+ <span class="text-sm font-medium text-gray-500">Type</span>
59
+ <span class="text-sm text-gray-900 font-medium"><%= exportType %></span>
60
+ </div>
61
+ <div class="flex justify-between items-center">
62
+ <span class="text-sm font-medium text-gray-500">Format</span>
63
+ <span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium <%= format === 'csv' ? 'bg-green-100 text-green-800' : 'bg-blue-100 text-blue-800' %>">
64
+ <%= format.toUpperCase() %>
65
+ </span>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ <!-- Password Form -->
71
+ <form id="passwordForm" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
72
+ <div class="mb-4">
73
+ <label for="password" class="block text-sm font-medium text-gray-700 mb-2">
74
+ Enter Password
75
+ </label>
76
+ <div class="relative">
77
+ <input
78
+ type="password"
79
+ id="password"
80
+ name="password"
81
+ required
82
+ class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
83
+ placeholder="Enter the export password"
84
+ autocomplete="current-password"
85
+ >
86
+ <button
87
+ type="button"
88
+ id="togglePassword"
89
+ class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
90
+ >
91
+ <span class="material-symbols-outlined text-sm">visibility</span>
92
+ </button>
93
+ </div>
94
+ </div>
95
+
96
+ <% if (error) { %>
97
+ <div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
98
+ <div class="flex items-center">
99
+ <span class="material-symbols-outlined text-red-500 text-sm mr-2">error</span>
100
+ <span class="text-sm text-red-700"><%= error %></span>
101
+ </div>
102
+ </div>
103
+ <% } %>
104
+
105
+ <button
106
+ type="submit"
107
+ id="submitBtn"
108
+ class="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 outline-none transition disabled:opacity-50 disabled:cursor-not-allowed"
109
+ >
110
+ <span id="btnText">Access Export</span>
111
+ <span id="btnSpinner" class="hidden">
112
+ <span class="material-symbols-outlined text-sm animate-spin">refresh</span>
113
+ </span>
114
+ </button>
115
+ </form>
116
+
117
+ <!-- Help Text -->
118
+ <div class="text-center mt-6">
119
+ <p class="text-sm text-gray-500">
120
+ Need help? Contact the person who shared this link.
121
+ </p>
122
+ </div>
123
+ </div>
124
+
125
+ <script>
126
+ // Password visibility toggle
127
+ const togglePassword = document.getElementById('togglePassword');
128
+ const passwordInput = document.getElementById('password');
129
+ const passwordForm = document.getElementById('passwordForm');
130
+ const submitBtn = document.getElementById('submitBtn');
131
+ const btnText = document.getElementById('btnText');
132
+ const btnSpinner = document.getElementById('btnSpinner');
133
+
134
+ togglePassword.addEventListener('click', () => {
135
+ const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
136
+ passwordInput.setAttribute('type', type);
137
+
138
+ const icon = togglePassword.querySelector('.material-symbols-outlined');
139
+ icon.textContent = type === 'password' ? 'visibility' : 'visibility_off';
140
+ });
141
+
142
+ // Form submission
143
+ passwordForm.addEventListener('submit', async (e) => {
144
+ e.preventDefault();
145
+
146
+ const password = passwordInput.value.trim();
147
+ if (!password) {
148
+ passwordInput.focus();
149
+ return;
150
+ }
151
+
152
+ // Show loading state
153
+ submitBtn.disabled = true;
154
+ btnText.classList.add('hidden');
155
+ btnSpinner.classList.remove('hidden');
156
+
157
+ try {
158
+ const response = await fetch('/share/export/<%= exportName %>/auth', {
159
+ method: 'POST',
160
+ headers: {
161
+ 'Content-Type': 'application/json',
162
+ },
163
+ body: JSON.stringify({
164
+ password: password,
165
+ format: '<%= format %>'
166
+ })
167
+ });
168
+
169
+ const data = await response.json();
170
+
171
+ if (response.ok) {
172
+ // Success - redirect to download
173
+ window.location.href = data.downloadUrl;
174
+ } else {
175
+ // Error - show message and reload page with error
176
+ const url = new URL(window.location);
177
+ url.searchParams.set('error', encodeURIComponent(data.error || 'Invalid password'));
178
+ window.location.href = url.toString();
179
+ }
180
+ } catch (error) {
181
+ console.error('Authentication error:', error);
182
+ const url = new URL(window.location);
183
+ url.searchParams.set('error', encodeURIComponent('Network error. Please try again.'));
184
+ window.location.href = url.toString();
185
+ }
186
+ });
187
+
188
+ // Focus password field on load
189
+ passwordInput.focus();
190
+ </script>
191
+ </body>
192
+ </html>