mbkbucket 1.3.0 → 1.5.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.
@@ -8,9 +8,35 @@ let currentViewMode = 'folder'; // 'folder' or 'flat'
8
8
  let pageFiles = [];
9
9
  let pageFolders = [];
10
10
  let currentNextToken = null;
11
+ let currentApiPrefix = '';
11
12
  const bucketAppName = document.getElementById('bucketAppData')?.dataset.appName || '';
12
13
  // Bucket is configured centrally on the server; client should not select a bucket.
13
14
 
15
+ function stripApiPrefixFromPath(path, apiPrefix = '') {
16
+ const normalizedPath = String(path || '').replace(/^\/+/, '');
17
+ const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
18
+
19
+ if (!normalizedApiPrefix) return normalizedPath;
20
+ if (normalizedPath === normalizedApiPrefix || normalizedPath === `${normalizedApiPrefix}/`) return '';
21
+ if (normalizedPath.startsWith(`${normalizedApiPrefix}/`)) {
22
+ return normalizedPath.slice(normalizedApiPrefix.length + 1);
23
+ }
24
+
25
+ return normalizedPath;
26
+ }
27
+
28
+ function toApiFolderPath(prefix = '', apiPrefix = '') {
29
+ const uiPrefix = String(prefix || '').replace(/^\/+/, '');
30
+ const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
31
+
32
+ if (!uiPrefix) return '';
33
+ if (!normalizedApiPrefix) return uiPrefix;
34
+ if (uiPrefix === normalizedApiPrefix || uiPrefix.startsWith(`${normalizedApiPrefix}/`)) {
35
+ return uiPrefix;
36
+ }
37
+ return `${normalizedApiPrefix}/${uiPrefix}`;
38
+ }
39
+
14
40
  // Show alert message (Toast)
15
41
  function showAlert(message, type = 'success') {
16
42
  const container = document.getElementById('toast-container');
@@ -51,7 +77,7 @@ function showAlert(message, type = 'success') {
51
77
  toast.innerHTML = `
52
78
  <i class="fas ${isSuccess ? 'fa-check-circle' : 'fa-exclamation-circle'}" style="font-size: 1.25rem;"></i>
53
79
  <div style="flex: 1;">${message}</div>
54
- <button onclick="this.parentElement.remove()" style="background:none; border:none; color:white; cursor:pointer; opacity:0.8; font-size:1.2rem;">&times;</button>
80
+ <button onclick="this.parentElement.remove()" class="toast-close-btn">&times;</button>
55
81
  `;
56
82
 
57
83
  container.appendChild(toast);
@@ -82,8 +108,9 @@ async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort,
82
108
  try {
83
109
  const parts = [];
84
110
  if (prefix) {
111
+ const folderPathForUrl = toApiFolderPath(prefix, currentApiPrefix);
85
112
  // preserve slashes in folder param for readability: encode then restore slashes
86
- const folderEncoded = encodeURIComponent(prefix).replace(/%2F/g, '/');
113
+ const folderEncoded = encodeURIComponent(folderPathForUrl).replace(/%2F/g, '/');
87
114
  parts.push(`folder=${folderEncoded}`);
88
115
  }
89
116
  if (page && page > 1) parts.push(`page=${page}`);
@@ -139,6 +166,7 @@ async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort,
139
166
 
140
167
  // Store next token
141
168
  currentNextToken = data.nextContinuationToken || null;
169
+ currentApiPrefix = data.prefix || '';
142
170
 
143
171
  // Update stats
144
172
  if (data.totalFiles === -1) {
@@ -151,7 +179,7 @@ async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort,
151
179
  filterInfo.textContent = `Search results for: "${search}"`;
152
180
  fileListTitle.innerHTML = `Search Results <span class="text-muted">for: </span><span class="badge bg-info">${search}</span>`;
153
181
  } else {
154
- const label = prefix || bucketAppName || 'Root';
182
+ const label = prefix || bucketAppName || 'app';
155
183
  filterInfo.textContent = `Filtered by folder: ${label}`;
156
184
  fileListTitle.innerHTML = `Files <span class="text-muted">in: </span><span class="badge bg-info">${label}</span>`;
157
185
  }
@@ -176,12 +204,16 @@ async function loadFiles(page = 1, prefix = '', search = '', sort = currentSort,
176
204
  pageFiles = data.files || [];
177
205
 
178
206
  if (data.mode === 'optimized') {
179
- pageFolders = data.folders || [];
207
+ pageFolders = (data.folders || []).map(folder => stripApiPrefixFromPath(folder, currentApiPrefix)).filter(Boolean);
180
208
  } else {
181
- pageFolders = search ? [] : extractFoldersAtCurrentLevel(data.files, prefix);
209
+ pageFolders = search
210
+ ? []
211
+ : extractFoldersAtCurrentLevel(data.files, currentApiPrefix || prefix)
212
+ .map(folder => stripApiPrefixFromPath(folder, currentApiPrefix))
213
+ .filter(Boolean);
182
214
  }
183
215
 
184
- renderFilesTable(pageFiles, pageFolders, prefix);
216
+ renderFilesTable(pageFiles, pageFolders, prefix, currentApiPrefix);
185
217
  renderPagination(data);
186
218
  paginationContainer.style.display = 'block';
187
219
  }
@@ -230,7 +262,7 @@ function toggleSort(field) {
230
262
  return 0;
231
263
  });
232
264
 
233
- renderFilesTable(pageFiles, pageFolders, currentPrefix);
265
+ renderFilesTable(pageFiles, pageFolders, currentPrefix, currentApiPrefix);
234
266
  }
235
267
  }
