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.
@@ -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-80"></div>
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
- const response = await fetch(`/mbkbucket/api/files?page=${page}&prefix=${encodeURIComponent(prefix)}${searchParam}${sortParam}`);
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
- totalFilesCount.textContent = `${data.totalFiles} ${search ? 'Results' : 'Files'}`;
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
- if (data.files.length === 0 && !prefix) {
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
- // 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);
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
- displayFiles = files;
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
- const folderName = folderPath.split('/').pop();
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
- // 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 {
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
- // Page numbers
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">${i}</span>
634
+ <span class="page-link">${currentPage}</span>
619
635
  </li>
620
- `;
621
- } else {
622
- paginationHTML += `
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(${i}, '${currentPrefix}', '${currentSearch}')">${i}</a>
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
- 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
- `;
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
- container.innerHTML = uploadQueue.map(item => `
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">${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>
883
+ <small class="text-muted status">${statusText}</small>
884
+ <div class="controls">${actionBtn}</div>
768
885
  </div>
769
886
  </div>
770
- </div>
771
- `).join('');
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
- // chunked upload
825
- const uploadId = createId();
826
- entry.uploadId = uploadId;
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
- if (entry.status === 'cancelled') throw new Error('Upload cancelled');
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 end = Math.min(file.size, start + CHUNK_SIZE);
833
- const blob = file.slice(start, end);
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('fileName', file.name);
839
- fd.append('chunkIndex', String(i));
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 resp = await fetch('/mbkbucket/upload-chunk', { method: 'POST', body: fd, signal: controller.signal });
846
- if (!resp.ok) throw new Error('Chunk upload failed');
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
- entry.progress = (uploadedBytes / file.size) * 95; // up to 95% until assembly
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
- // 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' }) });
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 || 'Assemble failed');
1031
+ if (!completeResp.ok || !completeJson.success) throw new Error(completeJson.error || 'Failed to complete upload');
856
1032
 
857
- entry.status = 'done'; entry.progress = 100; xhrMap.delete(entry.id); renderUploadQueue();
858
- showAlert('File uploaded successfully', 'success');
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 || '';
@@ -72,7 +72,7 @@
72
72
  }
73
73
 
74
74
  .logo img {
75
- width: 40px
75
+ width: 35px
76
76
  }
77
77
 
78
78
  .main-content {