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.
- package/.env.example +6 -3
- package/.github/PACKAGE.md +0 -4
- package/README.md +59 -14
- package/index.d.ts +192 -39
- package/index.js +4 -79
- package/lib/config/index.js +134 -1
- package/lib/index.js +2 -2
- package/lib/routes/api.js +501 -0
- package/lib/routes/index.js +17 -0
- package/lib/routes/info.js +28 -0
- package/lib/routes/view.js +458 -0
- package/lib/s3.js +297 -54
- package/package.json +3 -2
- package/public/bucketadmin.css +144 -32
- package/public/bucketadmin.js +253 -67
- package/views/bucket.handlebars +3 -3
- package/views/bucketadmin_alerts.handlebars +15 -0
- package/views/bucketadmin_delete_modal.handlebars +40 -0
- package/views/bucketadmin_filelist_skeleton.handlebars +20 -0
- package/views/bucketadmin_preview_modal.handlebars +33 -0
- package/views/bucketadmincontent.handlebars +7 -95
- package/views/mbkbucket_info.handlebars +226 -0
- package/lib/bucket.js +0 -726
- package/views/info.handlebars +0 -1056
package/public/bucketadmin.js
CHANGED
|
@@ -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()"
|
|
80
|
+
<button onclick="this.parentElement.remove()" class="toast-close-btn">×</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(
|
|
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 || '
|
|
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
|
-
|
|
207
|
+
pageFolders = (data.folders || []).map(folder => stripApiPrefixFromPath(folder, currentApiPrefix)).filter(Boolean);
|
|
180
208
|
} else {
|
|
181
|
-
|
|
209
|
+
pageFolders = search
|
|
210
|
+
? []
|
|
211
|
+
: extractFoldersAtCurrentLevel(data.files, currentApiPrefix || prefix)
|
|
212
|
+
.map(folder => stripApiPrefixFromPath(folder, currentApiPrefix))
|
|
213
|
+
.filter(Boolean);
|
|
182
214
|
}
|
|
183
215
|
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
575
|
+
<i class="fas fa-search empty-state-search-icon"></i>
|
|
535
576
|
</div>
|
|
536
577
|
<h4>No files found</h4>
|
|
537
|
-
<p
|
|
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
|
|
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
|
-
|
|
548
|
-
|
|
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
|
|
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
|
|
561
|
-
: '<i class="fas fa-sort-down
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
856
|
-
<small class="text-muted status">${
|
|
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
|
-
|
|
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
|
-
//
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
|
|
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
|
|
925
|
-
const
|
|
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('
|
|
931
|
-
fd.append('
|
|
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
|
|
938
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
945
|
-
|
|
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 || '
|
|
1065
|
+
if (!completeResp.ok || !completeJson.success) throw new Error(completeJson.error || 'Failed to complete upload');
|
|
948
1066
|
|
|
949
|
-
entry.status = 'done'; entry.progress = 100;
|
|
950
|
-
|
|
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') ||
|
|
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
|
-
//
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
1377
|
+
} else if (effectiveMode === 'text') {
|
|
1192
1378
|
dialog.classList.add('preview-compact');
|
|
1193
1379
|
|
|
1194
1380
|
fetch(`/mbkbucket/view/${encodedKey}`)
|
package/views/bucket.handlebars
CHANGED
|
@@ -63,12 +63,12 @@
|
|
|
63
63
|
/* Removed unused sidebar/menu styles to reduce CSS size. */
|
|
64
64
|
|
|
65
65
|
.logo {
|
|
66
|
-
font-size:
|
|
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: .
|
|
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;">
|
|
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">×</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">×</button>
|
|
14
|
+
</div>
|
|
15
|
+
{{/if}}
|