mbkbucket 1.1.2 → 1.2.1

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.
@@ -0,0 +1,1515 @@
1
+ // Global state
2
+ let currentPage = 1;
3
+ let currentPrefix = '';
4
+ let currentSearch = '';
5
+ let currentSort = 'name';
6
+ let currentOrder = 'asc';
7
+ let currentViewMode = 'folder'; // 'folder' or 'flat'
8
+ let pageFiles = [];
9
+ let pageFolders = [];
10
+ const bucketAppName = document.getElementById('bucketAppData')?.dataset.appName || '';
11
+ // Bucket is configured centrally on the server; client should not select a bucket.
12
+
13
+ // Show alert message (Toast)
14
+ function showAlert(message, type = 'success') {
15
+ const container = document.getElementById('toast-container');
16
+ if (!container) {
17
+ // Fallback to old method if container missing
18
+ const alertId = type === 'success' ? 'successAlert' : 'errorAlert';
19
+ const messageId = type === 'success' ? 'successMessage' : 'errorMessage';
20
+ const alertEl = document.getElementById(alertId);
21
+ const messageEl = document.getElementById(messageId);
22
+ if (alertEl && messageEl) {
23
+ messageEl.textContent = message;
24
+ alertEl.style.display = 'flex';
25
+ setTimeout(() => alertEl.style.display = 'none', 5000);
26
+ }
27
+ return;
28
+ }
29
+
30
+ // Create toast element
31
+ const toast = document.createElement('div');
32
+ const isSuccess = type === 'success';
33
+ toast.className = `toast-notification ${type}`;
34
+ toast.style.cssText = `
35
+ background: ${isSuccess ? 'linear-gradient(135deg, #10b981, #059669)' : 'linear-gradient(135deg, #ef4444, #dc2626)'};
36
+ color: white;
37
+ padding: 1rem 1.5rem;
38
+ border-radius: 8px;
39
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 12px;
43
+ min-width: 300px;
44
+ transform: translateX(100%);
45
+ opacity: 0;
46
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
47
+ font-weight: 500;
48
+ `;
49
+
50
+ toast.innerHTML = `
51
+ <i class="fas ${isSuccess ? 'fa-check-circle' : 'fa-exclamation-circle'}" style="font-size: 1.25rem;"></i>
52
+ <div style="flex: 1;">${message}</div>
53
+ <button onclick="this.parentElement.remove()" style="background:none; border:none; color:white; cursor:pointer; opacity:0.8; font-size:1.2rem;">&times;</button>
54
+ `;
55
+
56
+ container.appendChild(toast);
57
+
58
+ // Animate in
59
+ requestAnimationFrame(() => {
60
+ toast.style.transform = 'translateX(0)';
61
+ toast.style.opacity = '1';
62
+ });
63
+
64
+ // Auto dismiss
65
+ setTimeout(() => {
66
+ toast.style.transform = 'translateX(100%)';
67
+ toast.style.opacity = '0';
68
+ setTimeout(() => toast.remove(), 300);
69
+ }, 5000);
70
+ }
71
+
72
+ // Fetch and render files
73
+ async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort, order = currentOrder) {
74
+ currentPage = page;
75
+ currentPrefix = prefix;
76
+ currentSearch = search;
77
+ currentSort = sort;
78
+ currentOrder = order;
79
+
80
+ // Update browser URL so navigation is shareable/bookmarkable (folder shown as readable path)
81
+ try {
82
+ const parts = [];
83
+ if (prefix) {
84
+ // preserve slashes in folder param for readability: encode then restore slashes
85
+ const folderEncoded = encodeURIComponent(prefix).replace(/%2F/g, '/');
86
+ parts.push(`folder=${folderEncoded}`);
87
+ }
88
+ if (page && page > 1) parts.push(`page=${page}`);
89
+ if (search) parts.push(`search=${encodeURIComponent(search)}`);
90
+ // Optional: Add sort/order to URL if desired, skipping for simplicity/clean URLs for now
91
+
92
+ const newUrl = '/mbkbucket' + (parts.length ? '?' + parts.join('&') : '');
93
+ window.history.replaceState(null, '', newUrl);
94
+ } catch (e) {
95
+ // Ignore history errors in older browsers
96
+ console.warn('history update failed', e);
97
+ }
98
+
99
+ const container = document.getElementById('fileListContainer');
100
+ const paginationContainer = document.getElementById('paginationContainer');
101
+ const fileListTitle = document.getElementById('fileListTitle');
102
+ const totalFilesCount = document.getElementById('totalFilesCount');
103
+ const filterInfo = document.getElementById('filterInfo');
104
+
105
+ // Show loading skeleton state
106
+ container.innerHTML = `
107
+ <div class="table-responsive">
108
+ <div class="skeleton-row">
109
+ <div class="skeleton-box s-40"></div>
110
+ <div class="skeleton-box s-200"></div>
111
+ <div class="skeleton-box s-80"></div>
112
+ <div class="skeleton-box s-80"></div>
113
+ </div>
114
+ <div class="skeleton-row">
115
+ <div class="skeleton-box s-40"></div>
116
+ <div class="skeleton-box s-200"></div>
117
+ <div class="skeleton-box s-80"></div>
118
+ <div class="skeleton-box s-80"></div>
119
+ </div>
120
+ <div class="skeleton-row">
121
+ <div class="skeleton-box s-40"></div>
122
+ <div class="skeleton-box s-200"></div>
123
+ <div class="skeleton-box s-80"></div>
124
+ <div class="skeleton-box s-80"></div>
125
+ </div>
126
+ </div>
127
+ `;
128
+
129
+ try {
130
+ const searchParam = search ? `&search=${encodeURIComponent(search)}` : '';
131
+ const sortParam = `&sort=${sort}&order=${order}`;
132
+ const response = await fetch(`/mbkbucket/api/files?page=${page}&prefix=${encodeURIComponent(prefix)}${searchParam}${sortParam}`);
133
+ const data = await response.json();
134
+
135
+ if (!data.success) {
136
+ throw new Error(data.error || 'Failed to load files');
137
+ }
138
+
139
+ // Update stats
140
+ totalFilesCount.textContent = `${data.totalFiles} ${search ? 'Results' : 'Files'}`;
141
+
142
+ if (search) {
143
+ filterInfo.textContent = `Search results for: "${search}"`;
144
+ fileListTitle.innerHTML = `Search Results <span class="text-muted">for: </span><span class="badge bg-info">${search}</span>`;
145
+ } else {
146
+ const label = prefix || bucketAppName || 'Root';
147
+ filterInfo.textContent = `Filtered by folder: ${label}`;
148
+ fileListTitle.innerHTML = `Files <span class="text-muted">in: </span><span class="badge bg-info">${label}</span>`;
149
+ }
150
+
151
+ // Render files or empty state
152
+ if (data.files.length === 0 && !prefix) {
153
+ container.innerHTML = `
154
+ <div class="empty-state">
155
+ <i class="fas fa-folder-open"></i>
156
+ <h4>No files found</h4>
157
+ <p>Your bucket is empty. Upload some files to get started!</p>
158
+ <button class="btn btn-lg" onclick="document.getElementById('fileInput').click()">
159
+ <i class="fas fa-plus"></i> Upload Your First File
160
+ </button>
161
+ </div>
162
+ `;
163
+ paginationContainer.style.display = 'none';
164
+ } else {
165
+ // Store for client-side sorting and view switching
166
+ pageFiles = data.files;
167
+
168
+ // Only calculate folders if we are in search or folder mode (for potential future switching)
169
+ // But mainly we rely on currentViewMode in the render function
170
+ pageFolders = search ? [] : extractFoldersAtCurrentLevel(data.files, prefix);
171
+
172
+ renderFilesTable(pageFiles, pageFolders, prefix);
173
+ renderPagination(data);
174
+ paginationContainer.style.display = 'block';
175
+ }
176
+ } catch (error) {
177
+ console.error('Error loading files:', error);
178
+ container.innerHTML = `
179
+ <div class="empty-state">
180
+ <i class="fas fa-exclamation-triangle" style="color: var(--danger-color-ml);"></i>
181
+ <h4>Error Loading Files</h4>
182
+ <p>${error.message}</p>
183
+ <button class="btn btn-primary" onclick="loadFiles(${page}, '${prefix}')">
184
+ <i class="fas fa-redo"></i> Retry
185
+ </button>
186
+ </div>
187
+ `;
188
+ paginationContainer.style.display = 'none';
189
+ }
190
+ }
191
+
192
+ // Toggle sort order
193
+ function toggleSort(field) {
194
+ if (currentSort === field) {
195
+ currentOrder = currentOrder === 'asc' ? 'desc' : 'asc';
196
+ } else {
197
+ currentSort = field;
198
+ currentOrder = 'asc';
199
+ }
200
+
201
+ // Sort local data instead of calling API
202
+ if (pageFiles && pageFiles.length > 0) {
203
+ pageFiles.sort((a, b) => {
204
+ let valA, valB;
205
+ if (field === 'size') {
206
+ valA = a.Size || 0;
207
+ valB = b.Size || 0;
208
+ } else if (field === 'date') {
209
+ valA = new Date(a.LastModified || 0).getTime();
210
+ valB = new Date(b.LastModified || 0).getTime();
211
+ } else {
212
+ valA = (a.Key || '').split('/').pop().toLowerCase();
213
+ valB = (b.Key || '').split('/').pop().toLowerCase();
214
+ }
215
+
216
+ if (valA < valB) return currentOrder === 'asc' ? -1 : 1;
217
+ if (valA > valB) return currentOrder === 'asc' ? 1 : -1;
218
+ return 0;
219
+ });
220
+
221
+ renderFilesTable(pageFiles, pageFolders, currentPrefix);
222
+ }
223
+ }
224
+
225
+ // Perform search
226
+ function performSearch() {
227
+ const searchInput = document.getElementById('searchInput');
228
+ const search = searchInput.value.trim();
229
+ if (search) {
230
+ loadFiles(1, currentPrefix, search);
231
+ }
232
+ }
233
+
234
+ // Clear search
235
+ function clearSearch() {
236
+ document.getElementById('searchInput').value = '';
237
+ loadFiles(1, currentPrefix, '');
238
+ }
239
+
240
+ // Extract unique folders at current level
241
+ function extractFoldersAtCurrentLevel(files, prefix) {
242
+ const folderSet = new Set();
243
+ const prefixDepth = prefix ? prefix.split('/').filter(p => p).length : 0;
244
+
245
+ files.forEach(file => {
246
+ const key = file.Key;
247
+ // Remove prefix from key
248
+ const relativePath = prefix ? key.substring(prefix.length + (prefix.endsWith('/') ? 0 : 1)) : key;
249
+
250
+ // Check if there's a folder in the relative path
251
+ const slashIndex = relativePath.indexOf('/');
252
+ if (slashIndex !== -1) {
253
+ const folderName = relativePath.substring(0, slashIndex);
254
+ const fullFolderPath = prefix ? `${prefix}/${folderName}` : folderName;
255
+ folderSet.add(fullFolderPath);
256
+ }
257
+ });
258
+
259
+ return Array.from(folderSet).sort();
260
+ }
261
+
262
+ // Change view mode
263
+ function changeViewMode(mode) {
264
+ if (currentViewMode === mode) return;
265
+ currentViewMode = mode;
266
+
267
+ // Update button states
268
+ const btnFolder = document.getElementById('viewBtnFolder');
269
+ const btnFlat = document.getElementById('viewBtnFlat');
270
+
271
+ if (mode === 'folder') {
272
+ btnFolder.classList.add('active');
273
+ btnFolder.classList.remove('btn-outline-light');
274
+ btnFolder.classList.add('btn-light');
275
+
276
+ btnFlat.classList.remove('active');
277
+ btnFlat.classList.remove('btn-light');
278
+ btnFlat.classList.add('btn-outline-light');
279
+ } else {
280
+ btnFlat.classList.add('active');
281
+ btnFlat.classList.remove('btn-outline-light');
282
+ btnFlat.classList.add('btn-light');
283
+
284
+ btnFolder.classList.remove('active');
285
+ btnFolder.classList.remove('btn-light');
286
+ btnFolder.classList.add('btn-outline-light');
287
+ }
288
+
289
+ // Re-render with existing data
290
+ renderFilesTable(pageFiles, pageFolders, currentPrefix);
291
+ }
292
+
293
+ // Extract folders that match a search query from file paths
294
+ function extractMatchingFolders(files, search) {
295
+ if (!search) return [];
296
+
297
+ const searchLower = search.toLowerCase();
298
+ const folderSet = new Set();
299
+
300
+ files.forEach(file => {
301
+ const parts = file.Key.split('/');
302
+ // Remove filename
303
+ parts.pop();
304
+
305
+ let currentPath = '';
306
+ parts.forEach(part => {
307
+ const segmentPath = currentPath ? `${currentPath}/${part}` : part;
308
+ currentPath = segmentPath;
309
+
310
+ // If any part of the path matches the search, include that full path as a folder
311
+ // AND ensure we don't include the full file path, just the directory
312
+ if (part.toLowerCase().includes(searchLower)) {
313
+ folderSet.add(currentPath);
314
+ }
315
+ });
316
+ });
317
+
318
+ return Array.from(folderSet).sort();
319
+ }
320
+
321
+ // Render files table
322
+ function renderFilesTable(files, folders = [], prefix = '') {
323
+ // Determine which files/folders to show based on view mode
324
+ let displayFiles = [];
325
+ let displayFolders = [];
326
+
327
+ // Force flat view if searching
328
+ const isSearchActive = !!currentSearch;
329
+ const mode = isSearchActive ? 'flat' : currentViewMode;
330
+
331
+ if (mode === 'flat') {
332
+ // In flat mode, show all files in the current loaded page/list regardless of folder depth
333
+ if (isSearchActive) {
334
+ // Search Mode:
335
+ // 1. Show files where FILENAME matches the search term
336
+ const searchLower = currentSearch.toLowerCase();
337
+ displayFiles = files.filter(f => {
338
+ const fileName = f.Key.split('/').pop();
339
+ return fileName.toLowerCase().includes(searchLower);
340
+ });
341
+
342
+ // 2. Show folders where FOLDER NAME matches the search term
343
+ displayFolders = extractMatchingFolders(files, currentSearch);
344
+ } else {
345
+ // Normal Flat Mode: Show all files, no folders
346
+ displayFiles = files;
347
+ displayFolders = [];
348
+ }
349
+ } else {
350
+ // Normal Folder View logic
351
+ // Filter files to show only those at current level (not in subfolders)
352
+ displayFiles = files.filter(file => {
353
+ const relativePath = prefix ? file.Key.substring(prefix.length + (prefix.endsWith('/') ? 0 : 1)) : file.Key;
354
+ // File is at current level if it has no '/' in relative path
355
+ return !relativePath.includes('/');
356
+ });
357
+ displayFolders = folders;
358
+ }
359
+
360
+ // Generate breadcrumb navigation
361
+ let breadcrumb = '';
362
+ // Show breadcrumb only if we are in folder mode OR if we have a prefix filter active even in flat mode
363
+ // (In flat mode, the prefix acts as a base filter, but we see recursive files under it)
364
+ if (prefix) {
365
+ const parts = prefix.split('/').filter(p => p);
366
+ let breadcrumbHTML = `
367
+ <div class="breadcrumb-nav">
368
+ <div class="breadcrumb-item">
369
+ <i class="fas fa-home"></i>
370
+ <span class="breadcrumb-link" onclick="loadFiles(1, '')" title="Go to root">Root</span>
371
+ </div>
372
+ `;
373
+
374
+ let currentPath = '';
375
+ parts.forEach((part, index) => {
376
+ currentPath += (currentPath ? '/' : '') + part;
377
+ const isLast = index === parts.length - 1;
378
+
379
+ if (isLast) {
380
+ breadcrumbHTML += `
381
+ <span class="breadcrumb-separator"><i class="fas fa-chevron-right"></i></span>
382
+ <div class="breadcrumb-item">
383
+ <span class="breadcrumb-current">${part}</span>
384
+ </div>
385
+ `;
386
+ } else {
387
+ const pathCopy = currentPath;
388
+ breadcrumbHTML += `
389
+ <span class="breadcrumb-separator"><i class="fas fa-chevron-right"></i></span>
390
+ <div class="breadcrumb-item">
391
+ <span class="breadcrumb-link" onclick="loadFiles(1, '${pathCopy}')" title="Go to ${pathCopy}">${part}</span>
392
+ </div>
393
+ `;
394
+ }
395
+ });
396
+
397
+ breadcrumbHTML += `</div>`;
398
+ breadcrumb = breadcrumbHTML;
399
+ }
400
+
401
+ // Generate folder rows (only used in folder mode)
402
+ const folderRows = displayFolders.map(folderPath => {
403
+ const folderName = folderPath.split('/').pop();
404
+ return `
405
+ <tr class="folder-row" onclick="loadFiles(1, '${folderPath}')" title="Open folder: ${folderName}">
406
+ <td style="text-align:center;">
407
+ <!-- no selection for folders -->
408
+ </td>
409
+ <td style="padding-left: 1.5rem;">
410
+ <div style="display: flex; align-items: center;">
411
+ <div class="file-icon">
412
+ <i class="fas fa-folder"></i>
413
+ </div>
414
+ <div class="file-info">
415
+ <div class="file-name" style="font-weight: 700;">
416
+ <i class="fas fa-folder" style="margin-right: 0.5rem; color: #f59e0b;"></i>${folderName}/
417
+ </div>
418
+ </div>
419
+ <button type="button" class="btn btn-danger btn-sm delete-btn dtm" onclick="event.stopPropagation(); confirmDelete('${folderPath}/', true);" data-key="${folderPath}/" data-is-folder="true" title="Delete folder">
420
+ <i class="fas fa-trash"></i>
421
+ </button>
422
+ </div>
423
+ </td>
424
+ <td>
425
+ <span style="color: var(--gray-color-ml);">\u2014</span>
426
+ </td>
427
+ <td style="text-align: center;">
428
+ <span style="color: var(--gray-color-ml);">\u2014</span>
429
+ </td>
430
+ <td style="text-align: center;">
431
+ <div class="btn-group" role="group">
432
+ <button type="button" class="btn btn-danger btn-sm delete-btn" onclick="event.stopPropagation(); confirmDelete('${folderPath}/', true);" data-key="${folderPath}/" data-is-folder="true" title="Delete folder">
433
+ <i class="fas fa-trash"></i>
434
+ </button>
435
+ </div>
436
+ </td>
437
+ </tr>
438
+ `;
439
+ }).join('');
440
+
441
+ // Generate file rows
442
+ const fileRows = displayFiles.map(file => {
443
+ const encodedKey = encodeURIComponent(file.Key);
444
+
445
+ // Calculate display name and icon directly
446
+ const fullPath = file.Key;
447
+ const lastSlashIndex = fullPath.lastIndexOf('/');
448
+ let filename = fullPath;
449
+ let folderPath = '';
450
+
451
+ if (lastSlashIndex !== -1) {
452
+ filename = fullPath.substring(lastSlashIndex + 1);
453
+ folderPath = fullPath.substring(0, lastSlashIndex + 1); // include trailing slash
454
+ }
455
+
456
+ const iconClass = getFileIcon(filename);
457
+ const typeCategory = getFileTypeCategory(filename);
458
+ const truncatedName = truncateFileName(filename);
459
+ const isFileViewable = isViewable(filename);
460
+
461
+ // If in flat mode, we might want to show the folder path more prominently
462
+ const pathDisplay = (currentViewMode === 'flat' && folderPath && folderPath !== prefix + (prefix ? '/' : ''))
463
+ ? `<div style="font-size: 0.75rem; color: var(--gray-color-ml); margin-bottom:2px;">
464
+ <i class="fas fa-folder" style="margin-right:4px; opacity:0.5;"></i> ${folderPath}
465
+ </div>`
466
+ : '';
467
+
468
+ return `
469
+ <tr class="file-row">
470
+ <td style="text-align:center;">
471
+ <input type="checkbox" class="selectFileCheckbox" data-key="${file.Key}" aria-label="Select ${file.Key}" />
472
+ </td>
473
+ <td style="padding-left: 1.5rem;">
474
+ <div style="display: flex; align-items: center;">
475
+ <div class="file-icon ${typeCategory}">
476
+ <i class="${iconClass}"></i>
477
+ </div>
478
+ <div class="file-info">
479
+ ${pathDisplay}
480
+ <div class="file-name" data-full-path="${file.Key}" title="${filename}">${truncatedName}</div>
481
+ </div>
482
+ </div>
483
+ </td>
484
+ <td>
485
+ ${file.Size ? `<span class="file-size">${formatFileSize(file.Size)}</span>` : '<span style="color: var(--gray-color-ml);">N/A</span>'}
486
+ </td>
487
+ <td>
488
+ ${file.LastModified ? `<span style="color: var(--gray-color-ml);">${formatDate(file.LastModified)}</span>` : '<span style="color: var(--gray-color-ml);">N/A</span>'}
489
+ </td>
490
+ <td style="text-align: center;">
491
+ <div class="btn-group" role="group">
492
+ <button type="button" class="btn btn-primary btn-sm view-btn" data-key="${file.Key}" data-filename="${file.Key}"
493
+ data-key-enc="${encodedKey}" title="${isFileViewable ? 'View file in browser' : 'File type not supported for viewing'}" ${!isFileViewable ? 'disabled style="opacity:0.5"' : ''}>
494
+ <i class="fas fa-eye"></i>
495
+ </button>
496
+ <a href="/mbkbucket/download/${encodedKey}" class="btn btn-success btn-sm"
497
+ title="Download file">
498
+ <i class="fas fa-download"></i>
499
+ </a>
500
+ <button type="button" class="btn btn-danger btn-sm delete-btn" data-key="${file.Key}" title="Delete file">
501
+ <i class="fas fa-trash"></i>
502
+ </button>
503
+ </div>
504
+ </td>
505
+ </tr>
506
+ `;
507
+ }).join('');
508
+
509
+ const allRows = folderRows + fileRows;
510
+
511
+ if (allRows === '') {
512
+ document.getElementById('fileListContainer').innerHTML = `
513
+ <div class="empty-state">
514
+ <div style="margin-bottom: 1.5rem; position: relative; display: inline-block;">
515
+ <i class="fas fa-folder-open" style="font-size: 4rem; color: #cbd5e0;"></i>
516
+ <i class="fas fa-search" style="font-size: 2rem; color: var(--primary-color-ml); position: absolute; bottom: -5px; right: -10px; background: white; border-radius: 50%; padding: 5px;"></i>
517
+ </div>
518
+ <h4>No files found</h4>
519
+ <p style="max-width: 400px; margin: 0 auto 1.5rem; color: var(--text-muted-color-ml);">
520
+ ${prefix ? `This folder <strong>${prefix}</strong> is currently empty.` : 'Your bucket is empty. Start by uploading files or creating a new folder.'}
521
+ </p>
522
+ <div style="display:flex; gap:0.75rem; justify-content:center; flex-wrap: wrap;">
523
+ <button class="btn btn-primary" onclick="window.scrollTo({top: 0, behavior: 'smooth'}); document.getElementById('fileInput').click()">
524
+ <i class="fas fa-cloud-upload-alt"></i> Upload Files
525
+ </button>
526
+ <button class="btn btn-outline-secondary" onclick="createFolder()">
527
+ <i class="fas fa-folder-plus"></i> New Folder
528
+ </button>
529
+ ${prefix ? `<button class="btn btn-light" onclick="loadFiles(1, '')">
530
+ <i class="fas fa-home"></i> Go to Root
531
+ </button>` : ''}
532
+ </div>
533
+ </div>
534
+ `;
535
+ return;
536
+ }
537
+
538
+ // Sort icon helper
539
+ const getSortIcon = (field) => {
540
+ if (currentSort !== field) return '<i class="fas fa-sort" style="color:var(--gray-color-ml); opacity:0.3; font-size:0.8rem; margin-left:0.5rem;"></i>';
541
+ return currentOrder === 'asc'
542
+ ? '<i class="fas fa-sort-up" style="color:var(--primary-color-ml); font-size:0.9rem; margin-left:0.5rem;"></i>'
543
+ : '<i class="fas fa-sort-down" style="color:var(--primary-color-ml); font-size:0.9rem; margin-left:0.5rem;"></i>';
544
+ };
545
+
546
+ document.getElementById('fileListContainer').innerHTML = `
547
+ ${breadcrumb}
548
+ <div class="table-responsive">
549
+ <div id="bulkActionsBar" style="display:none; padding: 0.5rem; border-bottom: 1px solid var(--gray-light-color-ml); background: var(--light-color-ml);">
550
+ <span id="bulkSelectedCount">0 selected</span>
551
+ <div style="float: right;">
552
+ <button class="btn btn-danger btn-sm" id="bulkDeleteBtn" disabled>
553
+ <i class="fas fa-trash"></i> Delete Selected
554
+ </button>
555
+ </div>
556
+ </div>
557
+ <table class="table table-hover mb-0">
558
+ <thead>
559
+ <tr>
560
+ <th style="width:48px; text-align:center;">
561
+ <input type="checkbox" id="selectAllCheckbox" aria-label="Select all files" />
562
+ </th>
563
+ <th style="padding-left: 1.5rem; cursor: pointer;" onclick="toggleSort('name')">
564
+ <i class="fas fa-file-alt"></i> Name ${getSortIcon('name')}
565
+ </th>
566
+ <th style="cursor: pointer;" onclick="toggleSort('size')">
567
+ <i class="fas fa-weight"></i> Size ${getSortIcon('size')}
568
+ </th>
569
+ <th style="cursor: pointer;" onclick="toggleSort('date')">
570
+ <i class="fas fa-calendar"></i> Last Modified ${getSortIcon('date')}
571
+ </th>
572
+ <th style="text-align: center;">
573
+ <i class="fas fa-cogs"></i> Actions
574
+ </th>
575
+ </tr>
576
+ </thead>
577
+ <tbody>
578
+ ${allRows}
579
+ </tbody>
580
+ </table>
581
+ </div>
582
+ `;
583
+
584
+ // Wire selection checkboxes and bulk actions
585
+ setupSelection();
586
+ }
587
+
588
+ // Render pagination
589
+ function renderPagination(data) {
590
+ const { currentPage, totalPages, hasPrevPage, hasNextPage } = data;
591
+
592
+ let paginationHTML = '';
593
+
594
+ // Previous button
595
+ if (hasPrevPage) {
596
+ paginationHTML += `
597
+ <li class="page-item">
598
+ <a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(${currentPage - 1}, '${currentPrefix}', '${currentSearch}')">
599
+ <i class="fas fa-chevron-left"></i> Previous
600
+ </a>
601
+ </li>
602
+ `;
603
+ } else {
604
+ paginationHTML += `
605
+ <li class="page-item disabled">
606
+ <span class="page-link">
607
+ <i class="fas fa-chevron-left"></i> Previous
608
+ </span>
609
+ </li>
610
+ `;
611
+ }
612
+
613
+ // Page numbers
614
+ for (let i = 1; i <= totalPages; i++) {
615
+ if (i === currentPage) {
616
+ paginationHTML += `
617
+ <li class="page-item active">
618
+ <span class="page-link">${i}</span>
619
+ </li>
620
+ `;
621
+ } else {
622
+ paginationHTML += `
623
+ <li class="page-item">
624
+ <a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(${i}, '${currentPrefix}', '${currentSearch}')">${i}</a>
625
+ </li>
626
+ `;
627
+ }
628
+ }
629
+
630
+ // Next button
631
+ if (hasNextPage) {
632
+ paginationHTML += `
633
+ <li class="page-item">
634
+ <a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(${currentPage + 1}, '${currentPrefix}', '${currentSearch}')">
635
+ Next <i class="fas fa-chevron-right"></i>
636
+ </a>
637
+ </li>
638
+ `;
639
+ } else {
640
+ paginationHTML += `
641
+ <li class="page-item disabled">
642
+ <span class="page-link">
643
+ Next <i class="fas fa-chevron-right"></i>
644
+ </span>
645
+ </li>
646
+ `;
647
+ }
648
+
649
+ document.getElementById('paginationList').innerHTML = paginationHTML;
650
+ }
651
+
652
+ function confirmDelete(key, isFolder = false) {
653
+ document.getElementById('deleteKey').value = key;
654
+ document.getElementById('deleteIsFolder').value = isFolder ? 'true' : '';
655
+ document.getElementById('deleteFileName').textContent = key;
656
+
657
+ // Update modal message and button label for folders
658
+ const modalHeading = document.querySelector('#deleteModal .modal-body h6');
659
+ const smallText = document.querySelector('#deleteModal .modal-body small');
660
+ const confirmLabel = document.getElementById('deleteConfirmLabel');
661
+
662
+ if (isFolder) {
663
+ if (modalHeading) modalHeading.textContent = 'Are you sure you want to delete this folder and all its contents?';
664
+ if (smallText) smallText.textContent = 'This action will remove the folder and all files within it and cannot be undone.';
665
+ if (confirmLabel) confirmLabel.textContent = 'Delete Folder';
666
+ } else {
667
+ if (modalHeading) modalHeading.textContent = 'Are you sure you want to delete this file?';
668
+ if (smallText) smallText.textContent = 'This action cannot be undone.';
669
+ if (confirmLabel) confirmLabel.textContent = 'Delete File';
670
+ }
671
+
672
+ document.getElementById('deleteModal').classList.add('show');
673
+ document.getElementById('deleteModal').style.display = 'flex';
674
+ }
675
+
676
+ function closeModal() {
677
+ document.getElementById('deleteModal').classList.remove('show');
678
+ document.getElementById('deleteModal').style.display = 'none';
679
+ }
680
+
681
+ // Handle delete form submission
682
+ document.getElementById('deleteForm')?.addEventListener('submit', async function (e) {
683
+ e.preventDefault();
684
+
685
+ const key = document.getElementById('deleteKey').value;
686
+ const isFolder = document.getElementById('deleteIsFolder').value === 'true';
687
+
688
+ try {
689
+ const response = await fetch('/mbkbucket/delete', {
690
+ method: 'POST',
691
+ headers: {
692
+ 'Content-Type': 'application/json'
693
+ },
694
+ body: JSON.stringify({ key, folder: isFolder })
695
+ });
696
+
697
+ const data = await response.json();
698
+
699
+ if (data.success) {
700
+ showAlert('File deleted successfully', 'success');
701
+ closeModal();
702
+ // Reload file list
703
+ loadFiles(currentPage, currentPrefix);
704
+ } else {
705
+ showAlert(data.error || 'Delete failed', 'error');
706
+ closeModal();
707
+ }
708
+ } catch (error) {
709
+ console.error('Error deleting file:', error);
710
+ showAlert('Error deleting file: ' + error.message, 'error');
711
+ closeModal();
712
+ }
713
+ });
714
+
715
+ // Auto-dismiss alerts with fade effect
716
+ setTimeout(() => {
717
+ const alerts = document.querySelectorAll('.alert');
718
+ alerts.forEach(alert => {
719
+ alert.style.opacity = '0';
720
+ alert.style.transform = 'translateY(-10px)';
721
+ setTimeout(() => alert.remove(), 300);
722
+ });
723
+ }, 5000);
724
+
725
+ // Upload queue, drag-and-drop and multi-file support
726
+ const uploadQueue = []; // { id, file, status, progress, controller/xhr, uploadId }
727
+ const xhrMap = new Map();
728
+ const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunk threshold
729
+
730
+ function createId() { return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 9); }
731
+
732
+ function addFilesToQueue(fileList) {
733
+ for (const f of Array.from(fileList)) {
734
+ uploadQueue.push({ id: createId(), file: f, status: 'queued', progress: 0 });
735
+ }
736
+ renderUploadQueue();
737
+ }
738
+
739
+ // Render upload queue UI
740
+ function renderUploadQueue() {
741
+ const container = document.getElementById('uploadQueue');
742
+ if (!container) return;
743
+ if (uploadQueue.length === 0) {
744
+ container.innerHTML = `
745
+ <div class="form-text">No files selected.</div>
746
+ `;
747
+ return;
748
+ }
749
+
750
+ container.innerHTML = uploadQueue.map(item => `
751
+ <div class="upload-item" data-id="${item.id}">
752
+ <div class="meta">
753
+ <div style="display:flex; align-items:center; gap:0.75rem; min-width:0; width:100%; margin-bottom:5px;">
754
+ <div class="file-icon"><i class="fas fa-file"></i></div>
755
+ <div class="name">${item.file.name}</div>
756
+ <div class="form-text file-size" style="white-space:nowrap; color:var(--text-muted-color-ml); font-size:0.875rem; margin-left:auto;">${formatFileSize(item.file.size)}</div>
757
+ </div>
758
+ </div>
759
+ <div style="min-width:220px;">
760
+ <div class="progress-container" style="height: 8px; margin-bottom: 6px;">
761
+ <div class="progress-bar" style="width: ${Math.round(item.progress)}%; height: 8px;"></div>
762
+ </div>
763
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:0.5rem;">
764
+ <small class="text-muted status">${item.status}</small>
765
+ <div class="controls">
766
+ ${item.status === 'uploading' ? `<button class="btn btn-sm btn-outline-secondary cancel-upload" data-id="${item.id}"><i class="fas fa-ban"></i></button>` : `<button class="btn btn-sm btn-danger remove-queued" data-id="${item.id}"><i class="fas fa-trash"></i></button>`}
767
+ </div>
768
+ </div>
769
+ </div>
770
+ </div>
771
+ `).join('');
772
+
773
+ // Wire up controls
774
+ document.querySelectorAll('.remove-queued').forEach(btn => btn.addEventListener('click', function (e) {
775
+ const id = this.dataset.id;
776
+ const idx = uploadQueue.findIndex(i => i.id === id);
777
+ if (idx !== -1) uploadQueue.splice(idx, 1);
778
+ renderUploadQueue();
779
+ }));
780
+
781
+ document.querySelectorAll('.cancel-upload').forEach(btn => btn.addEventListener('click', function (e) {
782
+ const id = this.dataset.id;
783
+ const handle = xhrMap.get(id);
784
+ if (handle && handle.abort) {
785
+ handle.abort();
786
+ } else if (handle && handle.xhr) {
787
+ handle.xhr.abort();
788
+ }
789
+ const entry = uploadQueue.find(i => i.id === id);
790
+ if (entry) { entry.status = 'cancelled'; entry.progress = 0; }
791
+ xhrMap.delete(id);
792
+ renderUploadQueue();
793
+ }));
794
+ }
795
+
796
+ // Drag & drop wiring
797
+ const dropZone = document.getElementById('dropZone');
798
+ const fileInputEl = document.getElementById('fileInput');
799
+ if (dropZone) {
800
+ dropZone.addEventListener('click', () => fileInputEl.click());
801
+ dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
802
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
803
+ dropZone.addEventListener('drop', (e) => {
804
+ e.preventDefault(); dropZone.classList.remove('dragover');
805
+ if (e.dataTransfer && e.dataTransfer.files) addFilesToQueue(e.dataTransfer.files);
806
+ });
807
+ }
808
+
809
+ fileInputEl?.addEventListener('change', function (e) {
810
+ if (e.target.files && e.target.files.length) {
811
+ addFilesToQueue(e.target.files);
812
+ }
813
+ });
814
+
815
+ // Start queued uploads (single or chunked) and provide cancelation controls
816
+ async function startUploadEntry(entry) {
817
+ if (!entry || entry.status === 'uploading' || entry.status === 'done') return;
818
+ entry.status = 'uploading';
819
+ renderUploadQueue();
820
+
821
+ const file = entry.file;
822
+ try {
823
+ if (file.size > CHUNK_SIZE) {
824
+ // chunked upload
825
+ const uploadId = createId();
826
+ entry.uploadId = uploadId;
827
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
828
+ let uploadedBytes = 0;
829
+ for (let i = 0; i < totalChunks; i++) {
830
+ if (entry.status === 'cancelled') throw new Error('Upload cancelled');
831
+ const start = i * CHUNK_SIZE;
832
+ const end = Math.min(file.size, start + CHUNK_SIZE);
833
+ const blob = file.slice(start, end);
834
+
835
+ const fd = new FormData();
836
+ fd.append('chunk', blob, file.name);
837
+ fd.append('uploadId', uploadId);
838
+ fd.append('fileName', file.name);
839
+ fd.append('chunkIndex', String(i));
840
+ fd.append('totalChunks', String(totalChunks));
841
+
842
+ const controller = new AbortController();
843
+ xhrMap.set(entry.id, { controller });
844
+
845
+ const resp = await fetch('/mbkbucket/upload-chunk', { method: 'POST', body: fd, signal: controller.signal });
846
+ if (!resp.ok) throw new Error('Chunk upload failed');
847
+ uploadedBytes += blob.size;
848
+ entry.progress = (uploadedBytes / file.size) * 95; // up to 95% until assembly
849
+ renderUploadQueue();
850
+ }
851
+
852
+ // complete
853
+ const completeResp = await fetch('/mbkbucket/upload-complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId: entry.uploadId, fileName: file.name, prefix: currentPrefix, contentType: file.type || 'application/octet-stream' }) });
854
+ const completeJson = await completeResp.json();
855
+ if (!completeResp.ok || !completeJson.success) throw new Error(completeJson.error || 'Assemble failed');
856
+
857
+ entry.status = 'done'; entry.progress = 100; xhrMap.delete(entry.id); renderUploadQueue();
858
+ showAlert('File uploaded successfully', 'success');
859
+ loadFiles(currentPage, currentPrefix);
860
+ } else {
861
+ // small file XHR
862
+ const fd = new FormData();
863
+ fd.append('file', file, file.name);
864
+ fd.append('prefix', currentPrefix);
865
+
866
+ const xhr = new XMLHttpRequest();
867
+ xhr.open('POST', '/mbkbucket/upload');
868
+
869
+ xhr.upload.addEventListener('progress', (e) => {
870
+ if (!e.lengthComputable) return;
871
+ entry.progress = (e.loaded / e.total) * 100;
872
+ renderUploadQueue();
873
+ });
874
+
875
+ xhr.addEventListener('load', () => {
876
+ try {
877
+ const resp = JSON.parse(xhr.responseText);
878
+ if (xhr.status >= 200 && xhr.status < 300 && resp.success) {
879
+ entry.status = 'done'; entry.progress = 100; renderUploadQueue();
880
+ showAlert('File uploaded successfully', 'success');
881
+ loadFiles(currentPage, currentPrefix);
882
+ } else {
883
+ entry.status = 'error'; renderUploadQueue();
884
+ showAlert(resp.error || 'Upload failed', 'error');
885
+ }
886
+ } catch (err) {
887
+ entry.status = 'error'; renderUploadQueue();
888
+ showAlert('Upload response parse error', 'error');
889
+ }
890
+ xhrMap.delete(entry.id);
891
+ });
892
+
893
+ xhr.addEventListener('error', () => { entry.status = 'error'; renderUploadQueue(); showAlert('Network error during upload', 'error'); xhrMap.delete(entry.id); });
894
+ xhr.addEventListener('abort', () => { entry.status = 'cancelled'; renderUploadQueue(); xhrMap.delete(entry.id); });
895
+
896
+ xhrMap.set(entry.id, { xhr });
897
+ xhr.send(fd);
898
+ }
899
+ } catch (err) {
900
+ console.error('Upload failed for', entry.file.name, err);
901
+ entry.status = entry.status === 'cancelled' ? 'cancelled' : 'error';
902
+ renderUploadQueue();
903
+ showAlert('Upload failed: ' + (err.message || 'unknown'), 'error');
904
+ }
905
+ }
906
+
907
+ function startAllUploads() {
908
+ for (const entry of uploadQueue.slice()) {
909
+ if (entry.status === 'queued' || entry.status === 'error') startUploadEntry(entry);
910
+ }
911
+ }
912
+
913
+ document.getElementById('startUploadBtn')?.addEventListener('click', startAllUploads);
914
+ document.getElementById('clearQueueBtn')?.addEventListener('click', function () { uploadQueue.length = 0; renderUploadQueue(); });
915
+
916
+
917
+
918
+ // Add hover effects and animations
919
+ document.addEventListener('DOMContentLoaded', function () {
920
+ // Load initial files (support both legacy 'prefix' and new 'folder' query param)
921
+ const urlParams = new URLSearchParams(window.location.search);
922
+ const initialPrefix = urlParams.get('folder') || urlParams.get('prefix') || bucketAppName || '';
923
+ const initialPage = parseInt(urlParams.get('page')) || 1;
924
+ loadFiles(initialPage, initialPrefix);
925
+
926
+ // Search behavior: Trigger when the Search button is clicked OR when the user presses Enter in the search input
927
+ const searchInputEl = document.getElementById('searchInput');
928
+ if (searchInputEl) {
929
+ // Trigger search when Enter is pressed in the input
930
+ searchInputEl.addEventListener('keydown', function (e) {
931
+ if (e.key === 'Enter') {
932
+ e.preventDefault();
933
+ performSearch();
934
+ }
935
+ });
936
+ }
937
+
938
+ // Close modal when clicking outside
939
+ document.getElementById('deleteModal').addEventListener('click', function (e) {
940
+ if (e.target === this) {
941
+ closeModal();
942
+ }
943
+ });
944
+
945
+ // Delegate view and delete button clicks to avoid inline JS with unescaped data
946
+ document.addEventListener('click', function (e) {
947
+ const vbtn = e.target.closest('.view-btn');
948
+ if (vbtn) {
949
+ const keyEnc = vbtn.dataset.keyEnc;
950
+ const key = keyEnc || vbtn.dataset.key;
951
+ const filename = vbtn.dataset.filename || (vbtn.dataset.key || key);
952
+ handleViewFile(key, filename, e);
953
+ return;
954
+ }
955
+
956
+ const dbtn = e.target.closest('.delete-btn');
957
+ if (dbtn) {
958
+ e.stopPropagation();
959
+ const key = dbtn.dataset.key;
960
+ const isFolder = dbtn.dataset.isFolder === 'true';
961
+ if (key) confirmDelete(key, isFolder);
962
+ return;
963
+ }
964
+
965
+
966
+ });
967
+ });
968
+
969
+ // Function to check if file can be viewed in browser
970
+
971
+ // Enhanced view button click handler
972
+ function handleViewFile(key, filename, event) {
973
+ // Prevent default action and event bubbling
974
+ if (event) {
975
+ event.preventDefault();
976
+ event.stopPropagation();
977
+ }
978
+
979
+ if (!isViewable(filename)) {
980
+ alert(`File type "${filename.split('.').pop().toUpperCase()}" is not supported for viewing in browser.\n\nSupported types include: Images, Videos (MP4, WebM), Audio (MP3, WAV), Text files, Code files, and PDF documents.`);
981
+ return false;
982
+ }
983
+
984
+ // Determine file type
985
+ const extension = filename.split('.').pop().toLowerCase();
986
+ const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'];
987
+ const videoTypes = ['mp4', 'webm', 'ogg'];
988
+
989
+ // Use inline preview modal for supported types
990
+ const previewableImage = imageTypes.includes(extension);
991
+ const previewableVideo = videoTypes.includes(extension);
992
+ const previewableAudio = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(extension);
993
+ const previewablePdf = extension === 'pdf';
994
+ const previewableText = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'js', 'ts', 'html', 'htm', 'css', 'py', 'java', 'cpp', 'c', 'h', 'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml', 'toml', 'ini', 'conf'].includes(extension);
995
+
996
+ // Build an encoded key safely (if key already appears encoded, don't double-encode)
997
+ const encodedKey = (key && key.includes('%')) ? key : encodeURIComponent(key || filename);
998
+
999
+ // Show the inline preview
1000
+ showPreviewModal(encodedKey, filename, extension);
1001
+ return false;
1002
+ }
1003
+
1004
+ // Function to get file type category
1005
+
1006
+ // Preview modal helpers
1007
+ function showPreviewModal(encodedKey, filename, extension) {
1008
+ const modal = document.getElementById('previewModal');
1009
+ if (!modal) {
1010
+ window.open(`/mbkbucket/view/${encodedKey}`, '_blank', 'noopener,noreferrer');
1011
+ return;
1012
+ }
1013
+
1014
+ const dialog = modal.querySelector('.modal-dialog');
1015
+ const titleEl = document.getElementById('previewFileName');
1016
+ const contentEl = modal.querySelector('.preview-content');
1017
+ const openBtn = document.getElementById('previewOpenBtn');
1018
+ const downloadBtn = document.getElementById('previewDownloadBtn');
1019
+ const closeBtn = document.getElementById('previewCloseBtn');
1020
+ const closeFooter = document.getElementById('previewCloseFooter');
1021
+ const fsBtn = document.getElementById('previewFullscreenBtn');
1022
+
1023
+ titleEl.textContent = filename;
1024
+ openBtn.href = `/mbkbucket/view/${encodedKey}`;
1025
+ downloadBtn.href = `/mbkbucket/download/${encodedKey}`;
1026
+ // Ensure downloaded file has the original filename
1027
+ downloadBtn.setAttribute('download', filename);
1028
+
1029
+ // Reset sizing classes
1030
+ dialog.classList.remove('preview-large', 'preview-compact');
1031
+
1032
+ // Clear previous content and show loading
1033
+ contentEl.innerHTML = `<div class="empty-preview"><i class="fas fa-spinner fa-spin"></i><div style="margin-top:0.5rem; color:var(--gray-color-ml);">Loading preview...</div></div>`;
1034
+
1035
+ const imgTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'];
1036
+ const videoTypes = ['mp4', 'webm', 'ogg'];
1037
+ const audioTypes = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
1038
+ const textTypes = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'js', 'ts', 'html', 'htm', 'css', 'py', 'java', 'cpp', 'c', 'h', 'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml', 'toml', 'ini', 'conf'];
1039
+
1040
+ // Render content and adjust dialog size
1041
+ if (imgTypes.includes(extension)) {
1042
+ dialog.classList.add('preview-compact');
1043
+ const img = document.createElement('img');
1044
+ img.className = 'preview-img';
1045
+ img.src = `/mbkbucket/view/${encodedKey}`;
1046
+ img.alt = filename;
1047
+ img.onload = () => { };
1048
+ img.onerror = () => { contentEl.textContent = 'Could not load preview.'; };
1049
+ contentEl.innerHTML = '';
1050
+ contentEl.appendChild(img);
1051
+ } else if (videoTypes.includes(extension)) {
1052
+ dialog.classList.add('preview-large');
1053
+ const video = document.createElement('video');
1054
+ video.className = 'preview-video';
1055
+ video.controls = true;
1056
+ video.src = `/mbkbucket/view/${encodedKey}`;
1057
+ contentEl.innerHTML = '';
1058
+ contentEl.appendChild(video);
1059
+ } else if (audioTypes.includes(extension)) {
1060
+ dialog.classList.add('preview-compact');
1061
+ const audio = document.createElement('audio');
1062
+ audio.className = 'preview-audio';
1063
+ audio.controls = true;
1064
+ audio.src = `/mbkbucket/view/${encodedKey}`;
1065
+ contentEl.innerHTML = '';
1066
+ contentEl.appendChild(audio);
1067
+ } else if (extension === 'pdf') {
1068
+ dialog.classList.add('preview-large');
1069
+ const iframe = document.createElement('iframe');
1070
+ iframe.className = 'preview-iframe';
1071
+ iframe.src = `/mbkbucket/view/${encodedKey}`;
1072
+ contentEl.innerHTML = '';
1073
+ contentEl.appendChild(iframe);
1074
+ } else if (textTypes.includes(extension)) {
1075
+ dialog.classList.add('preview-compact');
1076
+
1077
+ fetch(`/mbkbucket/view/${encodedKey}`)
1078
+ .then(r => r.text())
1079
+ .then(text => {
1080
+ contentEl.innerHTML = '';
1081
+ const pre = document.createElement('pre');
1082
+ pre.textContent = text;
1083
+ pre.className = 'preview-text';
1084
+ contentEl.appendChild(pre);
1085
+ })
1086
+ .catch(err => { contentEl.textContent = 'Could not load preview.'; });
1087
+ } else {
1088
+ contentEl.innerHTML = `<div style="text-align:center;">Preview not available for this file type. <a href="/mbkbucket/download/${encodedKey}">Download</a></div>`;
1089
+ }
1090
+
1091
+ // Show modal
1092
+ modal.classList.add('show');
1093
+ modal.style.display = 'flex';
1094
+ modal.setAttribute('aria-hidden', 'false');
1095
+
1096
+ // Focus management + focus trap
1097
+ const prevFocus = document.activeElement;
1098
+ const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
1099
+ function getFocusable() { return Array.from(modal.querySelectorAll(focusableSelector)).filter(el => !el.hasAttribute('disabled')); }
1100
+ const focusable = () => getFocusable();
1101
+ const firstFocusable = () => focusable()[0];
1102
+ const lastFocusable = () => focusable()[focusable().length - 1];
1103
+
1104
+ // Focus initial
1105
+ (closeBtn || closeFooter).focus();
1106
+
1107
+ function onKeyDown(e) {
1108
+ if (e.key === 'Escape') return cleanup();
1109
+ if (e.key === 'Tab') {
1110
+ const f = focusable();
1111
+ if (f.length === 0) return;
1112
+ if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
1113
+ else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
1114
+ }
1115
+ }
1116
+
1117
+ function onBackdropClick(e) { if (e.target === modal) cleanup(); }
1118
+
1119
+ // Fullscreen toggle
1120
+ let fsActive = false;
1121
+ function toggleFullscreen() {
1122
+ if (!document.fullscreenElement) {
1123
+ dialog.requestFullscreen?.();
1124
+ fsBtn.innerHTML = '<i class="fas fa-compress"></i>';
1125
+ fsActive = true;
1126
+ } else {
1127
+ document.exitFullscreen?.();
1128
+ fsBtn.innerHTML = '<i class="fas fa-expand"></i>';
1129
+ fsActive = false;
1130
+ }
1131
+ }
1132
+
1133
+ function cleanup() {
1134
+ // Exit fullscreen if active
1135
+ if (fsActive && document.fullscreenElement) document.exitFullscreen?.();
1136
+ modal.classList.remove('show');
1137
+ modal.style.display = 'none';
1138
+ modal.setAttribute('aria-hidden', 'true');
1139
+ if (prevFocus && prevFocus.focus) prevFocus.focus();
1140
+ modal.removeEventListener('click', onBackdropClick);
1141
+ document.removeEventListener('keydown', onKeyDown);
1142
+ fsBtn.removeEventListener('click', toggleFullscreen);
1143
+ (closeBtn || closeFooter).removeEventListener('click', cleanup);
1144
+ closeFooter.removeEventListener('click', cleanup);
1145
+ }
1146
+
1147
+ modal.addEventListener('click', onBackdropClick);
1148
+ document.addEventListener('keydown', onKeyDown);
1149
+ fsBtn.addEventListener('click', toggleFullscreen);
1150
+ (closeBtn || closeFooter).addEventListener('click', cleanup);
1151
+ closeFooter.addEventListener('click', cleanup);
1152
+ }
1153
+
1154
+ function closePreviewModal() {
1155
+ const modal = document.getElementById('previewModal');
1156
+ if (!modal) return;
1157
+ modal.classList.remove('show');
1158
+ modal.style.display = 'none';
1159
+ modal.setAttribute('aria-hidden', 'true');
1160
+ }
1161
+
1162
+ // Update file icons in the table
1163
+ function updateFileIcons() {
1164
+ const fileRows = document.querySelectorAll('.file-row');
1165
+ fileRows.forEach(row => {
1166
+ const fileNameElement = row.querySelector('.file-name');
1167
+ const folderPathElement = row.querySelector('.folder-path');
1168
+ const fileIconElement = row.querySelector('.file-icon i');
1169
+ const fileIconContainer = row.querySelector('.file-icon');
1170
+ const viewButton = row.querySelector('.view-btn');
1171
+
1172
+ if (fileNameElement && fileIconElement && fileIconContainer) {
1173
+ const fullPath = fileNameElement.getAttribute('data-full-path');
1174
+
1175
+ // Split the path into folder and filename
1176
+ const lastSlashIndex = fullPath.lastIndexOf('/');
1177
+ let folder = '';
1178
+ let filename = fullPath;
1179
+
1180
+ if (lastSlashIndex !== -1) {
1181
+ folder = fullPath.substring(0, lastSlashIndex);
1182
+ filename = fullPath.substring(lastSlashIndex + 1);
1183
+ }
1184
+
1185
+ // Truncate long filenames
1186
+ const truncatedName = truncateFileName(filename);
1187
+
1188
+ // Update the display
1189
+ fileNameElement.textContent = truncatedName;
1190
+ fileNameElement.title = filename; // Full name on hover
1191
+
1192
+
1193
+
1194
+ const iconClass = getFileIcon(filename);
1195
+ const typeCategory = getFileTypeCategory(filename);
1196
+
1197
+ // Update icon
1198
+ fileIconElement.className = iconClass;
1199
+
1200
+ // Add type-specific styling
1201
+ if (typeCategory) {
1202
+ fileIconContainer.classList.add(typeCategory);
1203
+ }
1204
+
1205
+ // Show/hide and update view button based on file type
1206
+ if (viewButton) {
1207
+ const isFileViewable = isViewable(filename);
1208
+
1209
+ if (isFileViewable) {
1210
+ viewButton.style.display = 'inline-flex';
1211
+ viewButton.style.opacity = '1';
1212
+ viewButton.title = 'View file in browser';
1213
+ viewButton.disabled = false;
1214
+ } else {
1215
+ viewButton.style.opacity = '0.5';
1216
+ viewButton.title = 'File type not supported for viewing';
1217
+ viewButton.disabled = true;
1218
+ }
1219
+ }
1220
+ }
1221
+ });
1222
+ }
1223
+
1224
+ // Selection & bulk actions
1225
+ function setupSelection() {
1226
+ const selectAll = document.getElementById('selectAllCheckbox');
1227
+ const bulkBar = document.getElementById('bulkActionsBar');
1228
+ const bulkCount = document.getElementById('bulkSelectedCount');
1229
+ const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
1230
+
1231
+ const checkboxes = Array.from(document.querySelectorAll('.selectFileCheckbox'));
1232
+
1233
+ const selected = new Set();
1234
+
1235
+ function updateUI() {
1236
+ const count = selected.size;
1237
+ bulkCount.textContent = `${count} selected`;
1238
+ bulkBar.style.display = count > 0 ? 'block' : 'none';
1239
+ bulkDeleteBtn.disabled = count === 0;
1240
+ if (selectAll) selectAll.checked = checkboxes.length > 0 && selected.size === checkboxes.length;
1241
+ }
1242
+
1243
+ checkboxes.forEach(cb => {
1244
+ cb.checked = false;
1245
+ cb.addEventListener('change', function (e) {
1246
+ const key = this.dataset.key;
1247
+ if (this.checked) selected.add(key); else selected.delete(key);
1248
+ updateUI();
1249
+ });
1250
+ });
1251
+
1252
+ if (selectAll) {
1253
+ selectAll.checked = false;
1254
+ selectAll.addEventListener('change', function () {
1255
+ const checked = this.checked;
1256
+ selected.clear();
1257
+ checkboxes.forEach(cb => { cb.checked = checked; if (checked) selected.add(cb.dataset.key); });
1258
+ updateUI();
1259
+ });
1260
+ }
1261
+
1262
+ if (bulkDeleteBtn) {
1263
+ bulkDeleteBtn.addEventListener('click', async function () {
1264
+ if (!confirm('Delete selected files? This cannot be undone.')) return;
1265
+ const keys = Array.from(selected);
1266
+ try {
1267
+ const resp = await fetch('/mbkbucket/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys }) });
1268
+ const json = await resp.json();
1269
+ if (json.success) {
1270
+ showAlert('Deleted selected files', 'success');
1271
+ loadFiles(currentPage, currentPrefix);
1272
+ } else {
1273
+ showAlert(json.error || 'Delete failed', 'error');
1274
+ }
1275
+ } catch (err) {
1276
+ showAlert('Delete failed: ' + err.message, 'error');
1277
+ }
1278
+ });
1279
+ }
1280
+ }
1281
+
1282
+ // Create folder (zero-byte placeholder object)
1283
+ async function createFolder() {
1284
+ const name = prompt('Enter new folder name (no slashes):');
1285
+ if (!name) return;
1286
+ const safe = name.replace(/[\\/]+/g, '').trim();
1287
+ if (!safe) return showAlert('Invalid folder name', 'error');
1288
+
1289
+ try {
1290
+ const resp = await fetch('/mbkbucket/create-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix: currentPrefix, folderName: safe }) });
1291
+ const json = await resp.json();
1292
+ if (json.success) {
1293
+ showAlert('Folder created', 'success');
1294
+ loadFiles(currentPage, currentPrefix);
1295
+ } else {
1296
+ showAlert(json.error || 'Create folder failed', 'error');
1297
+ }
1298
+ } catch (err) {
1299
+ showAlert('Create folder failed: ' + err.message, 'error');
1300
+ }
1301
+ }
1302
+
1303
+ // Create new file
1304
+ async function createFile() {
1305
+ let name = prompt('Enter new file name:');
1306
+ if (!name) return;
1307
+
1308
+ name = name.trim();
1309
+ if (!name.includes('.')) {
1310
+ name += '.txt';
1311
+ }
1312
+
1313
+ // Ensure no leading slashes, we upload to currentPrefix
1314
+ if (name.startsWith('/')) name = name.substring(1);
1315
+
1316
+ // Check if valid
1317
+ if (!name) return showAlert('Invalid file name', 'error');
1318
+
1319
+ // Create empty file content
1320
+ const file = new File([" "], name, { type: "text/plain" });
1321
+ const formData = new FormData();
1322
+ formData.append('file', file);
1323
+ formData.append('prefix', currentPrefix);
1324
+
1325
+ try {
1326
+ const resp = await fetch('/mbkbucket/upload', {
1327
+ method: 'POST',
1328
+ body: formData
1329
+ });
1330
+
1331
+ const json = await resp.json();
1332
+ if (json.success) {
1333
+ showAlert('File created', 'success');
1334
+ loadFiles(currentPage, currentPrefix);
1335
+ } else {
1336
+ showAlert(json.error || 'Create file failed', 'error');
1337
+ }
1338
+ } catch (err) {
1339
+ showAlert('Create file failed: ' + err.message, 'error');
1340
+ }
1341
+ }
1342
+
1343
+
1344
+
1345
+ // mbkbucket shared utilities (exposed to global window for backward compatibility)
1346
+ (function (w) {
1347
+ 'use strict';
1348
+
1349
+ w.formatDate = function (dateString) {
1350
+ const date = new Date(dateString);
1351
+ return date.toLocaleString();
1352
+ };
1353
+
1354
+ w.formatFileSize = function (bytes) {
1355
+ if (bytes === 0) return '0 Bytes';
1356
+ const k = 1024;
1357
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
1358
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1359
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1360
+ };
1361
+
1362
+ w.formatTime = function (seconds) {
1363
+ if (seconds < 60) {
1364
+ return Math.round(seconds) + 's';
1365
+ } else if (seconds < 3600) {
1366
+ const minutes = Math.floor(seconds / 60);
1367
+ const remainingSeconds = Math.round(seconds % 60);
1368
+ return minutes + 'm ' + remainingSeconds + 's';
1369
+ } else {
1370
+ const hours = Math.floor(seconds / 3600);
1371
+ const minutes = Math.floor((seconds % 3600) / 60);
1372
+ return hours + 'h ' + minutes + 'm';
1373
+ }
1374
+ };
1375
+
1376
+ w.getFileIcon = function (filename) {
1377
+ const extension = (filename || '').split('.').pop().toLowerCase();
1378
+
1379
+ const iconMap = {
1380
+ jpg: 'fas fa-image',
1381
+ jpeg: 'fas fa-image',
1382
+ png: 'fas fa-image',
1383
+ gif: 'fas fa-image',
1384
+ webp: 'fas fa-image',
1385
+ svg: 'fas fa-image',
1386
+ bmp: 'fas fa-image',
1387
+ ico: 'fas fa-image',
1388
+ mp4: 'fas fa-video',
1389
+ webm: 'fas fa-video',
1390
+ ogg: 'fas fa-video',
1391
+ avi: 'fas fa-video',
1392
+ mov: 'fas fa-video',
1393
+ wmv: 'fas fa-video',
1394
+ flv: 'fas fa-video',
1395
+ mkv: 'fas fa-video',
1396
+ mp3: 'fas fa-music',
1397
+ wav: 'fas fa-music',
1398
+ flac: 'fas fa-music',
1399
+ aac: 'fas fa-music',
1400
+ m4a: 'fas fa-music',
1401
+ pdf: 'fas fa-file-pdf',
1402
+ doc: 'fas fa-file-word',
1403
+ docx: 'fas fa-file-word',
1404
+ xls: 'fas fa-file-excel',
1405
+ xlsx: 'fas fa-file-excel',
1406
+ ppt: 'fas fa-file-powerpoint',
1407
+ pptx: 'fas fa-file-powerpoint',
1408
+ js: 'fab fa-js-square',
1409
+ ts: 'fas fa-code',
1410
+ html: 'fab fa-html5',
1411
+ htm: 'fab fa-html5',
1412
+ css: 'fab fa-css3-alt',
1413
+ php: 'fab fa-php',
1414
+ py: 'fab fa-python',
1415
+ java: 'fab fa-java',
1416
+ cpp: 'fas fa-code',
1417
+ c: 'fas fa-code',
1418
+ h: 'fas fa-code',
1419
+ cs: 'fas fa-code',
1420
+ rb: 'fas fa-code',
1421
+ go: 'fas fa-code',
1422
+ rs: 'fas fa-code',
1423
+ sql: 'fas fa-database',
1424
+ sh: 'fas fa-terminal',
1425
+ bat: 'fas fa-terminal',
1426
+ ps1: 'fas fa-terminal',
1427
+ txt: 'fas fa-file-alt',
1428
+ md: 'fab fa-markdown',
1429
+ json: 'fas fa-code',
1430
+ xml: 'fas fa-code',
1431
+ csv: 'fas fa-file-csv',
1432
+ log: 'fas fa-file-alt',
1433
+ yaml: 'fas fa-code',
1434
+ yml: 'fas fa-code',
1435
+ toml: 'fas fa-code',
1436
+ ini: 'fas fa-cog',
1437
+ conf: 'fas fa-cog',
1438
+ zip: 'fas fa-file-archive',
1439
+ rar: 'fas fa-file-archive',
1440
+ '7z': 'fas fa-file-archive',
1441
+ tar: 'fas fa-file-archive',
1442
+ gz: 'fas fa-file-archive'
1443
+ };
1444
+
1445
+ return iconMap[extension] || 'fas fa-file-alt';
1446
+ };
1447
+
1448
+ w.isViewable = function (filename) {
1449
+ const extension = (filename || '').split('.').pop().toLowerCase();
1450
+ const viewableTypes = [
1451
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
1452
+ 'mp4', 'webm', 'ogg',
1453
+ 'mp3', 'wav', 'ogg',
1454
+ 'txt', 'md', 'json', 'xml', 'csv', 'log', 'js', 'ts', 'html', 'htm', 'css',
1455
+ 'php', 'py', 'java', 'cpp', 'c', 'h', 'cs', 'rb', 'go', 'rs', 'sql',
1456
+ 'sh', 'bat', 'ps1', 'yaml', 'yml', 'toml', 'ini', 'conf',
1457
+ 'pdf'
1458
+ ];
1459
+
1460
+ return viewableTypes.includes(extension);
1461
+ };
1462
+
1463
+ w.getFileTypeCategory = function (filename) {
1464
+ const extension = (filename || '').split('.').pop().toLowerCase();
1465
+ const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'];
1466
+ const videoTypes = ['mp4', 'webm', 'ogg', 'avi', 'mov', 'wmv', 'flv', 'mkv'];
1467
+ const audioTypes = ['mp3', 'wav', 'flac', 'aac', 'm4a'];
1468
+ const documentTypes = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
1469
+ const codeTypes = ['js', 'ts', 'html', 'htm', 'css', 'php', 'py', 'java', 'cpp', 'c', 'h', 'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml', 'toml', 'ini', 'conf', 'txt', 'md', 'json', 'xml', 'csv', 'log'];
1470
+
1471
+ if (imageTypes.includes(extension)) return 'image-file';
1472
+ if (videoTypes.includes(extension)) return 'video-file';
1473
+ if (audioTypes.includes(extension)) return 'audio-file';
1474
+ if (documentTypes.includes(extension)) return 'document-file';
1475
+ if (codeTypes.includes(extension)) return 'code-file';
1476
+
1477
+ return '';
1478
+ };
1479
+
1480
+ w.truncateFileName = function (filename) {
1481
+ const isMobile = window.innerWidth <= 768;
1482
+ if (isMobile) return filename;
1483
+ const maxLength = 30;
1484
+ if ((filename || '').length <= maxLength) return filename;
1485
+ const dotIndex = filename.lastIndexOf('.');
1486
+ let name = filename;
1487
+ let extension = '';
1488
+ if (dotIndex !== -1) {
1489
+ name = filename.substring(0, dotIndex);
1490
+ extension = filename.substring(dotIndex);
1491
+ }
1492
+ const lastChars = 3;
1493
+ const extensionLength = extension.length;
1494
+ const ellipsis = '...';
1495
+ const availableForName = maxLength - ellipsis.length - lastChars - extensionLength;
1496
+ if (availableForName <= 0) return filename.substring(0, maxLength - ellipsis.length) + ellipsis;
1497
+ const firstPart = name.substring(0, availableForName);
1498
+ const lastPart = name.substring(name.length - lastChars);
1499
+ return firstPart + ellipsis + lastPart + extension;
1500
+ };
1501
+
1502
+ // Debounce helper - returns a function that delays invocation until after wait ms
1503
+ w.debounce = function (fn, wait) {
1504
+ let timeout = null;
1505
+ return function () {
1506
+ const ctx = this;
1507
+ const args = arguments;
1508
+ clearTimeout(timeout);
1509
+ timeout = setTimeout(function () {
1510
+ fn.apply(ctx, args);
1511
+ }, wait);
1512
+ };
1513
+ };
1514
+
1515
+ })(window);