236
268
 
@@ -299,7 +331,7 @@ function changeViewMode(mode) {
299
331
  }
300
332
 
301
333
  // Re-render with existing data
302
- renderFilesTable(pageFiles, pageFolders, currentPrefix);
334
+ renderFilesTable(pageFiles, pageFolders, currentPrefix, currentApiPrefix);
303
335
  }
304
336
 
305
337
  // Extract folders that match a search query from file paths
@@ -331,7 +363,7 @@ function extractMatchingFolders(files, search) {
331
363
  }
332
364
 
333
365
  // Render files table
334
- function renderFilesTable(files, folders = [], prefix = '') {
366
+ function renderFilesTable(files, folders = [], prefix = '', apiPrefix = '') {
335
367
  // Determine which files/folders to show based on view mode
336
368
  let displayFiles = [];
337
369
  let displayFolders = [];
@@ -347,6 +379,9 @@ function renderFilesTable(files, folders = [], prefix = '') {
347
379
  // 1. Show files where FILENAME matches the search term
348
380
  const searchLower = currentSearch.toLowerCase();
349
381
  displayFiles = files.filter(f => {
382
+ // Skip folder marker objects (keys ending with /)
383
+ if (f.Key.endsWith('/')) return false;
384
+
350
385
  const fileName = f.Key.split('/').pop();
351
386
  return fileName.toLowerCase().includes(searchLower);
352
387
  });
@@ -355,14 +390,19 @@ function renderFilesTable(files, folders = [], prefix = '') {
355
390
  displayFolders = extractMatchingFolders(files, currentSearch);
356
391
  } else {
357
392
  // Normal Flat Mode: Show all files, no folders
358
- displayFiles = files;
393
+ // Filter out folder marker objects (keys ending with /)
394
+ displayFiles = files.filter(f => !f.Key.endsWith('/'));
359
395
  displayFolders = [];
360
396
  }
361
397
  } else {
362
398
  // Normal Folder View logic
363
399
  // Filter files to show only those at current level (not in subfolders)
364
400
  displayFiles = files.filter(file => {
365
- const relativePath = prefix ? file.Key.substring(prefix.length + (prefix.endsWith('/') ? 0 : 1)) : file.Key;
401
+ // Skip folder marker objects (keys ending with /)
402
+ if (file.Key.endsWith('/')) return false;
403
+
404
+ const basePrefix = apiPrefix || prefix;
405
+ const relativePath = basePrefix ? file.Key.substring(basePrefix.length + (basePrefix.endsWith('/') ? 0 : 1)) : file.Key;
366
406
  // File is at current level if it has no '/' in relative path
367
407
  return !relativePath.includes('/');
368
408
  });
@@ -379,7 +419,7 @@ function renderFilesTable(files, folders = [], prefix = '') {
379
419
  <div class="breadcrumb-nav">
380
420
  <div class="breadcrumb-item">
381
421
  <i class="fas fa-home"></i>
382
- <span class="breadcrumb-link" onclick="loadFiles(1, '')" title="Go to root">Root</span>
422
+ <span class="breadcrumb-link" onclick="loadFiles(1, '')" title="Go to ${bucketAppName || 'app'}">${bucketAppName || 'app'}</span>
383
423
  </div>
384
424
  `;
385
425
 
@@ -475,7 +515,7 @@ function renderFilesTable(files, folders = [], prefix = '') {
475
515
 
476
516
  // If in flat mode, we might want to show the folder path more prominently
477
517
  const pathDisplay = (currentViewMode === 'flat' && folderPath && folderPath !== prefix + (prefix ? '/' : ''))
478
- ? `<div style="font-size: 0.75rem; color: var(--gray-color-ml); margin-bottom:2px;">
518
+ ? `<div class="file-path-display">
479
519
  <i class="fas fa-folder" style="margin-right:4px; opacity:0.5;"></i> ${folderPath}
480
520
  </div>`
481
521
  : '';
@@ -528,24 +568,25 @@ function renderFilesTable(files, folders = [], prefix = '') {
528
568
 
529
569
  if (allRows === '') {
530
570
  document.getElementById('fileListContainer').innerHTML = `
571
+ ${breadcrumb}
531
572
  <div class="empty-state">
532
- <div style="margin-bottom: 1.5rem; position: relative; display: inline-block;">
573
+ <div class="empty-state-illustration">
533
574
  <i class="fas fa-folder-open" style="font-size: 4rem; color: #cbd5e0;"></i>
534
- <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>
575
+ <i class="fas fa-search empty-state-search-icon"></i>
535
576
  </div>
536
577
  <h4>No files found</h4>
537
- <p style="max-width: 400px; margin: 0 auto 1.5rem; color: var(--text-muted-color-ml);">
578
+ <p class="empty-state-description">
538
579
  ${prefix ? `This folder <strong>${prefix}</strong> is currently empty.` : 'Your bucket is empty. Start by uploading files or creating a new folder.'}
539
580
  </p>
540
- <div style="display:flex; gap:0.75rem; justify-content:center; flex-wrap: wrap;">
581
+ <div class="empty-state-actions">
541
582
  <button class="btn btn-primary" onclick="window.scrollTo({top: 0, behavior: 'smooth'}); document.getElementById('fileInput').click()">
542
583
  <i class="fas fa-cloud-upload-alt"></i> Upload Files
543
584
  </button>
544
585
  <button class="btn btn-outline-secondary" onclick="createFolder()">
545
586
  <i class="fas fa-folder-plus"></i> New Folder
546
587
  </button>
547
- ${prefix ? `<button class="btn btn-light" onclick="loadFiles(1, '')">
548
- <i class="fas fa-home"></i> Go to Root
588
+ ${prefix ? `<button class="btn btn-light" onclick="loadFiles(1, '')">
589
+ <i class="fas fa-home"></i> Go to ${bucketAppName || 'app'}
549
590
  </button>` : ''}
550
591
  </div>
551
592
  </div>
@@ -555,16 +596,16 @@ function renderFilesTable(files, folders = [], prefix = '') {
555
596
 
556
597
  // Sort icon helper
557
598
  const getSortIcon = (field) => {
558
- 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>';
599
+ if (currentSort !== field) return '<i class="fas fa-sort sort-icon sort-icon-inactive"></i>';
559
600
  return currentOrder === 'asc'
560
- ? '<i class="fas fa-sort-up" style="color:var(--primary-color-ml); font-size:0.9rem; margin-left:0.5rem;"></i>'
561
- : '<i class="fas fa-sort-down" style="color:var(--primary-color-ml); font-size:0.9rem; margin-left:0.5rem;"></i>';
601
+ ? '<i class="fas fa-sort-up sort-icon sort-icon-active"></i>'
602
+ : '<i class="fas fa-sort-down sort-icon sort-icon-active"></i>';
562
603
  };
563
604
 
564
605
  document.getElementById('fileListContainer').innerHTML = `
565
606
  ${breadcrumb}
566
607
  <div class="table-responsive">
567
- <div id="bulkActionsBar" style="display:none; padding: 0.5rem; border-bottom: 1px solid var(--gray-light-color-ml); background: var(--light-color-ml);">
608
+ <div id="bulkActionsBar" class="bulk-actions-bar">
568
609
  <span id="bulkSelectedCount">0 selected</span>
569
610
  <div style="float: right;">
570
611
  <button class="btn btn-danger btn-sm" id="bulkDeleteBtn" disabled>
@@ -823,7 +864,7 @@ function createId() { return Date.now().toString(36) + '-' + Math.random().toStr
823
864
 
824
865
  function addFilesToQueue(fileList) {
825
866
  for (const f of Array.from(fileList)) {
826
- uploadQueue.push({ id: createId(), file: f, status: 'queued', progress: 0 });
867
+ uploadQueue.push({ id: createId(), file: f, status: 'queued', progress: 0, uploadedBytes: 0, uploadRate: 0 });
827
868
  }
828
869
  renderUploadQueue();
829
870
  }
@@ -839,28 +880,46 @@ function renderUploadQueue() {
839
880
  return;
840
881
  }
841
882
 
842
- container.innerHTML = uploadQueue.map(item => `
883
+ // Local size formatter so it's always in scope regardless of IIFE load order
884
+ function fmtSize(bytes) {
885
+ if (!bytes || bytes === 0) return '0 B';
886
+ const units = ['B', 'KB', 'MB', 'GB'];
887
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
888
+ return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
889
+ }
890
+
891
+ container.innerHTML = uploadQueue.map(item => {
892
+ let statusText;
893
+ if (item.status === 'uploading') {
894
+ const pct = Math.round(item.progress || 0);
895
+ statusText = fmtSize(item.uploadedBytes || 0) + ' / ' + fmtSize(item.file.size) + ' (' + pct + '%)';
896
+ if (item.uploadRate > 0) statusText += ' \u00b7 ' + fmtSize(item.uploadRate) + '/s';
897
+ } else {
898
+ statusText = item.status;
899
+ }
900
+ const actionBtn = item.status === 'uploading'
901
+ ? `<button class="btn btn-sm btn-outline-secondary cancel-upload" data-id="${item.id}"><i class="fas fa-ban"></i></button>`
902
+ : `<button class="btn btn-sm btn-danger remove-queued" data-id="${item.id}"><i class="fas fa-trash"></i></button>`;
903
+ return `
843
904
  <div class="upload-item" data-id="${item.id}">
844
905
  <div class="meta">
845
- <div style="display:flex; align-items:center; gap:0.75rem; min-width:0; width:100%; margin-bottom:5px;">
906
+ <div class="upload-item-meta-row">
846
907
  <div class="file-icon"><i class="fas fa-file"></i></div>
847
908
  <div class="name">${item.file.name}</div>
848
- <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>
909
+ <div class="form-text file-size upload-item-size-label">${formatFileSize(item.file.size)}</div>
849
910
  </div>
850
911
  </div>
851
912
  <div style="min-width:220px;">
852
913
  <div class="progress-container" style="height: 8px; margin-bottom: 6px;">
853
914
  <div class="progress-bar" style="width: ${Math.round(item.progress)}%; height: 8px;"></div>
854
915
  </div>
855
- <div style="display:flex; justify-content:space-between; align-items:center; gap:0.5rem;">
856
- <small class="text-muted status">${item.status}</small>
857
- <div class="controls">
858
- ${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>`}
859
- </div>
916
+ <div class="upload-item-status-row">
917
+ <small class="text-muted status">${statusText}</small>
918
+ <div class="controls">${actionBtn}</div>
860
919
  </div>
861
920
  </div>
862
- </div>
863
- `).join('');
921
+ </div>`;
922
+ }).join('');
864
923
 
865
924
  // Wire up controls
866
925
  document.querySelectorAll('.remove-queued').forEach(btn => btn.addEventListener('click', function (e) {
@@ -870,15 +929,27 @@ function renderUploadQueue() {
870
929
  renderUploadQueue();
871
930
  }));
872
931
 
873
- document.querySelectorAll('.cancel-upload').forEach(btn => btn.addEventListener('click', function (e) {
932
+ document.querySelectorAll('.cancel-upload').forEach(btn => btn.addEventListener('click', async function (e) {
874
933
  const id = this.dataset.id;
875
934
  const handle = xhrMap.get(id);
876
- if (handle && handle.abort) {
877
- handle.abort();
878
- } else if (handle && handle.xhr) {
879
- handle.xhr.abort();
880
- }
881
935
  const entry = uploadQueue.find(i => i.id === id);
936
+
937
+ // Abort in-flight fetch
938
+ if (handle && handle.controller) handle.controller.abort();
939
+ // Abort legacy XHR (small file path)
940
+ if (handle && handle.xhr) handle.xhr.abort();
941
+
942
+ // Tell S3 to discard the incomplete multipart upload
943
+ if (entry && entry.s3UploadId && entry.s3Key) {
944
+ try {
945
+ await fetch('/mbkbucket/upload-abort', {
946
+ method: 'POST',
947
+ headers: { 'Content-Type': 'application/json' },
948
+ body: JSON.stringify({ uploadId: entry.s3UploadId, key: entry.s3Key })
949
+ });
950
+ } catch (_) { /* best-effort */ }
951
+ }
952
+
882
953
  if (entry) { entry.status = 'cancelled'; entry.progress = 0; }
883
954
  xhrMap.delete(id);
884
955
  renderUploadQueue();
@@ -913,41 +984,90 @@ async function startUploadEntry(entry) {
913
984
  const file = entry.file;
914
985
  try {
915
986
  if (file.size > CHUNK_SIZE) {
916
- // chunked upload
917
- const uploadId = createId();
918
- entry.uploadId = uploadId;
987
+ // ---------------------------------------------------------------
988
+ // S3 Multipart Upload
989
+ // Step 1 — Initiate: get uploadId + key from the server
990
+ // ---------------------------------------------------------------
991
+ const initResp = await fetch('/mbkbucket/upload-init', {
992
+ method: 'POST',
993
+ headers: { 'Content-Type': 'application/json' },
994
+ body: JSON.stringify({
995
+ fileName: file.name,
996
+ prefix: currentPrefix,
997
+ contentType: file.type || 'application/octet-stream'
998
+ })
999
+ });
1000
+ const initJson = await initResp.json();
1001
+ if (!initResp.ok || !initJson.success) throw new Error(initJson.error || 'Failed to initiate upload');
1002
+
1003
+ const { uploadId, key } = initJson;
1004
+ entry.s3UploadId = uploadId; // stored so cancel handler can abort
1005
+ entry.s3Key = key;
1006
+
1007
+ // ---------------------------------------------------------------
1008
+ // Step 2 — Upload parts (1-based partNumber, min 5 MB except last)
1009
+ // ---------------------------------------------------------------
919
1010
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
1011
+ const parts = []; // [{ partNumber, ETag }]
920
1012
  let uploadedBytes = 0;
1013
+
921
1014
  for (let i = 0; i < totalChunks; i++) {
922
- if (entry.status === 'cancelled') throw new Error('Upload cancelled');
1015
+ const _chunkStart = Date.now();
1016
+ if (entry.status === 'cancelled') {
1017
+ // Clean up on S3
1018
+ await fetch('/mbkbucket/upload-abort', {
1019
+ method: 'POST',
1020
+ headers: { 'Content-Type': 'application/json' },
1021
+ body: JSON.stringify({ uploadId, key })
1022
+ }).catch(() => {});
1023
+ throw new Error('Upload cancelled');
1024
+ }
1025
+
923
1026
  const start = i * CHUNK_SIZE;
924
- const end = Math.min(file.size, start + CHUNK_SIZE);
925
- const blob = file.slice(start, end);
1027
+ const blob = file.slice(start, Math.min(file.size, start + CHUNK_SIZE));
1028
+ const partNumber = i + 1; // S3 parts are 1-based
926
1029
 
927
1030
  const fd = new FormData();
928
1031
  fd.append('chunk', blob, file.name);
929
1032
  fd.append('uploadId', uploadId);
930
- fd.append('fileName', file.name);
931
- fd.append('chunkIndex', String(i));
932
- fd.append('totalChunks', String(totalChunks));
1033
+ fd.append('key', key);
1034
+ fd.append('partNumber', String(partNumber));
933
1035
 
934
1036
  const controller = new AbortController();
935
- xhrMap.set(entry.id, { controller });
1037
+ xhrMap.set(entry.id, { controller, s3UploadId: uploadId, s3Key: key });
936
1038
 
937
- const resp = await fetch('/mbkbucket/upload-chunk', { method: 'POST', body: fd, signal: controller.signal });
938
- if (!resp.ok) throw new Error('Chunk upload failed');
1039
+ const chunkResp = await fetch('/mbkbucket/upload-chunk', {
1040
+ method: 'POST',
1041
+ body: fd,
1042
+ signal: controller.signal
1043
+ });
1044
+ const chunkJson = await chunkResp.json();
1045
+ if (!chunkResp.ok || !chunkJson.success) throw new Error(chunkJson.error || `Part ${partNumber} upload failed`);
1046
+
1047
+ parts.push({ partNumber: chunkJson.partNumber, ETag: chunkJson.ETag });
939
1048
  uploadedBytes += blob.size;
940
- entry.progress = (uploadedBytes / file.size) * 95; // up to 95% until assembly
1049
+ const _chunkDuration = (Date.now() - _chunkStart) / 1000;
1050
+ entry.uploadRate = _chunkDuration > 0 ? blob.size / _chunkDuration : 0;
1051
+ entry.uploadedBytes = uploadedBytes;
1052
+ entry.progress = (uploadedBytes / file.size) * 95; // reserve last 5% for complete call
941
1053
  renderUploadQueue();
942
1054
  }
943
1055
 
944
- // complete
945
- 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' }) });
1056
+ // ---------------------------------------------------------------
1057
+ // Step 3 — Complete: assemble all parts on S3
1058
+ // ---------------------------------------------------------------
1059
+ const completeResp = await fetch('/mbkbucket/upload-complete', {
1060
+ method: 'POST',
1061
+ headers: { 'Content-Type': 'application/json' },
1062
+ body: JSON.stringify({ uploadId, key, parts })
1063
+ });
946
1064
  const completeJson = await completeResp.json();
947
- if (!completeResp.ok || !completeJson.success) throw new Error(completeJson.error || 'Assemble failed');
1065
+ if (!completeResp.ok || !completeJson.success) throw new Error(completeJson.error || 'Failed to complete upload');
948
1066
 
949
- entry.status = 'done'; entry.progress = 100; xhrMap.delete(entry.id); renderUploadQueue();
950
- showAlert('File uploaded successfully', 'success');
1067
+ entry.status = 'done'; entry.progress = 100;
1068
+ xhrMap.delete(entry.id);
1069
+ renderUploadQueue();
1070
+ showAlert(`${file.name} uploaded successfully`, 'success');
951
1071
  loadFiles(currentPage, currentPrefix);
952
1072
  } else {
953
1073
  // small file XHR
@@ -960,9 +1080,20 @@ async function startUploadEntry(entry) {
960
1080
 
961
1081
  xhr.upload.addEventListener('progress', (e) => {
962
1082
  if (!e.lengthComputable) return;
1083
+ const _now = Date.now();
1084
+ const _dt = (_now - (entry._rateLastTime || _now)) / 1000;
1085
+ const _db = e.loaded - (entry._rateLastBytes || 0);
1086
+ if (_dt > 0.2) {
1087
+ entry.uploadRate = _db / _dt;
1088
+ entry._rateLastTime = _now;
1089
+ entry._rateLastBytes = e.loaded;
1090
+ }
1091
+ entry.uploadedBytes = e.loaded;
963
1092
  entry.progress = (e.loaded / e.total) * 100;
964
1093
  renderUploadQueue();
965
1094
  });
1095
+ entry._rateLastTime = Date.now();
1096
+ entry._rateLastBytes = 0;
966
1097
 
967
1098
  xhr.addEventListener('load', () => {
968
1099
  try {
@@ -990,7 +1121,16 @@ async function startUploadEntry(entry) {
990
1121
  }
991
1122
  } catch (err) {
992
1123
  console.error('Upload failed for', entry.file.name, err);
1124
+ // If a multipart upload was initiated but didn't finish (not a user cancel), abort it on S3
1125
+ if (entry.s3UploadId && entry.s3Key && entry.status !== 'cancelled') {
1126
+ fetch('/mbkbucket/upload-abort', {
1127
+ method: 'POST',
1128
+ headers: { 'Content-Type': 'application/json' },
1129
+ body: JSON.stringify({ uploadId: entry.s3UploadId, key: entry.s3Key })
1130
+ }).catch(() => {});
1131
+ }
993
1132
  entry.status = entry.status === 'cancelled' ? 'cancelled' : 'error';
1133
+ xhrMap.delete(entry.id);
994
1134
  renderUploadQueue();
995
1135
  showAlert('Upload failed: ' + (err.message || 'unknown'), 'error');
996
1136
  }
@@ -1036,7 +1176,7 @@ document.addEventListener('DOMContentLoaded', function () {
1036
1176
 
1037
1177
  // Load initial files (support both legacy 'prefix' and new 'folder' query param)
1038
1178
  const urlParams = new URLSearchParams(window.location.search);
1039
- const initialPrefix = urlParams.get('folder') || urlParams.get('prefix') || bucketAppName || '';
1179
+ const initialPrefix = urlParams.get('folder') || urlParams.get('prefix') || '';
1040
1180
  const initialPage = parseInt(urlParams.get('page')) || 1;
1041
1181
  loadFiles(initialPage, initialPrefix);
1042
1182
 
@@ -1079,6 +1219,37 @@ document.addEventListener('DOMContentLoaded', function () {
1079
1219
  return;
1080
1220
  }
1081
1221
 
1222
+ const cbtn = e.target.closest('.copy-btn');
1223
+ console.log(cbtn);
1224
+ if (cbtn) {
1225
+ e.stopPropagation();
1226
+ const keyEnc = cbtn.dataset.keyEnc;
1227
+ if (!keyEnc) return;
1228
+ const link = `${window.location.origin}/mbkbucket/view/${keyEnc}`;
1229
+ const fallbackCopy = () => {
1230
+ try {
1231
+ const ta = document.createElement('textarea');
1232
+ ta.value = link;
1233
+ ta.style.position = 'fixed';
1234
+ ta.style.opacity = '0';
1235
+ document.body.appendChild(ta);
1236
+ ta.select();
1237
+ document.execCommand('copy');
1238
+ document.body.removeChild(ta);
1239
+ showAlert('Link copied to clipboard!', 'success');
1240
+ } catch {
1241
+ showAlert('Failed to copy link', 'error');
1242
+ }
1243
+ };
1244
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1245
+ navigator.clipboard.writeText(link).then(() => {
1246
+ showAlert('Link copied to clipboard!', 'success');
1247
+ }).catch(fallbackCopy);
1248
+ } else {
1249
+ fallbackCopy();
1250
+ }
1251
+ return;
1252
+ }
1082
1253
 
1083
1254
  });
1084
1255
  });
@@ -1103,25 +1274,32 @@ function handleViewFile(key, filename, event) {
1103
1274
  const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'];
1104
1275
  const videoTypes = ['mp4', 'webm', 'ogg'];
1105
1276
 
1106
- // Use inline preview modal for supported types
1277
+ // Determine preview mode from extension groups
1107
1278
  const previewableImage = imageTypes.includes(extension);
1108
1279
  const previewableVideo = videoTypes.includes(extension);
1109
1280
  const previewableAudio = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(extension);
1110
1281
  const previewablePdf = extension === 'pdf';
1111
1282
  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);
1112
1283
 
1284
+ let previewMode = 'unsupported';
1285
+ if (previewableImage) previewMode = 'image';
1286
+ else if (previewableVideo) previewMode = 'video';
1287
+ else if (previewableAudio) previewMode = 'audio';
1288
+ else if (previewablePdf) previewMode = 'pdf';
1289
+ else if (previewableText) previewMode = 'text';
1290
+
1113
1291
  // Build an encoded key safely (if key already appears encoded, don't double-encode)
1114
1292
  const encodedKey = (key && key.includes('%')) ? key : encodeURIComponent(key || filename);
1115
1293
 
1116
1294
  // Show the inline preview
1117
- showPreviewModal(encodedKey, filename, extension);
1295
+ showPreviewModal(encodedKey, filename, extension, previewMode);
1118
1296
  return false;
1119
1297
  }
1120
1298
 
1121
1299
  // Function to get file type category
1122
1300
 
1123
1301
  // Preview modal helpers
1124
- function showPreviewModal(encodedKey, filename, extension) {
1302
+ function showPreviewModal(encodedKey, filename, extension, previewMode = 'auto') {
1125
1303
  const modal = document.getElementById('previewModal');
1126
1304
  if (!modal) {
1127
1305
  window.open(`/mbkbucket/view/${encodedKey}`, '_blank', 'noopener,noreferrer');
@@ -1153,9 +1331,17 @@ function showPreviewModal(encodedKey, filename, extension) {
1153
1331
  const videoTypes = ['mp4', 'webm', 'ogg'];
1154
1332
  const audioTypes = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
1155
1333
  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'];
1334
+ const effectiveMode = previewMode === 'auto'
1335
+ ? (imgTypes.includes(extension) ? 'image'
1336
+ : videoTypes.includes(extension) ? 'video'
1337
+ : audioTypes.includes(extension) ? 'audio'
1338
+ : extension === 'pdf' ? 'pdf'
1339
+ : textTypes.includes(extension) ? 'text'
1340
+ : 'unsupported')
1341
+ : previewMode;
1156
1342
 
1157
1343
  // Render content and adjust dialog size
1158
- if (imgTypes.includes(extension)) {
1344
+ if (effectiveMode === 'image') {
1159
1345
  dialog.classList.add('preview-compact');
1160
1346
  const img = document.createElement('img');
1161
1347
  img.className = 'preview-img';
@@ -1165,7 +1351,7 @@ function showPreviewModal(encodedKey, filename, extension) {
1165
1351
  img.onerror = () => { contentEl.textContent = 'Could not load preview.'; };
1166
1352
  contentEl.innerHTML = '';
1167
1353
  contentEl.appendChild(img);
1168
- } else if (videoTypes.includes(extension)) {
1354
+ } else if (effectiveMode === 'video') {
1169
1355
  dialog.classList.add('preview-large');
1170
1356
  const video = document.createElement('video');
1171
1357
  video.className = 'preview-video';
@@ -1173,7 +1359,7 @@ function showPreviewModal(encodedKey, filename, extension) {
1173
1359
  video.src = `/mbkbucket/view/${encodedKey}`;
1174
1360
  contentEl.innerHTML = '';
1175
1361
  contentEl.appendChild(video);
1176
- } else if (audioTypes.includes(extension)) {
1362
+ } else if (effectiveMode === 'audio') {
1177
1363
  dialog.classList.add('preview-compact');
1178
1364
  const audio = document.createElement('audio');
1179
1365
  audio.className = 'preview-audio';
@@ -1181,14 +1367,14 @@ function showPreviewModal(encodedKey, filename, extension) {
1181
1367
  audio.src = `/mbkbucket/view/${encodedKey}`;
1182
1368
  contentEl.innerHTML = '';
1183
1369
  contentEl.appendChild(audio);
1184
- } else if (extension === 'pdf') {
1370
+ } else if (effectiveMode === 'pdf') {
1185
1371
  dialog.classList.add('preview-large');
1186
1372
  const iframe = document.createElement('iframe');
1187
1373
  iframe.className = 'preview-iframe';
1188
1374
  iframe.src = `/mbkbucket/view/${encodedKey}`;
1189
1375
  contentEl.innerHTML = '';
1190
1376
  contentEl.appendChild(iframe);
1191
- } else if (textTypes.includes(extension)) {
1377
+ } else if (effectiveMode === 'text') {
1192
1378
  dialog.classList.add('preview-compact');
1193
1379
 
1194
1380
  fetch(`/mbkbucket/view/${encodedKey}`)
@@ -63,12 +63,12 @@
63
63
  /* Removed unused sidebar/menu styles to reduce CSS size. */
64
64
 
65
65
  .logo {
66
- font-size: 1.25rem;
66
+ font-size: 2rem;
67
67
  font-weight: 700;
68
68
  color: var(--primary-color-ml);
69
69
  display: flex;
70
70
  align-items: center;
71
- gap: .5rem
71
+ gap: .3rem
72
72
  }
73
73
 
74
74
  .logo img {
@@ -205,7 +205,7 @@
205
205
  <nav class="top-nav">
206
206
  <div class="logo">
207
207
  <img onclick="window.location='/'" src="/icon.svg" alt="Logo">
208
- <span onclick="window.location='/'" style="cursor:pointer;">MBKBucket</span>
208
+ <span onclick="window.location='/'" style="cursor:pointer;">BKBucket</span>
209
209
  </div>
210
210
 
211
211
  {{> profilemenu }}
@@ -0,0 +1,15 @@
1
+ {{#if message}}
2
+ <div class="alert alert-success alert-dismissible fade show" role="alert" id="successAlert">
3
+ <i class="fas fa-check-circle"></i>
4
+ <span id="successMessage">{{message}}</span>
5
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">&times;</button>
6
+ </div>
7
+ {{/if}}
8
+
9
+ {{#if error}}
10
+ <div class="alert alert-danger alert-dismissible fade show" role="alert" id="errorAlert">
11
+ <i class="fas fa-exclamation-triangle"></i>
12
+ <span id="errorMessage">{{error}}</span>
13
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">&times;</button>
14
+ </div>
15
+ {{/if}}