mbkbucket 1.2.1 → 1.4.0
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.
- package/.env.example +5 -2
- package/README.md +59 -14
- package/index.d.ts +67 -3
- package/index.js +5 -79
- package/lib/bucket.js +719 -411
- package/lib/config/index.js +134 -1
- package/lib/s3.js +304 -56
- package/package.json +2 -1
- package/public/bucketadmin.css +164 -19
- package/public/bucketadmin.js +317 -94
- package/views/bucket.handlebars +1 -1
- package/views/bucketadmincontent.handlebars +17 -14
- package/views/mbkbucket_info.handlebars +226 -0
- package/views/info.handlebars +0 -1056
package/public/bucketadmin.js
CHANGED
|
@@ -7,6 +7,7 @@ let currentOrder = 'asc';
|
|
|
7
7
|
let currentViewMode = 'folder'; // 'folder' or 'flat'
|
|
8
8
|
let pageFiles = [];
|
|
9
9
|
let pageFolders = [];
|
|
10
|
+
let currentNextToken = null;
|
|
10
11
|
const bucketAppName = document.getElementById('bucketAppData')?.dataset.appName || '';
|
|
11
12
|
// Bucket is configured centrally on the server; client should not select a bucket.
|
|
12
13
|
|
|
@@ -70,7 +71,7 @@ function showAlert(message, type = 'success') {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
// Fetch and render files
|
|
73
|
-
async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort, order = currentOrder) {
|
|
74
|
+
async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort, order = currentOrder, token = '') {
|
|
74
75
|
currentPage = page;
|
|
75
76
|
currentPrefix = prefix;
|
|
76
77
|
currentSearch = search;
|
|
@@ -109,19 +110,13 @@ async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort,
|
|
|
109
110
|
<div class="skeleton-box s-40"></div>
|
|
110
111
|
<div class="skeleton-box s-200"></div>
|
|
111
112
|
<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
113
|
<div class="skeleton-box s-200"></div>
|
|
117
|
-
<div class="skeleton-box s-80"></div>
|
|
118
|
-
<div class="skeleton-box s-80"></div>
|
|
119
114
|
</div>
|
|
120
115
|
<div class="skeleton-row">
|
|
121
116
|
<div class="skeleton-box s-40"></div>
|
|
122
117
|
<div class="skeleton-box s-200"></div>
|
|
123
118
|
<div class="skeleton-box s-80"></div>
|
|
124
|
-
<div class="skeleton-box s-
|
|
119
|
+
<div class="skeleton-box s-200"></div>
|
|
125
120
|
</div>
|
|
126
121
|
</div>
|
|
127
122
|
`;
|
|
@@ -129,15 +124,28 @@ async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort,
|
|
|
129
124
|
try {
|
|
130
125
|
const searchParam = search ? `&search=${encodeURIComponent(search)}` : '';
|
|
131
126
|
const sortParam = `&sort=${sort}&order=${order}`;
|
|
132
|
-
|
|
127
|
+
|
|
128
|
+
// Determine recursive mode
|
|
129
|
+
const isFolderMode = currentViewMode === 'folder';
|
|
130
|
+
const recursiveParam = `&recursive=${!isFolderMode}`; // false if folder mode, true if flat mode
|
|
131
|
+
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
|
|
132
|
+
|
|
133
|
+
const response = await fetch(`/mbkbucket/api/files?page=${page}&prefix=${encodeURIComponent(prefix)}${searchParam}${sortParam}${recursiveParam}${tokenParam}`);
|
|
133
134
|
const data = await response.json();
|
|
134
135
|
|
|
135
136
|
if (!data.success) {
|
|
136
137
|
throw new Error(data.error || 'Failed to load files');
|
|
137
138
|
}
|
|
139
|
+
|
|
140
|
+
// Store next token
|
|
141
|
+
currentNextToken = data.nextContinuationToken || null;
|
|
138
142
|
|
|
139
143
|
// Update stats
|
|
140
|
-
|
|
144
|
+
if (data.totalFiles === -1) {
|
|
145
|
+
totalFilesCount.textContent = `${(data.files.length + (data.folders ? data.folders.length : 0))}${data.hasNextPage ? '+' : ''} Items`;
|
|
146
|
+
} else {
|
|
147
|
+
totalFilesCount.textContent = `${data.totalFiles} ${search ? 'Results' : 'Files'}`;
|
|
148
|
+
}
|
|
141
149
|
|
|
142
150
|
if (search) {
|
|
143
151
|
filterInfo.textContent = `Search results for: "${search}"`;
|
|
@@ -149,7 +157,9 @@ async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort,
|
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
// Render files or empty state
|
|
152
|
-
|
|
160
|
+
const hasContent = (data.files && data.files.length > 0) || (data.folders && data.folders.length > 0);
|
|
161
|
+
|
|
162
|
+
if (!hasContent && !prefix && !search) {
|
|
153
163
|
container.innerHTML = `
|
|
154
164
|
<div class="empty-state">
|
|
155
165
|
<i class="fas fa-folder-open"></i>
|
|
@@ -163,11 +173,13 @@ async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort,
|
|
|
163
173
|
paginationContainer.style.display = 'none';
|
|
164
174
|
} else {
|
|
165
175
|
// Store for client-side sorting and view switching
|
|
166
|
-
pageFiles = data.files;
|
|
176
|
+
pageFiles = data.files || [];
|
|
167
177
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
178
|
+
if (data.mode === 'optimized') {
|
|
179
|
+
pageFolders = data.folders || [];
|
|
180
|
+
} else {
|
|
181
|
+
pageFolders = search ? [] : extractFoldersAtCurrentLevel(data.files, prefix);
|
|
182
|
+
}
|
|
171
183
|
|
|
172
184
|
renderFilesTable(pageFiles, pageFolders, prefix);
|
|
173
185
|
renderPagination(data);
|
|
@@ -335,6 +347,9 @@ function renderFilesTable(files, folders = [], prefix = '') {
|
|
|
335
347
|
// 1. Show files where FILENAME matches the search term
|
|
336
348
|
const searchLower = currentSearch.toLowerCase();
|
|
337
349
|
displayFiles = files.filter(f => {
|
|
350
|
+
// Skip folder marker objects (keys ending with /)
|
|
351
|
+
if (f.Key.endsWith('/')) return false;
|
|
352
|
+
|
|
338
353
|
const fileName = f.Key.split('/').pop();
|
|
339
354
|
return fileName.toLowerCase().includes(searchLower);
|
|
340
355
|
});
|
|
@@ -343,13 +358,17 @@ function renderFilesTable(files, folders = [], prefix = '') {
|
|
|
343
358
|
displayFolders = extractMatchingFolders(files, currentSearch);
|
|
344
359
|
} else {
|
|
345
360
|
// Normal Flat Mode: Show all files, no folders
|
|
346
|
-
|
|
361
|
+
// Filter out folder marker objects (keys ending with /)
|
|
362
|
+
displayFiles = files.filter(f => !f.Key.endsWith('/'));
|
|
347
363
|
displayFolders = [];
|
|
348
364
|
}
|
|
349
365
|
} else {
|
|
350
366
|
// Normal Folder View logic
|
|
351
367
|
// Filter files to show only those at current level (not in subfolders)
|
|
352
368
|
displayFiles = files.filter(file => {
|
|
369
|
+
// Skip folder marker objects (keys ending with /)
|
|
370
|
+
if (file.Key.endsWith('/')) return false;
|
|
371
|
+
|
|
353
372
|
const relativePath = prefix ? file.Key.substring(prefix.length + (prefix.endsWith('/') ? 0 : 1)) : file.Key;
|
|
354
373
|
// File is at current level if it has no '/' in relative path
|
|
355
374
|
return !relativePath.includes('/');
|
|
@@ -400,7 +419,10 @@ function renderFilesTable(files, folders = [], prefix = '') {
|
|
|
400
419
|
|
|
401
420
|
// Generate folder rows (only used in folder mode)
|
|
402
421
|
const folderRows = displayFolders.map(folderPath => {
|
|
403
|
-
|
|
422
|
+
// Handle folder paths which typically end in a slash (e.g., "folder/subfolder/")
|
|
423
|
+
const cleanPath = folderPath.replace(/\/+$/, '');
|
|
424
|
+
const folderName = cleanPath.split('/').pop();
|
|
425
|
+
|
|
404
426
|
return `
|
|
405
427
|
<tr class="folder-row" onclick="loadFiles(1, '${folderPath}')" title="Open folder: ${folderName}">
|
|
406
428
|
<td style="text-align:center;">
|
|
@@ -453,10 +475,10 @@ function renderFilesTable(files, folders = [], prefix = '') {
|
|
|
453
475
|
folderPath = fullPath.substring(0, lastSlashIndex + 1); // include trailing slash
|
|
454
476
|
}
|
|
455
477
|
|
|
456
|
-
const iconClass = getFileIcon(filename);
|
|
457
|
-
const typeCategory = getFileTypeCategory(filename);
|
|
458
|
-
const truncatedName = truncateFileName(filename);
|
|
459
|
-
const isFileViewable = isViewable(filename);
|
|
478
|
+
const iconClass = window.getFileIcon ? window.getFileIcon(filename) : 'fas fa-file';
|
|
479
|
+
const typeCategory = window.getFileTypeCategory ? window.getFileTypeCategory(filename) : '';
|
|
480
|
+
const truncatedName = (window.truncateFileName ? window.truncateFileName(filename) : filename) || filename;
|
|
481
|
+
const isFileViewable = window.isViewable ? window.isViewable(filename) : false;
|
|
460
482
|
|
|
461
483
|
// If in flat mode, we might want to show the folder path more prominently
|
|
462
484
|
const pathDisplay = (currentViewMode === 'flat' && folderPath && folderPath !== prefix + (prefix ? '/' : ''))
|
|
@@ -493,6 +515,9 @@ function renderFilesTable(files, folders = [], prefix = '') {
|
|
|
493
515
|
data-key-enc="${encodedKey}" title="${isFileViewable ? 'View file in browser' : 'File type not supported for viewing'}" ${!isFileViewable ? 'disabled style="opacity:0.5"' : ''}>
|
|
494
516
|
<i class="fas fa-eye"></i>
|
|
495
517
|
</button>
|
|
518
|
+
<button type="button" class="btn btn-info btn-sm copy-btn" data-key-enc="${encodedKey}" title="Copy Link" style="color:white;">
|
|
519
|
+
<i class="fas fa-link"></i>
|
|
520
|
+
</button>
|
|
496
521
|
<a href="/mbkbucket/download/${encodedKey}" class="btn btn-success btn-sm"
|
|
497
522
|
title="Download file">
|
|
498
523
|
<i class="fas fa-download"></i>
|
|
@@ -587,63 +612,137 @@ function renderFilesTable(files, folders = [], prefix = '') {
|
|
|
587
612
|
|
|
588
613
|
// Render pagination
|
|
589
614
|
function renderPagination(data) {
|
|
590
|
-
const { currentPage, totalPages, hasPrevPage, hasNextPage } = data;
|
|
615
|
+
const { currentPage, totalPages, hasPrevPage, hasNextPage, mode } = data;
|
|
591
616
|
|
|
592
617
|
let paginationHTML = '';
|
|
593
618
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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 {
|
|
619
|
+
if (mode === 'optimized') {
|
|
620
|
+
// Optimized Mode: Token-based pagination (Next only, limited Prev)
|
|
621
|
+
|
|
622
|
+
// Previous button (Disabled for now as we don't track history stack of tokens)
|
|
604
623
|
paginationHTML += `
|
|
605
624
|
<li class="page-item disabled">
|
|
606
|
-
<span class="page-link">
|
|
625
|
+
<span class="page-link" title="Previous page not supported in optimized view">
|
|
607
626
|
<i class="fas fa-chevron-left"></i> Previous
|
|
608
627
|
</span>
|
|
609
628
|
</li>
|
|
610
629
|
`;
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
for (let i = 1; i <= totalPages; i++) {
|
|
615
|
-
if (i === currentPage) {
|
|
616
|
-
paginationHTML += `
|
|
630
|
+
|
|
631
|
+
// Current Page Indicator
|
|
632
|
+
paginationHTML += `
|
|
617
633
|
<li class="page-item active">
|
|
618
|
-
<span class="page-link">${
|
|
634
|
+
<span class="page-link">${currentPage}</span>
|
|
619
635
|
</li>
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
636
|
+
`;
|
|
637
|
+
|
|
638
|
+
// Next button
|
|
639
|
+
if (hasNextPage) {
|
|
640
|
+
// Quote the token correctly
|
|
641
|
+
const tokenStr = currentNextToken ? currentNextToken.replace(/'/g, "\\'") : "";
|
|
642
|
+
paginationHTML += `
|
|
623
643
|
<li class="page-item">
|
|
624
|
-
<a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(${
|
|
644
|
+
<a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(${currentPage + 1}, '${currentPrefix}', '${currentSearch}', '${currentSort}', '${currentOrder}', '${tokenStr}')">
|
|
645
|
+
Next <i class="fas fa-chevron-right"></i>
|
|
646
|
+
</a>
|
|
625
647
|
</li>
|
|
626
648
|
`;
|
|
627
|
-
|
|
628
|
-
|
|
649
|
+
} else {
|
|
650
|
+
paginationHTML += `
|
|
651
|
+
<li class="page-item disabled">
|
|
652
|
+
<span class="page-link">
|
|
653
|
+
Next <i class="fas fa-chevron-right"></i>
|
|
654
|
+
</span>
|
|
655
|
+
</li>
|
|
656
|
+
`;
|
|
657
|
+
}
|
|
629
658
|
|
|
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
659
|
} else {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
660
|
+
// Legacy Mode: Numbered pagination
|
|
661
|
+
|
|
662
|
+
// Previous button
|
|
663
|
+
if (hasPrevPage) {
|
|
664
|
+
paginationHTML += `
|
|
665
|
+
<li class="page-item">
|
|
666
|
+
<a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(${currentPage - 1}, '${currentPrefix}', '${currentSearch}')">
|
|
667
|
+
<i class="fas fa-chevron-left"></i> Previous
|
|
668
|
+
</a>
|
|
669
|
+
</li>
|
|
670
|
+
`;
|
|
671
|
+
} else {
|
|
672
|
+
paginationHTML += `
|
|
673
|
+
<li class="page-item disabled">
|
|
674
|
+
<span class="page-link">
|
|
675
|
+
<i class="fas fa-chevron-left"></i> Previous
|
|
676
|
+
</span>
|
|
677
|
+
</li>
|
|
678
|
+
`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Smart Page Numbers (limit visible pages)
|
|
682
|
+
const maxPagesToShow = 7;
|
|
683
|
+
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
|
|
684
|
+
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
|
|
685
|
+
|
|
686
|
+
if (endPage - startPage + 1 < maxPagesToShow) {
|
|
687
|
+
startPage = Math.max(1, endPage - maxPagesToShow + 1);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// First page
|
|
691
|
+
if (startPage > 1) {
|
|
692
|
+
paginationHTML += `
|
|
693
|
+
<li class="page-item">
|
|
694
|
+
<a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(1, '${currentPrefix}', '${currentSearch}')">1</a>
|
|
695
|
+
</li>`;
|
|
696
|
+
if (startPage > 2) {
|
|
697
|
+
paginationHTML += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
for (let i = startPage; i <= endPage; i++) {
|
|
702
|
+
if (i === currentPage) {
|
|
703
|
+
paginationHTML += `
|
|
704
|
+
<li class="page-item active">
|
|
705
|
+
<span class="page-link">${i}</span>
|
|
706
|
+
</li>
|
|
707
|
+
`;
|
|
708
|
+
} else {
|
|
709
|
+
paginationHTML += `
|
|
710
|
+
<li class="page-item">
|
|
711
|
+
<a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(${i}, '${currentPrefix}', '${currentSearch}')">${i}</a>
|
|
712
|
+
</li>
|
|
713
|
+
`;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Last page
|
|
718
|
+
if (endPage < totalPages) {
|
|
719
|
+
if (endPage < totalPages - 1) {
|
|
720
|
+
paginationHTML += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
|
|
721
|
+
}
|
|
722
|
+
paginationHTML += `
|
|
723
|
+
<li class="page-item">
|
|
724
|
+
<a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(${totalPages}, '${currentPrefix}', '${currentSearch}')">${totalPages}</a>
|
|
725
|
+
</li>`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Next button
|
|
729
|
+
if (hasNextPage) {
|
|
730
|
+
paginationHTML += `
|
|
731
|
+
<li class="page-item">
|
|
732
|
+
<a class="page-link" href="#" onclick="event.preventDefault(); loadFiles(${currentPage + 1}, '${currentPrefix}', '${currentSearch}')">
|
|
733
|
+
Next <i class="fas fa-chevron-right"></i>
|
|
734
|
+
</a>
|
|
735
|
+
</li>
|
|
736
|
+
`;
|
|
737
|
+
} else {
|
|
738
|
+
paginationHTML += `
|
|
739
|
+
<li class="page-item disabled">
|
|
740
|
+
<span class="page-link">
|
|
741
|
+
Next <i class="fas fa-chevron-right"></i>
|
|
742
|
+
</span>
|
|
743
|
+
</li>
|
|
744
|
+
`;
|
|
745
|
+
}
|
|
647
746
|
}
|
|
648
747
|
|
|
649
748
|
document.getElementById('paginationList').innerHTML = paginationHTML;
|
|
@@ -731,7 +830,7 @@ function createId() { return Date.now().toString(36) + '-' + Math.random().toStr
|
|
|
731
830
|
|
|
732
831
|
function addFilesToQueue(fileList) {
|
|
733
832
|
for (const f of Array.from(fileList)) {
|
|
734
|
-
uploadQueue.push({ id: createId(), file: f, status: 'queued', progress: 0 });
|
|
833
|
+
uploadQueue.push({ id: createId(), file: f, status: 'queued', progress: 0, uploadedBytes: 0, uploadRate: 0 });
|
|
735
834
|
}
|
|
736
835
|
renderUploadQueue();
|
|
737
836
|
}
|
|
@@ -747,7 +846,27 @@ function renderUploadQueue() {
|
|
|
747
846
|
return;
|
|
748
847
|
}
|
|
749
848
|
|
|
750
|
-
|
|
849
|
+
// Local size formatter so it's always in scope regardless of IIFE load order
|
|
850
|
+
function fmtSize(bytes) {
|
|
851
|
+
if (!bytes || bytes === 0) return '0 B';
|
|
852
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
853
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
854
|
+
return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
container.innerHTML = uploadQueue.map(item => {
|
|
858
|
+
let statusText;
|
|
859
|
+
if (item.status === 'uploading') {
|
|
860
|
+
const pct = Math.round(item.progress || 0);
|
|
861
|
+
statusText = fmtSize(item.uploadedBytes || 0) + ' / ' + fmtSize(item.file.size) + ' (' + pct + '%)';
|
|
862
|
+
if (item.uploadRate > 0) statusText += ' \u00b7 ' + fmtSize(item.uploadRate) + '/s';
|
|
863
|
+
} else {
|
|
864
|
+
statusText = item.status;
|
|
865
|
+
}
|
|
866
|
+
const actionBtn = item.status === 'uploading'
|
|
867
|
+
? `<button class="btn btn-sm btn-outline-secondary cancel-upload" data-id="${item.id}"><i class="fas fa-ban"></i></button>`
|
|
868
|
+
: `<button class="btn btn-sm btn-danger remove-queued" data-id="${item.id}"><i class="fas fa-trash"></i></button>`;
|
|
869
|
+
return `
|
|
751
870
|
<div class="upload-item" data-id="${item.id}">
|
|
752
871
|
<div class="meta">
|
|
753
872
|
<div style="display:flex; align-items:center; gap:0.75rem; min-width:0; width:100%; margin-bottom:5px;">
|
|
@@ -761,14 +880,12 @@ function renderUploadQueue() {
|
|
|
761
880
|
<div class="progress-bar" style="width: ${Math.round(item.progress)}%; height: 8px;"></div>
|
|
762
881
|
</div>
|
|
763
882
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:0.5rem;">
|
|
764
|
-
<small class="text-muted status">${
|
|
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>
|
|
883
|
+
<small class="text-muted status">${statusText}</small>
|
|
884
|
+
<div class="controls">${actionBtn}</div>
|
|
768
885
|
</div>
|
|
769
886
|
</div>
|
|
770
|
-
</div
|
|
771
|
-
|
|
887
|
+
</div>`;
|
|
888
|
+
}).join('');
|
|
772
889
|
|
|
773
890
|
// Wire up controls
|
|
774
891
|
document.querySelectorAll('.remove-queued').forEach(btn => btn.addEventListener('click', function (e) {
|
|
@@ -778,15 +895,27 @@ function renderUploadQueue() {
|
|
|
778
895
|
renderUploadQueue();
|
|
779
896
|
}));
|
|
780
897
|
|
|
781
|
-
document.querySelectorAll('.cancel-upload').forEach(btn => btn.addEventListener('click', function (e) {
|
|
898
|
+
document.querySelectorAll('.cancel-upload').forEach(btn => btn.addEventListener('click', async function (e) {
|
|
782
899
|
const id = this.dataset.id;
|
|
783
900
|
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
901
|
const entry = uploadQueue.find(i => i.id === id);
|
|
902
|
+
|
|
903
|
+
// Abort in-flight fetch
|
|
904
|
+
if (handle && handle.controller) handle.controller.abort();
|
|
905
|
+
// Abort legacy XHR (small file path)
|
|
906
|
+
if (handle && handle.xhr) handle.xhr.abort();
|
|
907
|
+
|
|
908
|
+
// Tell S3 to discard the incomplete multipart upload
|
|
909
|
+
if (entry && entry.s3UploadId && entry.s3Key) {
|
|
910
|
+
try {
|
|
911
|
+
await fetch('/mbkbucket/upload-abort', {
|
|
912
|
+
method: 'POST',
|
|
913
|
+
headers: { 'Content-Type': 'application/json' },
|
|
914
|
+
body: JSON.stringify({ uploadId: entry.s3UploadId, key: entry.s3Key })
|
|
915
|
+
});
|
|
916
|
+
} catch (_) { /* best-effort */ }
|
|
917
|
+
}
|
|
918
|
+
|
|
790
919
|
if (entry) { entry.status = 'cancelled'; entry.progress = 0; }
|
|
791
920
|
xhrMap.delete(id);
|
|
792
921
|
renderUploadQueue();
|
|
@@ -821,41 +950,90 @@ async function startUploadEntry(entry) {
|
|
|
821
950
|
const file = entry.file;
|
|
822
951
|
try {
|
|
823
952
|
if (file.size > CHUNK_SIZE) {
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
953
|
+
// ---------------------------------------------------------------
|
|
954
|
+
// S3 Multipart Upload
|
|
955
|
+
// Step 1 — Initiate: get uploadId + key from the server
|
|
956
|
+
// ---------------------------------------------------------------
|
|
957
|
+
const initResp = await fetch('/mbkbucket/upload-init', {
|
|
958
|
+
method: 'POST',
|
|
959
|
+
headers: { 'Content-Type': 'application/json' },
|
|
960
|
+
body: JSON.stringify({
|
|
961
|
+
fileName: file.name,
|
|
962
|
+
prefix: currentPrefix,
|
|
963
|
+
contentType: file.type || 'application/octet-stream'
|
|
964
|
+
})
|
|
965
|
+
});
|
|
966
|
+
const initJson = await initResp.json();
|
|
967
|
+
if (!initResp.ok || !initJson.success) throw new Error(initJson.error || 'Failed to initiate upload');
|
|
968
|
+
|
|
969
|
+
const { uploadId, key } = initJson;
|
|
970
|
+
entry.s3UploadId = uploadId; // stored so cancel handler can abort
|
|
971
|
+
entry.s3Key = key;
|
|
972
|
+
|
|
973
|
+
// ---------------------------------------------------------------
|
|
974
|
+
// Step 2 — Upload parts (1-based partNumber, min 5 MB except last)
|
|
975
|
+
// ---------------------------------------------------------------
|
|
827
976
|
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
|
977
|
+
const parts = []; // [{ partNumber, ETag }]
|
|
828
978
|
let uploadedBytes = 0;
|
|
979
|
+
|
|
829
980
|
for (let i = 0; i < totalChunks; i++) {
|
|
830
|
-
|
|
981
|
+
const _chunkStart = Date.now();
|
|
982
|
+
if (entry.status === 'cancelled') {
|
|
983
|
+
// Clean up on S3
|
|
984
|
+
await fetch('/mbkbucket/upload-abort', {
|
|
985
|
+
method: 'POST',
|
|
986
|
+
headers: { 'Content-Type': 'application/json' },
|
|
987
|
+
body: JSON.stringify({ uploadId, key })
|
|
988
|
+
}).catch(() => {});
|
|
989
|
+
throw new Error('Upload cancelled');
|
|
990
|
+
}
|
|
991
|
+
|
|
831
992
|
const start = i * CHUNK_SIZE;
|
|
832
|
-
const
|
|
833
|
-
const
|
|
993
|
+
const blob = file.slice(start, Math.min(file.size, start + CHUNK_SIZE));
|
|
994
|
+
const partNumber = i + 1; // S3 parts are 1-based
|
|
834
995
|
|
|
835
996
|
const fd = new FormData();
|
|
836
997
|
fd.append('chunk', blob, file.name);
|
|
837
998
|
fd.append('uploadId', uploadId);
|
|
838
|
-
fd.append('
|
|
839
|
-
fd.append('
|
|
840
|
-
fd.append('totalChunks', String(totalChunks));
|
|
999
|
+
fd.append('key', key);
|
|
1000
|
+
fd.append('partNumber', String(partNumber));
|
|
841
1001
|
|
|
842
1002
|
const controller = new AbortController();
|
|
843
|
-
xhrMap.set(entry.id, { controller });
|
|
1003
|
+
xhrMap.set(entry.id, { controller, s3UploadId: uploadId, s3Key: key });
|
|
844
1004
|
|
|
845
|
-
const
|
|
846
|
-
|
|
1005
|
+
const chunkResp = await fetch('/mbkbucket/upload-chunk', {
|
|
1006
|
+
method: 'POST',
|
|
1007
|
+
body: fd,
|
|
1008
|
+
signal: controller.signal
|
|
1009
|
+
});
|
|
1010
|
+
const chunkJson = await chunkResp.json();
|
|
1011
|
+
if (!chunkResp.ok || !chunkJson.success) throw new Error(chunkJson.error || `Part ${partNumber} upload failed`);
|
|
1012
|
+
|
|
1013
|
+
parts.push({ partNumber: chunkJson.partNumber, ETag: chunkJson.ETag });
|
|
847
1014
|
uploadedBytes += blob.size;
|
|
848
|
-
|
|
1015
|
+
const _chunkDuration = (Date.now() - _chunkStart) / 1000;
|
|
1016
|
+
entry.uploadRate = _chunkDuration > 0 ? blob.size / _chunkDuration : 0;
|
|
1017
|
+
entry.uploadedBytes = uploadedBytes;
|
|
1018
|
+
entry.progress = (uploadedBytes / file.size) * 95; // reserve last 5% for complete call
|
|
849
1019
|
renderUploadQueue();
|
|
850
1020
|
}
|
|
851
1021
|
|
|
852
|
-
//
|
|
853
|
-
|
|
1022
|
+
// ---------------------------------------------------------------
|
|
1023
|
+
// Step 3 — Complete: assemble all parts on S3
|
|
1024
|
+
// ---------------------------------------------------------------
|
|
1025
|
+
const completeResp = await fetch('/mbkbucket/upload-complete', {
|
|
1026
|
+
method: 'POST',
|
|
1027
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1028
|
+
body: JSON.stringify({ uploadId, key, parts })
|
|
1029
|
+
});
|
|
854
1030
|
const completeJson = await completeResp.json();
|
|
855
|
-
if (!completeResp.ok || !completeJson.success) throw new Error(completeJson.error || '
|
|
1031
|
+
if (!completeResp.ok || !completeJson.success) throw new Error(completeJson.error || 'Failed to complete upload');
|
|
856
1032
|
|
|
857
|
-
entry.status = 'done'; entry.progress = 100;
|
|
858
|
-
|
|
1033
|
+
entry.status = 'done'; entry.progress = 100;
|
|
1034
|
+
xhrMap.delete(entry.id);
|
|
1035
|
+
renderUploadQueue();
|
|
1036
|
+
showAlert(`${file.name} uploaded successfully`, 'success');
|
|
859
1037
|
loadFiles(currentPage, currentPrefix);
|
|
860
1038
|
} else {
|
|
861
1039
|
// small file XHR
|
|
@@ -868,9 +1046,20 @@ async function startUploadEntry(entry) {
|
|
|
868
1046
|
|
|
869
1047
|
xhr.upload.addEventListener('progress', (e) => {
|
|
870
1048
|
if (!e.lengthComputable) return;
|
|
1049
|
+
const _now = Date.now();
|
|
1050
|
+
const _dt = (_now - (entry._rateLastTime || _now)) / 1000;
|
|
1051
|
+
const _db = e.loaded - (entry._rateLastBytes || 0);
|
|
1052
|
+
if (_dt > 0.2) {
|
|
1053
|
+
entry.uploadRate = _db / _dt;
|
|
1054
|
+
entry._rateLastTime = _now;
|
|
1055
|
+
entry._rateLastBytes = e.loaded;
|
|
1056
|
+
}
|
|
1057
|
+
entry.uploadedBytes = e.loaded;
|
|
871
1058
|
entry.progress = (e.loaded / e.total) * 100;
|
|
872
1059
|
renderUploadQueue();
|
|
873
1060
|
});
|
|
1061
|
+
entry._rateLastTime = Date.now();
|
|
1062
|
+
entry._rateLastBytes = 0;
|
|
874
1063
|
|
|
875
1064
|
xhr.addEventListener('load', () => {
|
|
876
1065
|
try {
|
|
@@ -898,7 +1087,16 @@ async function startUploadEntry(entry) {
|
|
|
898
1087
|
}
|
|
899
1088
|
} catch (err) {
|
|
900
1089
|
console.error('Upload failed for', entry.file.name, err);
|
|
1090
|
+
// If a multipart upload was initiated but didn't finish (not a user cancel), abort it on S3
|
|
1091
|
+
if (entry.s3UploadId && entry.s3Key && entry.status !== 'cancelled') {
|
|
1092
|
+
fetch('/mbkbucket/upload-abort', {
|
|
1093
|
+
method: 'POST',
|
|
1094
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1095
|
+
body: JSON.stringify({ uploadId: entry.s3UploadId, key: entry.s3Key })
|
|
1096
|
+
}).catch(() => {});
|
|
1097
|
+
}
|
|
901
1098
|
entry.status = entry.status === 'cancelled' ? 'cancelled' : 'error';
|
|
1099
|
+
xhrMap.delete(entry.id);
|
|
902
1100
|
renderUploadQueue();
|
|
903
1101
|
showAlert('Upload failed: ' + (err.message || 'unknown'), 'error');
|
|
904
1102
|
}
|
|
@@ -915,8 +1113,33 @@ document.getElementById('clearQueueBtn')?.addEventListener('click', function ()
|
|
|
915
1113
|
|
|
916
1114
|
|
|
917
1115
|
|
|
1116
|
+
// Initialize upload section collapse functionality
|
|
1117
|
+
function initializeUploadCollapse() {
|
|
1118
|
+
const toggleBtn = document.getElementById('uploadToggleBtn');
|
|
1119
|
+
const uploadSection = document.getElementById('uploadSection');
|
|
1120
|
+
|
|
1121
|
+
if (!toggleBtn || !uploadSection) return;
|
|
1122
|
+
|
|
1123
|
+
toggleBtn.addEventListener('click', function() {
|
|
1124
|
+
const isExpanded = uploadSection.classList.contains('expanded');
|
|
1125
|
+
|
|
1126
|
+
if (isExpanded) {
|
|
1127
|
+
// Collapse
|
|
1128
|
+
uploadSection.classList.remove('expanded');
|
|
1129
|
+
toggleBtn.classList.remove('expanded');
|
|
1130
|
+
} else {
|
|
1131
|
+
// Expand
|
|
1132
|
+
uploadSection.classList.add('expanded');
|
|
1133
|
+
toggleBtn.classList.add('expanded');
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
918
1138
|
// Add hover effects and animations
|
|
919
1139
|
document.addEventListener('DOMContentLoaded', function () {
|
|
1140
|
+
// Initialize collapsible upload section
|
|
1141
|
+
initializeUploadCollapse();
|
|
1142
|
+
|
|
920
1143
|
// Load initial files (support both legacy 'prefix' and new 'folder' query param)
|
|
921
1144
|
const urlParams = new URLSearchParams(window.location.search);
|
|
922
1145
|
const initialPrefix = urlParams.get('folder') || urlParams.get('prefix') || bucketAppName || '';
|