mbkbucket 3.5.1 → 3.6.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.
Files changed (42) hide show
  1. package/.env.example +14 -14
  2. package/.mbkbucket.config.example.json +14 -14
  3. package/LICENSE +20 -20
  4. package/README.md +377 -350
  5. package/index.js +30 -30
  6. package/lib/cli.js +649 -649
  7. package/lib/public/bucketadmin.css +1420 -1420
  8. package/lib/public/bucketadmin.js +1229 -1229
  9. package/lib/public/mbkbucket-helper-demo.html +1184 -1184
  10. package/lib/public/mbkbucket-helper.css +1235 -1235
  11. package/lib/public/mbkbucket-helper.js +1557 -1557
  12. package/lib/src/app.js +109 -109
  13. package/lib/src/cli/auth-helper.js +150 -150
  14. package/lib/src/config/index.js +112 -112
  15. package/lib/src/config/validation.js +73 -73
  16. package/lib/src/controllers/api.controller.js +410 -410
  17. package/lib/src/controllers/dashboard.controller.js +48 -48
  18. package/lib/src/controllers/info.controller.js +34 -34
  19. package/lib/src/controllers/view.controller.js +233 -233
  20. package/lib/src/middleware/async-handler.js +23 -23
  21. package/lib/src/middleware/bucket-resolver.js +37 -37
  22. package/lib/src/middleware/error-handler.js +54 -54
  23. package/lib/src/middleware/rate-limiter.js +22 -22
  24. package/lib/src/middleware/security.js +27 -27
  25. package/lib/src/routes/api.routes.js +54 -40
  26. package/lib/src/routes/index.js +40 -21
  27. package/lib/src/routes/info.routes.js +9 -9
  28. package/lib/src/routes/view.routes.js +25 -20
  29. package/lib/src/services/s3.service.js +603 -603
  30. package/lib/src/utils/errors.js +54 -54
  31. package/lib/src/utils/helpers.js +251 -251
  32. package/lib/src/utils/logger.js +17 -17
  33. package/package.json +83 -83
  34. package/views/bucket.handlebars +196 -196
  35. package/views/bucketadmin_alerts.handlebars +15 -15
  36. package/views/bucketadmin_delete_modal.handlebars +36 -36
  37. package/views/bucketadmin_filelist_skeleton.handlebars +32 -32
  38. package/views/bucketadmin_newfolder_modal.handlebars +29 -29
  39. package/views/bucketadmin_preview_modal.handlebars +40 -40
  40. package/views/bucketadmin_upload_modal.handlebars +54 -54
  41. package/views/bucketadmincontent.handlebars +141 -141
  42. package/views/mbkbucket_info.handlebars +232 -232
@@ -1,1229 +1,1229 @@
1
- /**
2
- * MBKBucket
3
- * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
- * Licensed under the MIT License.
5
- * Source: https://github.com/MIbnEKhalid/mbkbucket
6
- */
7
-
8
- /**
9
- * MBKBucket File Manager - UI & Client Logic
10
- */
11
-
12
- (function () {
13
- 'use strict';
14
-
15
- // Global State
16
- let currentPage = 1;
17
- let currentPrefix = '';
18
- let currentSearch = '';
19
- let currentSort = 'name';
20
- let currentOrder = 'asc';
21
- let currentViewMode = localStorage.getItem('mbk_view_mode') || 'grid'; // 'grid' or 'list'
22
- let currentQuickFilter = 'all'; // 'all', 'media', 'docs', 'code', 'starred'
23
- let pageFiles = [];
24
- let pageFolders = [];
25
- let currentApiPrefix = '';
26
- let currentNextToken = null;
27
- let selectedFiles = new Set();
28
- let starredFiles = new Set(JSON.parse(localStorage.getItem('mbk_starred_keys') || '[]'));
29
-
30
- // Upload State
31
- let uploadQueue = [];
32
- let isUploading = false;
33
-
34
- // DOM Elements & Config
35
- const bucketAppData = document.getElementById('bucketAppData');
36
- const bucketAppName = bucketAppData?.dataset.appName || '';
37
- const urlParams = new URLSearchParams(window.location.search);
38
- const selectedBucket = urlParams.get('bucket') || '';
39
- let activeBucket = selectedBucket;
40
- const app = String(bucketAppName || '').toLowerCase();
41
- const isRootApp = app === 'portal' || app === 'mbkbucket';
42
- const rootLabel = isRootApp ? 'My Drive' : (bucketAppName || 'Bucket Drive');
43
-
44
- // File categories & extensions
45
- const MEDIA_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'mp4', 'webm', 'ogg', 'avi', 'mov', 'mp3', 'wav', 'flac', 'aac', 'm4a']);
46
- const DOC_EXTS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'rtf']);
47
- const CODE_EXTS = new Set(['js', 'ts', 'jsx', 'tsx', 'html', 'htm', 'css', 'scss', 'php', 'py', 'java', 'cpp', 'c', 'h', 'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml', 'toml', 'ini', 'conf', 'json', 'xml', 'log']);
48
-
49
- const FILE_TYPES = {
50
- pdf: { color: 'var(--red)', bg: 'var(--red-lt)', label: 'PDF' },
51
- doc: { color: 'var(--blue)', bg: 'var(--blue-lt)', label: 'DOC' },
52
- docx: { color: 'var(--blue)', bg: 'var(--blue-lt)', label: 'DOC' },
53
- xls: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'XLS' },
54
- xlsx: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'XLS' },
55
- csv: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'CSV' },
56
- ppt: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'PPT' },
57
- pptx: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'PPT' },
58
- txt: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'TXT' },
59
- md: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'MD' },
60
- jpg: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
61
- jpeg: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
62
- png: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
63
- gif: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'GIF' },
64
- webp: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
65
- svg: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'SVG' },
66
- mp4: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
67
- webm: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
68
- mov: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
69
- mp3: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'AUD' },
70
- wav: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'AUD' },
71
- zip: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'ZIP' },
72
- tar: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'TAR' },
73
- gz: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'GZ' },
74
- code: { color: 'var(--cyan)', bg: 'var(--cyan-lt)', label: 'CODE' }
75
- };
76
-
77
- // SVGs
78
- const ICONS = {
79
- folder: '<svg class="folder-svg" viewBox="0 0 32 32" fill="var(--primary-lt)" stroke="var(--primary)" stroke-width="1.3"><path d="M4 8a2 2 0 012-2h6l3 3h11a2 2 0 012 2v13a2 2 0 01-2 2H6a2 2 0 01-2-2V8z"/></svg>',
80
- folderSm: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4a1 1 0 011-1h3l2 2h5a1 1 0 011 1v6a1 1 0 01-1 1H3a1 1 0 01-1-1V4z"/></svg>',
81
- drive: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6l4-4h4l4 4v8H2V6z"/></svg>',
82
- starred: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1l2 5 5 .5-4 3.5 1 5-4-2.5-4 2.5 1-5-4-3.5 5-.5z"/></svg>',
83
- media: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="12" height="12" rx="2"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M14 10l-4-4-6 6"/></svg>',
84
- docs: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2h7l4 4v8a1 1 0 01-1 1H3a1 1 0 01-1-1V3a1 1 0 011-1z"/><path d="M10 2v4h4"/></svg>',
85
- code: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 5L2 8l3 3M11 5l3 3-3 3M9 3L7 13"/></svg>',
86
- more: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="3" r="1.2"/><circle cx="8" cy="8" r="1.2"/><circle cx="8" cy="13" r="1.2"/></svg>'
87
- };
88
-
89
- // Helper Functions
90
- function escapeHtml(str) {
91
- return String(str || '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
92
- }
93
-
94
- function escapeAttr(str) {
95
- return escapeHtml(str);
96
- }
97
-
98
- function getBaseName(path) {
99
- const val = String(path || '').replace(/\/+$/, '');
100
- const lastSlash = val.lastIndexOf('/');
101
- return lastSlash === -1 ? val : val.substring(lastSlash + 1);
102
- }
103
-
104
- function getFileExt(fileName) {
105
- const name = getBaseName(fileName);
106
- const dot = name.lastIndexOf('.');
107
- return dot > -1 ? name.slice(dot + 1).toLowerCase() : '';
108
- }
109
-
110
- function formatFileSize(bytes) {
111
- if (!bytes || bytes === 0) return '0 B';
112
- const k = 1024;
113
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
114
- const i = Math.floor(Math.log(bytes) / Math.log(k));
115
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
116
- }
117
-
118
- function formatDate(dateStr) {
119
- if (!dateStr) return '—';
120
- const d = new Date(dateStr);
121
- if (isNaN(d.getTime())) return String(dateStr);
122
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
123
- }
124
-
125
- function withBucketUrl(url) {
126
- if (!activeBucket) return url;
127
- const sep = url.includes('?') ? '&' : '?';
128
- return `${url}${sep}bucket=${encodeURIComponent(activeBucket)}`;
129
- }
130
-
131
- function getViewUrl(key) {
132
- return withBucketUrl(`/mbkbucket/view/${encodeURIComponent(key)}`);
133
- }
134
-
135
- function getDownloadUrl(key) {
136
- return withBucketUrl(`/mbkbucket/download/${encodeURIComponent(key)}`);
137
- }
138
-
139
- function isImage(fileName) {
140
- const ext = getFileExt(fileName);
141
- return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext);
142
- }
143
-
144
- function isVideo(fileName) {
145
- const ext = getFileExt(fileName);
146
- return ['mp4', 'webm', 'ogg', 'mov', 'mkv'].includes(ext);
147
- }
148
-
149
- function isAudio(fileName) {
150
- const ext = getFileExt(fileName);
151
- return ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext);
152
- }
153
-
154
- function isTextOrCode(fileName) {
155
- const ext = getFileExt(fileName);
156
- return CODE_EXTS.has(ext) || ['txt', 'md', 'csv', 'log', 'json', 'xml', 'env'].includes(ext);
157
- }
158
-
159
- function isPdf(fileName) {
160
- return getFileExt(fileName) === 'pdf';
161
- }
162
-
163
- function getFileTypeConfig(fileName) {
164
- const ext = getFileExt(fileName);
165
- if (FILE_TYPES[ext]) return FILE_TYPES[ext];
166
- if (CODE_EXTS.has(ext)) return FILE_TYPES.code;
167
- return { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: ext.toUpperCase().slice(0, 4) || 'FILE' };
168
- }
169
-
170
- function stripApiPrefixFromPath(path, apiPrefix = '') {
171
- const normalizedPath = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
172
- const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
173
- if (!normalizedApiPrefix) return normalizedPath;
174
- if (normalizedPath === normalizedApiPrefix || normalizedPath === `${normalizedApiPrefix}/`) return '';
175
- if (normalizedPath.startsWith(`${normalizedApiPrefix}/`)) {
176
- return normalizedPath.slice(normalizedApiPrefix.length + 1);
177
- }
178
- return normalizedPath;
179
- }
180
-
181
- function toApiFolderPath(prefix = '', apiPrefix = '') {
182
- const uiPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
183
- const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
184
- if (!uiPrefix) return '';
185
- if (!normalizedApiPrefix) return uiPrefix;
186
- if (uiPrefix === normalizedApiPrefix || uiPrefix.startsWith(`${normalizedApiPrefix}/`)) return uiPrefix;
187
- return `${normalizedApiPrefix}/${uiPrefix}`;
188
- }
189
-
190
- function extractFoldersAtCurrentLevel(files, prefix = '') {
191
- const folderSet = new Set();
192
- const cleanPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
193
- const prefixOffset = cleanPrefix ? cleanPrefix.length + 1 : 0;
194
-
195
- (files || []).forEach(file => {
196
- const key = String(file.Key || '').replace(/^\/+/, '');
197
- if (cleanPrefix && !key.startsWith(cleanPrefix + '/')) return;
198
- const relPath = cleanPrefix ? key.substring(prefixOffset) : key;
199
- const slashIdx = relPath.indexOf('/');
200
- if (slashIdx !== -1) {
201
- const folder = relPath.substring(0, slashIdx);
202
- folderSet.add(cleanPrefix ? `${cleanPrefix}/${folder}` : folder);
203
- }
204
- });
205
-
206
- return Array.from(folderSet).sort();
207
- }
208
-
209
- function syncStateUrl() {
210
- try {
211
- const parts = [];
212
- if (activeBucket) parts.push(`bucket=${encodeURIComponent(activeBucket)}`);
213
- if (currentPrefix) {
214
- const folderPath = toApiFolderPath(currentPrefix, currentApiPrefix);
215
- parts.push(`folder=${encodeURIComponent(folderPath).replace(/%2F/g, '/')}`);
216
- }
217
- if (currentPage > 1) parts.push(`page=${currentPage}`);
218
- if (currentSearch) parts.push(`search=${encodeURIComponent(currentSearch)}`);
219
- if (currentSort !== 'name') parts.push(`sort=${encodeURIComponent(currentSort)}`);
220
- if (currentOrder !== 'asc') parts.push(`order=${encodeURIComponent(currentOrder)}`);
221
- if (currentViewMode !== 'grid') parts.push(`view=${encodeURIComponent(currentViewMode)}`);
222
- if (currentQuickFilter !== 'all') parts.push(`filter=${encodeURIComponent(currentQuickFilter)}`);
223
-
224
- const newUrl = '/mbkbucket' + (parts.length ? '?' + parts.join('&') : '');
225
- window.history.replaceState(null, '', newUrl);
226
- } catch (e) {
227
- console.warn('History update failed', e);
228
- }
229
- }
230
-
231
- // Toast System
232
- function showToast(message, type = 'success') {
233
- const container = document.getElementById('toast-container');
234
- if (!container) return;
235
-
236
- const toast = document.createElement('div');
237
- toast.className = `toast-notification ${type}`;
238
- toast.innerHTML = `
239
- <span>${escapeHtml(message)}</span>
240
- <button type="button" class="toast-close-btn">&times;</button>
241
- `;
242
-
243
- toast.querySelector('.toast-close-btn').addEventListener('click', () => {
244
- toast.remove();
245
- });
246
-
247
- container.appendChild(toast);
248
-
249
- setTimeout(() => {
250
- toast.style.opacity = '0';
251
- toast.style.transform = 'translateY(10px)';
252
- setTimeout(() => toast.remove(), 250);
253
- }, 4000);
254
- }
255
-
256
- // Tree Renderer
257
- function renderTree() {
258
- const treeEl = document.getElementById('fm-tree');
259
- if (!treeEl) return;
260
-
261
- const allFolders = pageFolders || [];
262
- const isStarredActive = currentQuickFilter === 'starred';
263
- const isRootActive = !currentPrefix && currentQuickFilter === 'all';
264
-
265
- let treeHtml = `
266
- <div class="tree-group">
267
- <button type="button" class="tree-link ${isRootActive ? 'active' : ''}" data-tree-prefix="" data-tree-filter="all">
268
- ${ICONS.drive}
269
- <span>${escapeHtml(rootLabel)}</span>
270
- </button>
271
- </div>
272
- `;
273
-
274
- // Folder hierarchy
275
- if (allFolders.length > 0) {
276
- treeHtml += `
277
- <div class="tree-group">
278
- <div class="tree-subs">
279
- ${allFolders.map(folder => {
280
- const cleanFolder = String(folder || '').replace(/\/+$/, '');
281
- const folderName = getBaseName(cleanFolder);
282
- const folderPathForAction = currentPrefix && !cleanFolder.startsWith(currentPrefix + '/') && cleanFolder !== currentPrefix
283
- ? `${currentPrefix}/${cleanFolder}`
284
- : cleanFolder;
285
- const isFolderActive = currentPrefix === folderPathForAction;
286
- return `
287
- <button type="button" class="tree-link tree-sub ${isFolderActive ? 'active' : ''}" data-tree-prefix="${escapeAttr(folderPathForAction)}" data-tree-filter="all">
288
- ${ICONS.folderSm}
289
- <span>${escapeHtml(folderName)}</span>
290
- </button>
291
- `;
292
- }).join('')}
293
- </div>
294
- </div>
295
- `;
296
- }
297
-
298
- // Quick navigation filters
299
- treeHtml += `
300
- <div class="tree-group" style="margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border-color-light);">
301
- <button type="button" class="tree-link ${isStarredActive ? 'active' : ''}" data-tree-filter="starred">
302
- ${ICONS.starred}
303
- <span>Starred</span>
304
- ${starredFiles.size > 0 ? `<span class="tree-badge">${starredFiles.size}</span>` : ''}
305
- </button>
306
- <button type="button" class="tree-link ${currentQuickFilter === 'media' ? 'active' : ''}" data-tree-filter="media">
307
- ${ICONS.media}
308
- <span>Media</span>
309
- </button>
310
- <button type="button" class="tree-link ${currentQuickFilter === 'docs' ? 'active' : ''}" data-tree-filter="docs">
311
- ${ICONS.docs}
312
- <span>Documents</span>
313
- </button>
314
- <button type="button" class="tree-link ${currentQuickFilter === 'code' ? 'active' : ''}" data-tree-filter="code">
315
- ${ICONS.code}
316
- <span>Code</span>
317
- </button>
318
- </div>
319
- `;
320
-
321
- treeEl.innerHTML = treeHtml;
322
- }
323
-
324
- // Breadcrumbs Renderer
325
- function renderBreadcrumbs() {
326
- const bcEl = document.getElementById('fm-breadcrumb');
327
- if (!bcEl) return;
328
-
329
- if (!currentPrefix && currentQuickFilter === 'all') {
330
- bcEl.innerHTML = `<button type="button" class="bc-link current">${escapeHtml(rootLabel)}</button>`;
331
- return;
332
- }
333
-
334
- if (currentQuickFilter !== 'all') {
335
- bcEl.innerHTML = `
336
- <button type="button" class="bc-link" data-nav-prefix="">${escapeHtml(rootLabel)}</button>
337
- <span class="sep">›</span>
338
- <button type="button" class="bc-link current">${escapeHtml(currentQuickFilter.charAt(0).toUpperCase() + currentQuickFilter.slice(1))}</button>
339
- `;
340
- return;
341
- }
342
-
343
- const parts = currentPrefix.split('/').filter(Boolean);
344
- let accum = '';
345
- const breadcrumbHtml = [
346
- `<button type="button" class="bc-link" data-nav-prefix="">${escapeHtml(rootLabel)}</button>`
347
- ];
348
-
349
- parts.forEach((part, index) => {
350
- accum += (accum ? '/' : '') + part;
351
- const isLast = index === parts.length - 1;
352
- breadcrumbHtml.push('<span class="sep">›</span>');
353
- if (isLast) {
354
- breadcrumbHtml.push(`<button type="button" class="bc-link current">${escapeHtml(part)}</button>`);
355
- } else {
356
- breadcrumbHtml.push(`<button type="button" class="bc-link" data-nav-prefix="${escapeAttr(accum)}">${escapeHtml(part)}</button>`);
357
- }
358
- });
359
-
360
- bcEl.innerHTML = breadcrumbHtml.join('');
361
- }
362
-
363
- // File Filtering Logic
364
- function getFilteredFiles() {
365
- let list = (pageFiles || []).filter(f => !String(f.Key || '').endsWith('/'));
366
-
367
- // Search filter
368
- if (currentSearch) {
369
- const q = currentSearch.toLowerCase();
370
- list = list.filter(f => getBaseName(f.Key).toLowerCase().includes(q));
371
- }
372
-
373
- // Quick filter
374
- if (currentQuickFilter === 'media') {
375
- list = list.filter(f => MEDIA_EXTS.has(getFileExt(f.Key)));
376
- } else if (currentQuickFilter === 'docs') {
377
- list = list.filter(f => DOC_EXTS.has(getFileExt(f.Key)));
378
- } else if (currentQuickFilter === 'code') {
379
- list = list.filter(f => CODE_EXTS.has(getFileExt(f.Key)));
380
- } else if (currentQuickFilter === 'starred') {
381
- list = list.filter(f => starredFiles.has(f.Key));
382
- }
383
-
384
- return list;
385
- }
386
-
387
- function getFilteredFolders() {
388
- if (currentQuickFilter !== 'all') return [];
389
- if (currentSearch) {
390
- const q = currentSearch.toLowerCase();
391
- return (pageFolders || []).filter(f => getBaseName(f).toLowerCase().includes(q));
392
- }
393
- return pageFolders || [];
394
- }
395
-
396
- // Main Files & Grid Renderer
397
- function renderFiles() {
398
- const gridEl = document.getElementById('fm-grid');
399
- const emptyEl = document.getElementById('fm-empty');
400
- if (!gridEl) return;
401
-
402
- const files = getFilteredFiles();
403
- const folders = getFilteredFolders();
404
-
405
- // Update stats
406
- const totalCountEl = document.getElementById('totalFilesCount');
407
- const filterInfoEl = document.getElementById('filterInfo');
408
- const totalItems = files.length + folders.length;
409
-
410
- if (totalCountEl) {
411
- totalCountEl.textContent = `${totalItems} item${totalItems === 1 ? '' : 's'}`;
412
- }
413
- if (filterInfoEl) {
414
- filterInfoEl.textContent = activeBucket ? `Bucket: ${activeBucket}` : 'Default bucket';
415
- }
416
-
417
- if (totalItems === 0) {
418
- gridEl.innerHTML = '';
419
- if (emptyEl) {
420
- emptyEl.hidden = false;
421
- emptyEl.style.display = 'flex';
422
- }
423
- return;
424
- }
425
-
426
- if (emptyEl) {
427
- emptyEl.hidden = true;
428
- emptyEl.style.display = 'none';
429
- }
430
- gridEl.className = `fm-grid view-${currentViewMode}`;
431
-
432
- let html = '';
433
-
434
- // If List view, add header row
435
- if (currentViewMode === 'list') {
436
- html += `
437
- <div class="fm-list-header">
438
- <div></div>
439
- <div></div>
440
- <div>Name</div>
441
- <div>Size</div>
442
- <div>Modified</div>
443
- <div></div>
444
- <div></div>
445
- </div>
446
- `;
447
- }
448
-
449
- // Render Folders
450
- folders.forEach(folder => {
451
- const cleanFolder = String(folder || '').replace(/\/+$/, '');
452
- const folderName = getBaseName(cleanFolder);
453
- const folderPathForAction = currentPrefix && !cleanFolder.startsWith(currentPrefix + '/') && cleanFolder !== currentPrefix
454
- ? `${currentPrefix}/${cleanFolder}`
455
- : cleanFolder;
456
- const safePath = escapeAttr(folderPathForAction);
457
- const safeName = escapeHtml(folderName);
458
-
459
- if (currentViewMode === 'grid') {
460
- html += `
461
- <div class="fm-item" data-type="folder" data-folder-path="${safePath}" tabindex="0">
462
- <div class="fm-item-icon">
463
- ${ICONS.folder}
464
- </div>
465
- <div class="fm-item-details">
466
- <div class="fm-item-name" title="${safeName}">${safeName}</div>
467
- <div class="fm-item-meta">Folder</div>
468
- </div>
469
- <button type="button" class="fm-item-menu" data-menu-folder="${safePath}" aria-label="Folder options">
470
- ${ICONS.more}
471
- </button>
472
- </div>
473
- `;
474
- } else {
475
- html += `
476
- <div class="fm-item" data-type="folder" data-folder-path="${safePath}" tabindex="0">
477
- <div class="fm-item-checkbox"></div>
478
- <div class="fm-item-icon">
479
- ${ICONS.folderSm}
480
- </div>
481
- <div class="fm-item-name" title="${safeName}">${safeName}</div>
482
- <div class="fm-item-size fm-item-meta">—</div>
483
- <div class="fm-item-date fm-item-meta">—</div>
484
- <div class="fm-star-wrap"></div>
485
- <button type="button" class="fm-item-menu" data-menu-folder="${safePath}" aria-label="Folder options">
486
- ${ICONS.more}
487
- </button>
488
- </div>
489
- `;
490
- }
491
- });
492
-
493
- // Render Files
494
- files.forEach(file => {
495
- const rawKey = String(file.Key || '');
496
- const fileName = getBaseName(rawKey);
497
- const safeKey = escapeAttr(rawKey);
498
- const safeName = escapeHtml(fileName);
499
- const isStarred = starredFiles.has(rawKey);
500
- const isImg = isImage(fileName);
501
- const typeCfg = getFileTypeConfig(fileName);
502
- const formattedSize = file.Size ? formatFileSize(file.Size) : '—';
503
- const formattedDate = file.LastModified ? formatDate(file.LastModified) : '—';
504
-
505
- let iconInner = `<span class="ext">${typeCfg.label}</span>`;
506
- if (isImg) {
507
- iconInner = `<img src="${getViewUrl(rawKey)}" class="fm-item-thumb" alt="${safeName}" loading="lazy" onerror="this.outerHTML='<span class=\\'ext\\'>IMG</span>'">`;
508
- }
509
-
510
- if (currentViewMode === 'grid') {
511
- html += `
512
- <div class="fm-item ${isStarred ? 'starred' : ''}" data-type="file" data-key="${safeKey}" tabindex="0">
513
- <div class="fm-item-icon" style="${isImg ? '' : `background:${typeCfg.bg};color:${typeCfg.color}`}">
514
- ${iconInner}
515
- </div>
516
- <div class="fm-item-details">
517
- <div class="fm-item-name" title="${safeName}">${safeName}</div>
518
- <div class="fm-item-meta">${formattedSize} · ${formattedDate}</div>
519
- </div>
520
- <button type="button" class="fm-star ${isStarred ? 'on' : ''}" data-star-key="${safeKey}" aria-label="${isStarred ? 'Unstar' : 'Star'}">
521
- ${ICONS.starred}
522
- </button>
523
- <button type="button" class="fm-item-menu" data-menu-file="${safeKey}" aria-label="File options">
524
- ${ICONS.more}
525
- </button>
526
- </div>
527
- `;
528
- } else {
529
- html += `
530
- <div class="fm-item ${isStarred ? 'starred' : ''}" data-type="file" data-key="${safeKey}" tabindex="0">
531
- <div class="fm-item-checkbox">
532
- <input type="checkbox" class="file-select-cb" data-key="${safeKey}">
533
- </div>
534
- <div class="fm-item-icon" style="${isImg ? '' : `background:${typeCfg.bg};color:${typeCfg.color}`}">
535
- <span class="ext">${typeCfg.label}</span>
536
- </div>
537
- <div class="fm-item-name" title="${safeName}">${safeName}</div>
538
- <div class="fm-item-size fm-item-meta">${formattedSize}</div>
539
- <div class="fm-item-date fm-item-meta">${formattedDate}</div>
540
- <button type="button" class="fm-star ${isStarred ? 'on' : ''}" data-star-key="${safeKey}" aria-label="${isStarred ? 'Unstar' : 'Star'}">
541
- ${ICONS.starred}
542
- </button>
543
- <button type="button" class="fm-item-menu" data-menu-file="${safeKey}" aria-label="File options">
544
- ${ICONS.more}
545
- </button>
546
- </div>
547
- `;
548
- }
549
- });
550
-
551
- gridEl.innerHTML = html;
552
- }
553
-
554
- // Load Files API Call
555
- async function loadFiles(page = 1, prefix = '', search = '') {
556
- currentPage = page;
557
- currentPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
558
- currentSearch = search;
559
-
560
- syncStateUrl();
561
-
562
- const gridEl = document.getElementById('fm-grid');
563
- if (gridEl) {
564
- gridEl.innerHTML = `
565
- <div class="fm-skeleton-grid">
566
- ${[...Array(6)].map(() => `
567
- <div class="fm-skeleton-card">
568
- <div class="fm-skeleton-icon"></div>
569
- <div class="fm-skeleton-line w-75"></div>
570
- <div class="fm-skeleton-line w-50"></div>
571
- </div>
572
- `).join('')}
573
- </div>
574
- `;
575
- }
576
-
577
- try {
578
- const searchParam = search ? `&search=${encodeURIComponent(search)}` : '';
579
- const sortParam = `&sort=${currentSort}&order=${currentOrder}`;
580
- const recursiveParam = `&recursive=false`;
581
- const url = withBucketUrl(`/mbkbucket/api/files?page=${page}&prefix=${encodeURIComponent(currentPrefix)}${searchParam}${sortParam}${recursiveParam}`);
582
-
583
- const response = await fetch(url);
584
- const data = await response.json();
585
-
586
- if (!data.success) {
587
- throw new Error(data.error || 'Failed to load files');
588
- }
589
-
590
- currentNextToken = data.nextContinuationToken || null;
591
- currentApiPrefix = data.prefix || '';
592
- pageFiles = data.files || [];
593
-
594
- if (data.mode === 'optimized') {
595
- pageFolders = (data.folders || []).map(folder => stripApiPrefixFromPath(folder, currentApiPrefix)).filter(Boolean);
596
- } else {
597
- pageFolders = currentSearch
598
- ? []
599
- : extractFoldersAtCurrentLevel(pageFiles, currentApiPrefix || currentPrefix)
600
- .map(folder => stripApiPrefixFromPath(folder, currentApiPrefix))
601
- .filter(Boolean);
602
- }
603
-
604
- renderTree();
605
- renderBreadcrumbs();
606
- renderFiles();
607
- renderPagination(data);
608
- } catch (err) {
609
- console.error('Error loading files:', err);
610
- showToast(err.message || 'Error loading files', 'error');
611
- if (gridEl) {
612
- gridEl.innerHTML = `
613
- <div class="fm-empty">
614
- <div class="empty-title">Error Loading Files</div>
615
- <div class="empty-desc">${escapeHtml(err.message)}</div>
616
- <button type="button" class="btn btn-primary btn-sm mt-3" onclick="window.mbkbucket.loadFiles(${page}, '${escapeAttr(currentPrefix)}')">Retry</button>
617
- </div>
618
- `;
619
- }
620
- }
621
- }
622
-
623
- // Pagination Renderer
624
- function renderPagination(data) {
625
- const paginationContainer = document.getElementById('paginationContainer');
626
- const paginationList = document.getElementById('paginationList');
627
- if (!paginationContainer || !paginationList) return;
628
-
629
- if (!data.totalPages || data.totalPages <= 1) {
630
- paginationContainer.style.display = 'none';
631
- return;
632
- }
633
-
634
- paginationContainer.style.display = 'flex';
635
- let html = '';
636
-
637
- // Prev
638
- html += `
639
- <li class="page-item ${data.currentPage <= 1 ? 'disabled' : ''}">
640
- <button type="button" class="page-link" data-page="${data.currentPage - 1}">‹</button>
641
- </li>
642
- `;
643
-
644
- // Pages
645
- for (let i = 1; i <= data.totalPages; i++) {
646
- if (i === 1 || i === data.totalPages || (i >= data.currentPage - 2 && i <= data.currentPage + 2)) {
647
- html += `
648
- <li class="page-item ${i === data.currentPage ? 'active' : ''}">
649
- <button type="button" class="page-link" data-page="${i}">${i}</button>
650
- </li>
651
- `;
652
- } else if (i === data.currentPage - 3 || i === data.currentPage + 3) {
653
- html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
654
- }
655
- }
656
-
657
- // Next
658
- html += `
659
- <li class="page-item ${data.currentPage >= data.totalPages ? 'disabled' : ''}">
660
- <button type="button" class="page-link" data-page="${data.currentPage + 1}">›</button>
661
- </li>
662
- `;
663
-
664
- paginationList.innerHTML = html;
665
- }
666
-
667
- // Context Menu
668
- function openContextMenu(x, y, items) {
669
- const menu = document.getElementById('fm-context-menu');
670
- if (!menu) return;
671
-
672
- menu.innerHTML = items.map(item => {
673
- if (item === '-') return '<div class="fm-context-divider"></div>';
674
- return `<button type="button" class="fm-context-item ${item.danger ? 'danger' : ''}" data-action="${item.id}">${item.icon || ''} ${escapeHtml(item.label)}</button>`;
675
- }).join('');
676
-
677
- menu.style.display = 'block';
678
-
679
- const menuWidth = 180;
680
- const menuHeight = menu.offsetHeight || 150;
681
- const posX = Math.min(x, window.innerWidth - menuWidth - 10);
682
- const posY = Math.min(y, window.innerHeight - menuHeight - 10);
683
-
684
- menu.style.left = `${posX}px`;
685
- menu.style.top = `${posY}px`;
686
-
687
- menu.querySelectorAll('.fm-context-item').forEach(btn => {
688
- btn.addEventListener('click', (e) => {
689
- e.stopPropagation();
690
- const actionId = btn.dataset.action;
691
- const targetItem = items.find(it => it.id === actionId);
692
- menu.style.display = 'none';
693
- if (targetItem && typeof targetItem.action === 'function') {
694
- targetItem.action();
695
- }
696
- });
697
- });
698
- }
699
-
700
- function closeContextMenu() {
701
- const menu = document.getElementById('fm-context-menu');
702
- if (menu) menu.style.display = 'none';
703
- }
704
-
705
- // Actions: Star, Download, Preview, Delete, New Folder
706
- function toggleStar(key) {
707
- if (starredFiles.has(key)) {
708
- starredFiles.delete(key);
709
- showToast('Removed from Starred');
710
- } else {
711
- starredFiles.add(key);
712
- showToast('Added to Starred', 'success');
713
- }
714
- localStorage.setItem('mbk_starred_keys', JSON.stringify([...starredFiles]));
715
- renderTree();
716
- renderFiles();
717
- }
718
-
719
- async function downloadFile(key) {
720
- const fileName = getBaseName(key);
721
- const downloadUrl = getDownloadUrl(key);
722
- showToast(`Downloading ${fileName}...`);
723
- try {
724
- const response = await fetch(downloadUrl);
725
- if (!response.ok) throw new Error('Download failed');
726
- const blob = await response.blob();
727
- const url = URL.createObjectURL(blob);
728
- const a = document.createElement('a');
729
- a.href = url;
730
- a.download = fileName;
731
- document.body.appendChild(a);
732
- a.click();
733
- document.body.removeChild(a);
734
- setTimeout(() => URL.revokeObjectURL(url), 1000);
735
- } catch (err) {
736
- showToast(err.message, 'error');
737
- }
738
- }
739
-
740
- function copyShareLink(key) {
741
- const viewUrl = window.location.origin + getViewUrl(key);
742
- navigator.clipboard.writeText(viewUrl).then(() => {
743
- showToast('Link copied to clipboard', 'success');
744
- }).catch(() => {
745
- showToast('Failed to copy link', 'error');
746
- });
747
- }
748
-
749
- // Preview Modal
750
- function openPreview(key) {
751
- const modal = document.getElementById('previewModal');
752
- const titleEl = document.getElementById('previewFileName');
753
- const contentEl = document.getElementById('previewContent');
754
- const openBtn = document.getElementById('previewOpenBtn');
755
- const downloadBtn = document.getElementById('previewDownloadBtn');
756
-
757
- if (!modal || !contentEl) return;
758
-
759
- const fileName = getBaseName(key);
760
- const viewUrl = getViewUrl(key);
761
- const downloadUrl = getDownloadUrl(key);
762
-
763
- if (titleEl) titleEl.textContent = fileName;
764
- if (openBtn) openBtn.href = viewUrl;
765
- if (downloadBtn) downloadBtn.href = downloadUrl;
766
-
767
- contentEl.innerHTML = '<div class="empty-preview"><div class="spinner-border text-light"></div><div class="mt-2 text-light">Loading preview...</div></div>';
768
- modal.classList.add('show');
769
-
770
- if (isImage(fileName)) {
771
- contentEl.innerHTML = `<img src="${viewUrl}" alt="${escapeAttr(fileName)}">`;
772
- } else if (isVideo(fileName)) {
773
- contentEl.innerHTML = `<video controls autoplay class="w-100" style="max-height: 70vh;"><source src="${viewUrl}"></video>`;
774
- } else if (isAudio(fileName)) {
775
- contentEl.innerHTML = `<div class="p-4 text-center"><audio controls autoplay class="w-100"><source src="${viewUrl}"></audio></div>`;
776
- } else if (isPdf(fileName)) {
777
- contentEl.innerHTML = `<iframe src="${viewUrl}" class="preview-iframe"></iframe>`;
778
- } else if (isTextOrCode(fileName)) {
779
- fetch(viewUrl).then(res => res.text()).then(text => {
780
- contentEl.innerHTML = `<pre class="preview-code-block"><code>${escapeHtml(text)}</code></pre>`;
781
- }).catch(err => {
782
- contentEl.innerHTML = `<div class="p-4 text-danger text-center">Failed to load text preview: ${escapeHtml(err.message)}</div>`;
783
- });
784
- } else {
785
- contentEl.innerHTML = `
786
- <div class="p-5 text-center text-light">
787
- <h5>No inline preview available for this file type.</h5>
788
- <p class="text-muted small">You can open it in a new tab or download it directly.</p>
789
- </div>
790
- `;
791
- }
792
- }
793
-
794
- window.closePreviewModal = function () {
795
- const modal = document.getElementById('previewModal');
796
- if (modal) modal.classList.remove('show');
797
- };
798
-
799
- // Delete Modal
800
- function promptDelete(key, isFolder = false) {
801
- const modal = document.getElementById('deleteModal');
802
- const deleteFileName = document.getElementById('deleteFileName');
803
- const deleteKey = document.getElementById('deleteKey');
804
- const deleteIsFolder = document.getElementById('deleteIsFolder');
805
- const deleteConfirmLabel = document.getElementById('deleteConfirmLabel');
806
-
807
- if (!modal) return;
808
-
809
- if (deleteFileName) deleteFileName.textContent = key;
810
- if (deleteKey) deleteKey.value = key;
811
- if (deleteIsFolder) deleteIsFolder.value = isFolder ? 'true' : '';
812
- if (deleteConfirmLabel) deleteConfirmLabel.textContent = isFolder ? 'Delete Folder' : 'Delete File';
813
-
814
- modal.classList.add('show');
815
- }
816
-
817
- window.closeModal = function () {
818
- const deleteModal = document.getElementById('deleteModal');
819
- if (deleteModal) deleteModal.classList.remove('show');
820
- };
821
-
822
- // New Folder Modal
823
- function openNewFolderModal() {
824
- const modal = document.getElementById('newFolderModal');
825
- const input = document.getElementById('newFolderNameInput');
826
- if (!modal) return;
827
- if (input) input.value = '';
828
- modal.classList.add('show');
829
- if (input) setTimeout(() => input.focus(), 50);
830
- }
831
-
832
- window.closeNewFolderModal = function () {
833
- const modal = document.getElementById('newFolderModal');
834
- if (modal) modal.classList.remove('show');
835
- };
836
-
837
- window.submitCreateFolder = async function () {
838
- const input = document.getElementById('newFolderNameInput');
839
- const name = input ? input.value.trim() : '';
840
- if (!name) {
841
- showToast('Please enter a folder name', 'warning');
842
- return;
843
- }
844
-
845
- try {
846
- const response = await fetch(withBucketUrl('/mbkbucket/create-folder'), {
847
- method: 'POST',
848
- headers: { 'Content-Type': 'application/json' },
849
- body: JSON.stringify({ folderName: name, prefix: currentPrefix })
850
- });
851
- const data = await response.json();
852
- if (!data.success) throw new Error(data.error || 'Failed to create folder');
853
-
854
- closeNewFolderModal();
855
- showToast(`Folder "${name}" created`, 'success');
856
- loadFiles(1, currentPrefix);
857
- } catch (err) {
858
- showToast(err.message, 'error');
859
- }
860
- };
861
-
862
- // Upload Modal & Flow
863
- function openUploadModal() {
864
- const modal = document.getElementById('uploadModal');
865
- const prefixInput = document.getElementById('uploadPrefixInput');
866
- if (prefixInput) prefixInput.value = currentPrefix;
867
- if (modal) modal.classList.add('show');
868
- }
869
-
870
- window.closeUploadModal = function () {
871
- const modal = document.getElementById('uploadModal');
872
- if (modal) modal.classList.remove('show');
873
- };
874
-
875
- function updateUploadQueueUI() {
876
- const queueEl = document.getElementById('uploadQueue');
877
- const clearBtn = document.getElementById('clearQueueBtn');
878
- if (!queueEl) return;
879
-
880
- if (uploadQueue.length === 0) {
881
- queueEl.innerHTML = '';
882
- if (clearBtn) clearBtn.style.display = 'none';
883
- return;
884
- }
885
-
886
- if (clearBtn) clearBtn.style.display = 'inline-flex';
887
- queueEl.innerHTML = uploadQueue.map((file, idx) => `
888
- <div class="upload-queue-item">
889
- <div class="d-flex align-items-center gap-2">
890
- <span class="font-medium">${escapeHtml(file.name)}</span>
891
- <span class="upload-queue-meta">${formatFileSize(file.size)}</span>
892
- </div>
893
- <button type="button" class="btn-close" data-remove-queue="${idx}" style="font-size: 14px;">&times;</button>
894
- </div>
895
- `).join('');
896
- }
897
-
898
- async function processUploads() {
899
- if (isUploading || uploadQueue.length === 0) return;
900
- isUploading = true;
901
-
902
- const progressWrap = document.getElementById('uploadProgress');
903
- const progressBar = document.getElementById('progressBar');
904
- const percentEl = document.getElementById('uploadPercent');
905
- const statusLabel = document.getElementById('uploadStatusLabel');
906
- const startBtn = document.getElementById('startUploadBtn');
907
-
908
- if (progressWrap) progressWrap.style.display = 'block';
909
- if (startBtn) startBtn.disabled = true;
910
-
911
- const totalFiles = uploadQueue.length;
912
- let completed = 0;
913
-
914
- for (let i = 0; i < uploadQueue.length; i++) {
915
- const file = uploadQueue[i];
916
- if (statusLabel) statusLabel.textContent = `Uploading ${file.name} (${i + 1}/${totalFiles})...`;
917
-
918
- const formData = new FormData();
919
- formData.append('file', file);
920
- formData.append('prefix', currentPrefix);
921
-
922
- try {
923
- const response = await fetch(withBucketUrl('/mbkbucket/upload'), {
924
- method: 'POST',
925
- body: formData
926
- });
927
- const result = await response.json();
928
- if (!result.success) throw new Error(result.error || 'Upload failed');
929
- completed++;
930
- } catch (err) {
931
- showToast(`Failed to upload ${file.name}: ${err.message}`, 'error');
932
- }
933
-
934
- const percent = Math.round(((i + 1) / totalFiles) * 100);
935
- if (progressBar) progressBar.style.width = `${percent}%`;
936
- if (percentEl) percentEl.textContent = `${percent}%`;
937
- }
938
-
939
- showToast(`Successfully uploaded ${completed} file(s)`, 'success');
940
- uploadQueue = [];
941
- isUploading = false;
942
- if (startBtn) startBtn.disabled = false;
943
- if (progressWrap) progressWrap.style.display = 'none';
944
- closeUploadModal();
945
- updateUploadQueueUI();
946
- loadFiles(1, currentPrefix);
947
- }
948
-
949
- // Event Listeners Setup
950
- function initEventListeners() {
951
- // Tree clicks
952
- document.getElementById('fm-tree')?.addEventListener('click', (e) => {
953
- const link = e.target.closest('.tree-link');
954
- if (!link) return;
955
- e.preventDefault();
956
- const filter = link.dataset.treeFilter;
957
- const prefix = link.dataset.treePrefix;
958
-
959
- if (filter && filter !== 'all') {
960
- currentQuickFilter = filter;
961
- document.querySelectorAll('.quick-filter-btn').forEach(b => b.classList.remove('active'));
962
- renderTree();
963
- renderBreadcrumbs();
964
- renderFiles();
965
- } else {
966
- currentQuickFilter = 'all';
967
- document.querySelectorAll('.quick-filter-btn').forEach(b => {
968
- b.classList.toggle('active', b.dataset.filter === 'all');
969
- });
970
- loadFiles(1, prefix !== undefined ? prefix : '');
971
- }
972
- });
973
-
974
- // Breadcrumb clicks
975
- document.getElementById('fm-breadcrumb')?.addEventListener('click', (e) => {
976
- const link = e.target.closest('[data-nav-prefix]');
977
- if (!link) return;
978
- e.preventDefault();
979
- currentQuickFilter = 'all';
980
- loadFiles(1, link.dataset.navPrefix || '');
981
- });
982
-
983
- // Quick filters
984
- document.querySelectorAll('.quick-filter-btn').forEach(btn => {
985
- btn.addEventListener('click', () => {
986
- document.querySelectorAll('.quick-filter-btn').forEach(b => b.classList.remove('active'));
987
- btn.classList.add('active');
988
- currentQuickFilter = btn.dataset.filter || 'all';
989
- renderTree();
990
- renderBreadcrumbs();
991
- renderFiles();
992
- syncStateUrl();
993
- });
994
- });
995
-
996
- // View mode toggle
997
- const gridBtn = document.getElementById('view-grid');
998
- const listBtn = document.getElementById('view-list');
999
-
1000
- gridBtn?.addEventListener('click', () => {
1001
- currentViewMode = 'grid';
1002
- localStorage.setItem('mbk_view_mode', 'grid');
1003
- gridBtn.classList.add('active');
1004
- listBtn?.classList.remove('active');
1005
- renderFiles();
1006
- syncStateUrl();
1007
- });
1008
-
1009
- listBtn?.addEventListener('click', () => {
1010
- currentViewMode = 'list';
1011
- localStorage.setItem('mbk_view_mode', 'list');
1012
- listBtn.classList.add('active');
1013
- gridBtn?.classList.remove('active');
1014
- renderFiles();
1015
- syncStateUrl();
1016
- });
1017
-
1018
- // Search input
1019
- const searchInput = document.getElementById('searchInput');
1020
- const clearSearchBtn = document.getElementById('searchClearBtn');
1021
-
1022
- let searchTimer = null;
1023
- searchInput?.addEventListener('input', (e) => {
1024
- const val = e.target.value;
1025
- if (clearSearchBtn) clearSearchBtn.style.display = val ? 'block' : 'none';
1026
-
1027
- clearTimeout(searchTimer);
1028
- searchTimer = setTimeout(() => {
1029
- currentSearch = val.trim();
1030
- renderFiles();
1031
- }, 250);
1032
- });
1033
-
1034
- clearSearchBtn?.addEventListener('click', () => {
1035
- if (searchInput) searchInput.value = '';
1036
- clearSearchBtn.style.display = 'none';
1037
- currentSearch = '';
1038
- renderFiles();
1039
- });
1040
-
1041
- // Grid Item Interactions (Click, Double Click, Star, Context Menu)
1042
- document.getElementById('fm-grid')?.addEventListener('click', (e) => {
1043
- // Star click
1044
- const starBtn = e.target.closest('[data-star-key]');
1045
- if (starBtn) {
1046
- e.stopPropagation();
1047
- toggleStar(starBtn.dataset.starKey);
1048
- return;
1049
- }
1050
-
1051
- // Menu click
1052
- const menuBtn = e.target.closest('.fm-item-menu');
1053
- if (menuBtn) {
1054
- e.stopPropagation();
1055
- const rect = menuBtn.getBoundingClientRect();
1056
- const fileKey = menuBtn.dataset.menuFile;
1057
- const folderPath = menuBtn.dataset.menuFolder;
1058
-
1059
- if (fileKey) {
1060
- const isStarred = starredFiles.has(fileKey);
1061
- openContextMenu(rect.left, rect.bottom + 4, [
1062
- { id: 'view', label: 'Preview', icon: '<i class="fas fa-eye"></i>', action: () => openPreview(fileKey) },
1063
- { id: 'download', label: 'Download', icon: '<i class="fas fa-download"></i>', action: () => downloadFile(fileKey) },
1064
- { id: 'share', label: 'Copy link', icon: '<i class="fas fa-link"></i>', action: () => copyShareLink(fileKey) },
1065
- { id: 'star', label: isStarred ? 'Unstar' : 'Star', icon: '<i class="fas fa-star"></i>', action: () => toggleStar(fileKey) },
1066
- '-',
1067
- { id: 'delete', label: 'Delete', icon: '<i class="fas fa-trash"></i>', danger: true, action: () => promptDelete(fileKey, false) }
1068
- ]);
1069
- } else if (folderPath) {
1070
- openContextMenu(rect.left, rect.bottom + 4, [
1071
- { id: 'open', label: 'Open Folder', icon: '<i class="fas fa-folder-open"></i>', action: () => loadFiles(1, folderPath) },
1072
- '-',
1073
- { id: 'delete', label: 'Delete Folder', icon: '<i class="fas fa-trash"></i>', danger: true, action: () => promptDelete(folderPath + '/', true) }
1074
- ]);
1075
- }
1076
- return;
1077
- }
1078
-
1079
- // Folder click to navigate
1080
- const folderItem = e.target.closest('[data-type="folder"]');
1081
- if (folderItem) {
1082
- e.stopPropagation();
1083
- loadFiles(1, folderItem.dataset.folderPath);
1084
- return;
1085
- }
1086
-
1087
- // File click to preview
1088
- const fileItem = e.target.closest('[data-type="file"]');
1089
- if (fileItem && !e.target.closest('.file-select-cb')) {
1090
- e.stopPropagation();
1091
- openPreview(fileItem.dataset.key);
1092
- }
1093
- });
1094
-
1095
- // Bucket Selector
1096
- document.getElementById('bucketSelector')?.addEventListener('change', (e) => {
1097
- const newBucket = e.target.value;
1098
- if (!newBucket) return;
1099
- activeBucket = newBucket;
1100
- loadFiles(1, '');
1101
- });
1102
-
1103
- // Top action buttons & Modals
1104
- document.querySelectorAll('[data-action="upload-files"]').forEach(btn => {
1105
- btn.addEventListener('click', openUploadModal);
1106
- });
1107
-
1108
- document.querySelectorAll('[data-action="create-folder"]').forEach(btn => {
1109
- btn.addEventListener('click', openNewFolderModal);
1110
- });
1111
-
1112
- // Drag and Drop Upload Zone
1113
- const dropZone = document.getElementById('dropZone');
1114
- const fileInput = document.getElementById('fileInput');
1115
-
1116
- dropZone?.addEventListener('click', () => fileInput?.click());
1117
-
1118
- dropZone?.addEventListener('dragover', (e) => {
1119
- e.preventDefault();
1120
- dropZone.classList.add('dragover');
1121
- });
1122
-
1123
- dropZone?.addEventListener('dragleave', () => {
1124
- dropZone.classList.remove('dragover');
1125
- });
1126
-
1127
- dropZone?.addEventListener('drop', (e) => {
1128
- e.preventDefault();
1129
- dropZone.classList.remove('dragover');
1130
- if (e.dataTransfer.files?.length) {
1131
- uploadQueue.push(...Array.from(e.dataTransfer.files));
1132
- updateUploadQueueUI();
1133
- }
1134
- });
1135
-
1136
- fileInput?.addEventListener('change', (e) => {
1137
- if (e.target.files?.length) {
1138
- uploadQueue.push(...Array.from(e.target.files));
1139
- updateUploadQueueUI();
1140
- }
1141
- });
1142
-
1143
- document.getElementById('uploadQueue')?.addEventListener('click', (e) => {
1144
- const removeBtn = e.target.closest('[data-remove-queue]');
1145
- if (removeBtn) {
1146
- const idx = parseInt(removeBtn.dataset.removeQueue, 10);
1147
- uploadQueue.splice(idx, 1);
1148
- updateUploadQueueUI();
1149
- }
1150
- });
1151
-
1152
- document.getElementById('clearQueueBtn')?.addEventListener('click', () => {
1153
- uploadQueue = [];
1154
- updateUploadQueueUI();
1155
- });
1156
-
1157
- document.getElementById('startUploadBtn')?.addEventListener('click', processUploads);
1158
-
1159
- // Fullscreen Preview Toggle
1160
- document.getElementById('previewFullscreenBtn')?.addEventListener('click', () => {
1161
- document.querySelector('.preview-modal-dialog')?.classList.toggle('is-fullscreen');
1162
- });
1163
-
1164
- // Pagination clicks
1165
- document.getElementById('paginationList')?.addEventListener('click', (e) => {
1166
- const link = e.target.closest('[data-page]');
1167
- if (!link) return;
1168
- const page = parseInt(link.dataset.page, 10);
1169
- if (!isNaN(page) && page > 0) {
1170
- loadFiles(page, currentPrefix, currentSearch);
1171
- }
1172
- });
1173
-
1174
- // Global click to close context menu
1175
- document.addEventListener('click', closeContextMenu);
1176
-
1177
- // Handle delete form submission
1178
- const deleteForm = document.getElementById('deleteForm');
1179
- deleteForm?.addEventListener('submit', async (e) => {
1180
- e.preventDefault();
1181
- const formData = new FormData(deleteForm);
1182
- const key = formData.get('key');
1183
- const isFolder = formData.get('folder') === 'true';
1184
-
1185
- try {
1186
- const response = await fetch(withBucketUrl('/mbkbucket/delete'), {
1187
- method: 'POST',
1188
- headers: { 'Content-Type': 'application/json' },
1189
- body: JSON.stringify({ key, isFolder, prefix: currentPrefix })
1190
- });
1191
- const data = await response.json();
1192
- if (!data.success) throw new Error(data.error || 'Delete failed');
1193
-
1194
- closeModal();
1195
- showToast(`Successfully deleted ${key}`, 'success');
1196
- loadFiles(currentPage, currentPrefix);
1197
- } catch (err) {
1198
- showToast(err.message, 'error');
1199
- }
1200
- });
1201
- }
1202
-
1203
- // Initialization
1204
- document.addEventListener('DOMContentLoaded', () => {
1205
- // Sync view mode buttons
1206
- if (currentViewMode === 'list') {
1207
- document.getElementById('view-list')?.classList.add('active');
1208
- document.getElementById('view-grid')?.classList.remove('active');
1209
- }
1210
-
1211
- initEventListeners();
1212
-
1213
- // Initial load with URL params
1214
- const initialFolder = urlParams.get('folder') || '';
1215
- const initialPage = parseInt(urlParams.get('page') || '1', 10);
1216
- const initialSearch = urlParams.get('search') || '';
1217
-
1218
- loadFiles(initialPage, initialFolder, initialSearch);
1219
- });
1220
-
1221
- // Expose global methods if needed
1222
- window.mbkbucket = {
1223
- loadFiles,
1224
- openPreview,
1225
- downloadFile,
1226
- showToast
1227
- };
1228
-
1229
- })();
1
+ /**
2
+ * MBKBucket
3
+ * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
+ * Licensed under the MIT License.
5
+ * Source: https://github.com/MIbnEKhalid/mbkbucket
6
+ */
7
+
8
+ /**
9
+ * MBKBucket File Manager - UI & Client Logic
10
+ */
11
+
12
+ (function () {
13
+ 'use strict';
14
+
15
+ // Global State
16
+ let currentPage = 1;
17
+ let currentPrefix = '';
18
+ let currentSearch = '';
19
+ let currentSort = 'name';
20
+ let currentOrder = 'asc';
21
+ let currentViewMode = localStorage.getItem('mbk_view_mode') || 'grid'; // 'grid' or 'list'
22
+ let currentQuickFilter = 'all'; // 'all', 'media', 'docs', 'code', 'starred'
23
+ let pageFiles = [];
24
+ let pageFolders = [];
25
+ let currentApiPrefix = '';
26
+ let currentNextToken = null;
27
+ let selectedFiles = new Set();
28
+ let starredFiles = new Set(JSON.parse(localStorage.getItem('mbk_starred_keys') || '[]'));
29
+
30
+ // Upload State
31
+ let uploadQueue = [];
32
+ let isUploading = false;
33
+
34
+ // DOM Elements & Config
35
+ const bucketAppData = document.getElementById('bucketAppData');
36
+ const bucketAppName = bucketAppData?.dataset.appName || '';
37
+ const urlParams = new URLSearchParams(window.location.search);
38
+ const selectedBucket = urlParams.get('bucket') || '';
39
+ let activeBucket = selectedBucket;
40
+ const app = String(bucketAppName || '').toLowerCase();
41
+ const isRootApp = app === 'portal' || app === 'mbkbucket';
42
+ const rootLabel = isRootApp ? 'My Drive' : (bucketAppName || 'Bucket Drive');
43
+
44
+ // File categories & extensions
45
+ const MEDIA_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'mp4', 'webm', 'ogg', 'avi', 'mov', 'mp3', 'wav', 'flac', 'aac', 'm4a']);
46
+ const DOC_EXTS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'rtf']);
47
+ const CODE_EXTS = new Set(['js', 'ts', 'jsx', 'tsx', 'html', 'htm', 'css', 'scss', 'php', 'py', 'java', 'cpp', 'c', 'h', 'cs', 'rb', 'go', 'rs', 'sql', 'sh', 'bat', 'ps1', 'yaml', 'yml', 'toml', 'ini', 'conf', 'json', 'xml', 'log']);
48
+
49
+ const FILE_TYPES = {
50
+ pdf: { color: 'var(--red)', bg: 'var(--red-lt)', label: 'PDF' },
51
+ doc: { color: 'var(--blue)', bg: 'var(--blue-lt)', label: 'DOC' },
52
+ docx: { color: 'var(--blue)', bg: 'var(--blue-lt)', label: 'DOC' },
53
+ xls: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'XLS' },
54
+ xlsx: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'XLS' },
55
+ csv: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'CSV' },
56
+ ppt: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'PPT' },
57
+ pptx: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'PPT' },
58
+ txt: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'TXT' },
59
+ md: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'MD' },
60
+ jpg: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
61
+ jpeg: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
62
+ png: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
63
+ gif: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'GIF' },
64
+ webp: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
65
+ svg: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'SVG' },
66
+ mp4: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
67
+ webm: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
68
+ mov: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
69
+ mp3: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'AUD' },
70
+ wav: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'AUD' },
71
+ zip: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'ZIP' },
72
+ tar: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'TAR' },
73
+ gz: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'GZ' },
74
+ code: { color: 'var(--cyan)', bg: 'var(--cyan-lt)', label: 'CODE' }
75
+ };
76
+
77
+ // SVGs
78
+ const ICONS = {
79
+ folder: '<svg class="folder-svg" viewBox="0 0 32 32" fill="var(--primary-lt)" stroke="var(--primary)" stroke-width="1.3"><path d="M4 8a2 2 0 012-2h6l3 3h11a2 2 0 012 2v13a2 2 0 01-2 2H6a2 2 0 01-2-2V8z"/></svg>',
80
+ folderSm: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4a1 1 0 011-1h3l2 2h5a1 1 0 011 1v6a1 1 0 01-1 1H3a1 1 0 01-1-1V4z"/></svg>',
81
+ drive: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6l4-4h4l4 4v8H2V6z"/></svg>',
82
+ starred: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1l2 5 5 .5-4 3.5 1 5-4-2.5-4 2.5 1-5-4-3.5 5-.5z"/></svg>',
83
+ media: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="12" height="12" rx="2"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M14 10l-4-4-6 6"/></svg>',
84
+ docs: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2h7l4 4v8a1 1 0 01-1 1H3a1 1 0 01-1-1V3a1 1 0 011-1z"/><path d="M10 2v4h4"/></svg>',
85
+ code: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 5L2 8l3 3M11 5l3 3-3 3M9 3L7 13"/></svg>',
86
+ more: '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="3" r="1.2"/><circle cx="8" cy="8" r="1.2"/><circle cx="8" cy="13" r="1.2"/></svg>'
87
+ };
88
+
89
+ // Helper Functions
90
+ function escapeHtml(str) {
91
+ return String(str || '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
92
+ }
93
+
94
+ function escapeAttr(str) {
95
+ return escapeHtml(str);
96
+ }
97
+
98
+ function getBaseName(path) {
99
+ const val = String(path || '').replace(/\/+$/, '');
100
+ const lastSlash = val.lastIndexOf('/');
101
+ return lastSlash === -1 ? val : val.substring(lastSlash + 1);
102
+ }
103
+
104
+ function getFileExt(fileName) {
105
+ const name = getBaseName(fileName);
106
+ const dot = name.lastIndexOf('.');
107
+ return dot > -1 ? name.slice(dot + 1).toLowerCase() : '';
108
+ }
109
+
110
+ function formatFileSize(bytes) {
111
+ if (!bytes || bytes === 0) return '0 B';
112
+ const k = 1024;
113
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
114
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
115
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
116
+ }
117
+
118
+ function formatDate(dateStr) {
119
+ if (!dateStr) return '—';
120
+ const d = new Date(dateStr);
121
+ if (isNaN(d.getTime())) return String(dateStr);
122
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
123
+ }
124
+
125
+ function withBucketUrl(url) {
126
+ if (!activeBucket) return url;
127
+ const sep = url.includes('?') ? '&' : '?';
128
+ return `${url}${sep}bucket=${encodeURIComponent(activeBucket)}`;
129
+ }
130
+
131
+ function getViewUrl(key) {
132
+ return withBucketUrl(`/mbkbucket/view/${encodeURIComponent(key)}`);
133
+ }
134
+
135
+ function getDownloadUrl(key) {
136
+ return withBucketUrl(`/mbkbucket/download/${encodeURIComponent(key)}`);
137
+ }
138
+
139
+ function isImage(fileName) {
140
+ const ext = getFileExt(fileName);
141
+ return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext);
142
+ }
143
+
144
+ function isVideo(fileName) {
145
+ const ext = getFileExt(fileName);
146
+ return ['mp4', 'webm', 'ogg', 'mov', 'mkv'].includes(ext);
147
+ }
148
+
149
+ function isAudio(fileName) {
150
+ const ext = getFileExt(fileName);
151
+ return ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext);
152
+ }
153
+
154
+ function isTextOrCode(fileName) {
155
+ const ext = getFileExt(fileName);
156
+ return CODE_EXTS.has(ext) || ['txt', 'md', 'csv', 'log', 'json', 'xml', 'env'].includes(ext);
157
+ }
158
+
159
+ function isPdf(fileName) {
160
+ return getFileExt(fileName) === 'pdf';
161
+ }
162
+
163
+ function getFileTypeConfig(fileName) {
164
+ const ext = getFileExt(fileName);
165
+ if (FILE_TYPES[ext]) return FILE_TYPES[ext];
166
+ if (CODE_EXTS.has(ext)) return FILE_TYPES.code;
167
+ return { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: ext.toUpperCase().slice(0, 4) || 'FILE' };
168
+ }
169
+
170
+ function stripApiPrefixFromPath(path, apiPrefix = '') {
171
+ const normalizedPath = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
172
+ const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
173
+ if (!normalizedApiPrefix) return normalizedPath;
174
+ if (normalizedPath === normalizedApiPrefix || normalizedPath === `${normalizedApiPrefix}/`) return '';
175
+ if (normalizedPath.startsWith(`${normalizedApiPrefix}/`)) {
176
+ return normalizedPath.slice(normalizedApiPrefix.length + 1);
177
+ }
178
+ return normalizedPath;
179
+ }
180
+
181
+ function toApiFolderPath(prefix = '', apiPrefix = '') {
182
+ const uiPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
183
+ const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
184
+ if (!uiPrefix) return '';
185
+ if (!normalizedApiPrefix) return uiPrefix;
186
+ if (uiPrefix === normalizedApiPrefix || uiPrefix.startsWith(`${normalizedApiPrefix}/`)) return uiPrefix;
187
+ return `${normalizedApiPrefix}/${uiPrefix}`;
188
+ }
189
+
190
+ function extractFoldersAtCurrentLevel(files, prefix = '') {
191
+ const folderSet = new Set();
192
+ const cleanPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
193
+ const prefixOffset = cleanPrefix ? cleanPrefix.length + 1 : 0;
194
+
195
+ (files || []).forEach(file => {
196
+ const key = String(file.Key || '').replace(/^\/+/, '');
197
+ if (cleanPrefix && !key.startsWith(cleanPrefix + '/')) return;
198
+ const relPath = cleanPrefix ? key.substring(prefixOffset) : key;
199
+ const slashIdx = relPath.indexOf('/');
200
+ if (slashIdx !== -1) {
201
+ const folder = relPath.substring(0, slashIdx);
202
+ folderSet.add(cleanPrefix ? `${cleanPrefix}/${folder}` : folder);
203
+ }
204
+ });
205
+
206
+ return Array.from(folderSet).sort();
207
+ }
208
+
209
+ function syncStateUrl() {
210
+ try {
211
+ const parts = [];
212
+ if (activeBucket) parts.push(`bucket=${encodeURIComponent(activeBucket)}`);
213
+ if (currentPrefix) {
214
+ const folderPath = toApiFolderPath(currentPrefix, currentApiPrefix);
215
+ parts.push(`folder=${encodeURIComponent(folderPath).replace(/%2F/g, '/')}`);
216
+ }
217
+ if (currentPage > 1) parts.push(`page=${currentPage}`);
218
+ if (currentSearch) parts.push(`search=${encodeURIComponent(currentSearch)}`);
219
+ if (currentSort !== 'name') parts.push(`sort=${encodeURIComponent(currentSort)}`);
220
+ if (currentOrder !== 'asc') parts.push(`order=${encodeURIComponent(currentOrder)}`);
221
+ if (currentViewMode !== 'grid') parts.push(`view=${encodeURIComponent(currentViewMode)}`);
222
+ if (currentQuickFilter !== 'all') parts.push(`filter=${encodeURIComponent(currentQuickFilter)}`);
223
+
224
+ const newUrl = '/mbkbucket' + (parts.length ? '?' + parts.join('&') : '');
225
+ window.history.replaceState(null, '', newUrl);
226
+ } catch (e) {
227
+ console.warn('History update failed', e);
228
+ }
229
+ }
230
+
231
+ // Toast System
232
+ function showToast(message, type = 'success') {
233
+ const container = document.getElementById('toast-container');
234
+ if (!container) return;
235
+
236
+ const toast = document.createElement('div');
237
+ toast.className = `toast-notification ${type}`;
238
+ toast.innerHTML = `
239
+ <span>${escapeHtml(message)}</span>
240
+ <button type="button" class="toast-close-btn">&times;</button>
241
+ `;
242
+
243
+ toast.querySelector('.toast-close-btn').addEventListener('click', () => {
244
+ toast.remove();
245
+ });
246
+
247
+ container.appendChild(toast);
248
+
249
+ setTimeout(() => {
250
+ toast.style.opacity = '0';
251
+ toast.style.transform = 'translateY(10px)';
252
+ setTimeout(() => toast.remove(), 250);
253
+ }, 4000);
254
+ }
255
+
256
+ // Tree Renderer
257
+ function renderTree() {
258
+ const treeEl = document.getElementById('fm-tree');
259
+ if (!treeEl) return;
260
+
261
+ const allFolders = pageFolders || [];
262
+ const isStarredActive = currentQuickFilter === 'starred';
263
+ const isRootActive = !currentPrefix && currentQuickFilter === 'all';
264
+
265
+ let treeHtml = `
266
+ <div class="tree-group">
267
+ <button type="button" class="tree-link ${isRootActive ? 'active' : ''}" data-tree-prefix="" data-tree-filter="all">
268
+ ${ICONS.drive}
269
+ <span>${escapeHtml(rootLabel)}</span>
270
+ </button>
271
+ </div>
272
+ `;
273
+
274
+ // Folder hierarchy
275
+ if (allFolders.length > 0) {
276
+ treeHtml += `
277
+ <div class="tree-group">
278
+ <div class="tree-subs">
279
+ ${allFolders.map(folder => {
280
+ const cleanFolder = String(folder || '').replace(/\/+$/, '');
281
+ const folderName = getBaseName(cleanFolder);
282
+ const folderPathForAction = currentPrefix && !cleanFolder.startsWith(currentPrefix + '/') && cleanFolder !== currentPrefix
283
+ ? `${currentPrefix}/${cleanFolder}`
284
+ : cleanFolder;
285
+ const isFolderActive = currentPrefix === folderPathForAction;
286
+ return `
287
+ <button type="button" class="tree-link tree-sub ${isFolderActive ? 'active' : ''}" data-tree-prefix="${escapeAttr(folderPathForAction)}" data-tree-filter="all">
288
+ ${ICONS.folderSm}
289
+ <span>${escapeHtml(folderName)}</span>
290
+ </button>
291
+ `;
292
+ }).join('')}
293
+ </div>
294
+ </div>
295
+ `;
296
+ }
297
+
298
+ // Quick navigation filters
299
+ treeHtml += `
300
+ <div class="tree-group" style="margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border-color-light);">
301
+ <button type="button" class="tree-link ${isStarredActive ? 'active' : ''}" data-tree-filter="starred">
302
+ ${ICONS.starred}
303
+ <span>Starred</span>
304
+ ${starredFiles.size > 0 ? `<span class="tree-badge">${starredFiles.size}</span>` : ''}
305
+ </button>
306
+ <button type="button" class="tree-link ${currentQuickFilter === 'media' ? 'active' : ''}" data-tree-filter="media">
307
+ ${ICONS.media}
308
+ <span>Media</span>
309
+ </button>
310
+ <button type="button" class="tree-link ${currentQuickFilter === 'docs' ? 'active' : ''}" data-tree-filter="docs">
311
+ ${ICONS.docs}
312
+ <span>Documents</span>
313
+ </button>
314
+ <button type="button" class="tree-link ${currentQuickFilter === 'code' ? 'active' : ''}" data-tree-filter="code">
315
+ ${ICONS.code}
316
+ <span>Code</span>
317
+ </button>
318
+ </div>
319
+ `;
320
+
321
+ treeEl.innerHTML = treeHtml;
322
+ }
323
+
324
+ // Breadcrumbs Renderer
325
+ function renderBreadcrumbs() {
326
+ const bcEl = document.getElementById('fm-breadcrumb');
327
+ if (!bcEl) return;
328
+
329
+ if (!currentPrefix && currentQuickFilter === 'all') {
330
+ bcEl.innerHTML = `<button type="button" class="bc-link current">${escapeHtml(rootLabel)}</button>`;
331
+ return;
332
+ }
333
+
334
+ if (currentQuickFilter !== 'all') {
335
+ bcEl.innerHTML = `
336
+ <button type="button" class="bc-link" data-nav-prefix="">${escapeHtml(rootLabel)}</button>
337
+ <span class="sep">›</span>
338
+ <button type="button" class="bc-link current">${escapeHtml(currentQuickFilter.charAt(0).toUpperCase() + currentQuickFilter.slice(1))}</button>
339
+ `;
340
+ return;
341
+ }
342
+
343
+ const parts = currentPrefix.split('/').filter(Boolean);
344
+ let accum = '';
345
+ const breadcrumbHtml = [
346
+ `<button type="button" class="bc-link" data-nav-prefix="">${escapeHtml(rootLabel)}</button>`
347
+ ];
348
+
349
+ parts.forEach((part, index) => {
350
+ accum += (accum ? '/' : '') + part;
351
+ const isLast = index === parts.length - 1;
352
+ breadcrumbHtml.push('<span class="sep">›</span>');
353
+ if (isLast) {
354
+ breadcrumbHtml.push(`<button type="button" class="bc-link current">${escapeHtml(part)}</button>`);
355
+ } else {
356
+ breadcrumbHtml.push(`<button type="button" class="bc-link" data-nav-prefix="${escapeAttr(accum)}">${escapeHtml(part)}</button>`);
357
+ }
358
+ });
359
+
360
+ bcEl.innerHTML = breadcrumbHtml.join('');
361
+ }
362
+
363
+ // File Filtering Logic
364
+ function getFilteredFiles() {
365
+ let list = (pageFiles || []).filter(f => !String(f.Key || '').endsWith('/'));
366
+
367
+ // Search filter
368
+ if (currentSearch) {
369
+ const q = currentSearch.toLowerCase();
370
+ list = list.filter(f => getBaseName(f.Key).toLowerCase().includes(q));
371
+ }
372
+
373
+ // Quick filter
374
+ if (currentQuickFilter === 'media') {
375
+ list = list.filter(f => MEDIA_EXTS.has(getFileExt(f.Key)));
376
+ } else if (currentQuickFilter === 'docs') {
377
+ list = list.filter(f => DOC_EXTS.has(getFileExt(f.Key)));
378
+ } else if (currentQuickFilter === 'code') {
379
+ list = list.filter(f => CODE_EXTS.has(getFileExt(f.Key)));
380
+ } else if (currentQuickFilter === 'starred') {
381
+ list = list.filter(f => starredFiles.has(f.Key));
382
+ }
383
+
384
+ return list;
385
+ }
386
+
387
+ function getFilteredFolders() {
388
+ if (currentQuickFilter !== 'all') return [];
389
+ if (currentSearch) {
390
+ const q = currentSearch.toLowerCase();
391
+ return (pageFolders || []).filter(f => getBaseName(f).toLowerCase().includes(q));
392
+ }
393
+ return pageFolders || [];
394
+ }
395
+
396
+ // Main Files & Grid Renderer
397
+ function renderFiles() {
398
+ const gridEl = document.getElementById('fm-grid');
399
+ const emptyEl = document.getElementById('fm-empty');
400
+ if (!gridEl) return;
401
+
402
+ const files = getFilteredFiles();
403
+ const folders = getFilteredFolders();
404
+
405
+ // Update stats
406
+ const totalCountEl = document.getElementById('totalFilesCount');
407
+ const filterInfoEl = document.getElementById('filterInfo');
408
+ const totalItems = files.length + folders.length;
409
+
410
+ if (totalCountEl) {
411
+ totalCountEl.textContent = `${totalItems} item${totalItems === 1 ? '' : 's'}`;
412
+ }
413
+ if (filterInfoEl) {
414
+ filterInfoEl.textContent = activeBucket ? `Bucket: ${activeBucket}` : 'Default bucket';
415
+ }
416
+
417
+ if (totalItems === 0) {
418
+ gridEl.innerHTML = '';
419
+ if (emptyEl) {
420
+ emptyEl.hidden = false;
421
+ emptyEl.style.display = 'flex';
422
+ }
423
+ return;
424
+ }
425
+
426
+ if (emptyEl) {
427
+ emptyEl.hidden = true;
428
+ emptyEl.style.display = 'none';
429
+ }
430
+ gridEl.className = `fm-grid view-${currentViewMode}`;
431
+
432
+ let html = '';
433
+
434
+ // If List view, add header row
435
+ if (currentViewMode === 'list') {
436
+ html += `
437
+ <div class="fm-list-header">
438
+ <div></div>
439
+ <div></div>
440
+ <div>Name</div>
441
+ <div>Size</div>
442
+ <div>Modified</div>
443
+ <div></div>
444
+ <div></div>
445
+ </div>
446
+ `;
447
+ }
448
+
449
+ // Render Folders
450
+ folders.forEach(folder => {
451
+ const cleanFolder = String(folder || '').replace(/\/+$/, '');
452
+ const folderName = getBaseName(cleanFolder);
453
+ const folderPathForAction = currentPrefix && !cleanFolder.startsWith(currentPrefix + '/') && cleanFolder !== currentPrefix
454
+ ? `${currentPrefix}/${cleanFolder}`
455
+ : cleanFolder;
456
+ const safePath = escapeAttr(folderPathForAction);
457
+ const safeName = escapeHtml(folderName);
458
+
459
+ if (currentViewMode === 'grid') {
460
+ html += `
461
+ <div class="fm-item" data-type="folder" data-folder-path="${safePath}" tabindex="0">
462
+ <div class="fm-item-icon">
463
+ ${ICONS.folder}
464
+ </div>
465
+ <div class="fm-item-details">
466
+ <div class="fm-item-name" title="${safeName}">${safeName}</div>
467
+ <div class="fm-item-meta">Folder</div>
468
+ </div>
469
+ <button type="button" class="fm-item-menu" data-menu-folder="${safePath}" aria-label="Folder options">
470
+ ${ICONS.more}
471
+ </button>
472
+ </div>
473
+ `;
474
+ } else {
475
+ html += `
476
+ <div class="fm-item" data-type="folder" data-folder-path="${safePath}" tabindex="0">
477
+ <div class="fm-item-checkbox"></div>
478
+ <div class="fm-item-icon">
479
+ ${ICONS.folderSm}
480
+ </div>
481
+ <div class="fm-item-name" title="${safeName}">${safeName}</div>
482
+ <div class="fm-item-size fm-item-meta">—</div>
483
+ <div class="fm-item-date fm-item-meta">—</div>
484
+ <div class="fm-star-wrap"></div>
485
+ <button type="button" class="fm-item-menu" data-menu-folder="${safePath}" aria-label="Folder options">
486
+ ${ICONS.more}
487
+ </button>
488
+ </div>
489
+ `;
490
+ }
491
+ });
492
+
493
+ // Render Files
494
+ files.forEach(file => {
495
+ const rawKey = String(file.Key || '');
496
+ const fileName = getBaseName(rawKey);
497
+ const safeKey = escapeAttr(rawKey);
498
+ const safeName = escapeHtml(fileName);
499
+ const isStarred = starredFiles.has(rawKey);
500
+ const isImg = isImage(fileName);
501
+ const typeCfg = getFileTypeConfig(fileName);
502
+ const formattedSize = file.Size ? formatFileSize(file.Size) : '—';
503
+ const formattedDate = file.LastModified ? formatDate(file.LastModified) : '—';
504
+
505
+ let iconInner = `<span class="ext">${typeCfg.label}</span>`;
506
+ if (isImg) {
507
+ iconInner = `<img src="${getViewUrl(rawKey)}" class="fm-item-thumb" alt="${safeName}" loading="lazy" onerror="this.outerHTML='<span class=\\'ext\\'>IMG</span>'">`;
508
+ }
509
+
510
+ if (currentViewMode === 'grid') {
511
+ html += `
512
+ <div class="fm-item ${isStarred ? 'starred' : ''}" data-type="file" data-key="${safeKey}" tabindex="0">
513
+ <div class="fm-item-icon" style="${isImg ? '' : `background:${typeCfg.bg};color:${typeCfg.color}`}">
514
+ ${iconInner}
515
+ </div>
516
+ <div class="fm-item-details">
517
+ <div class="fm-item-name" title="${safeName}">${safeName}</div>
518
+ <div class="fm-item-meta">${formattedSize} · ${formattedDate}</div>
519
+ </div>
520
+ <button type="button" class="fm-star ${isStarred ? 'on' : ''}" data-star-key="${safeKey}" aria-label="${isStarred ? 'Unstar' : 'Star'}">
521
+ ${ICONS.starred}
522
+ </button>
523
+ <button type="button" class="fm-item-menu" data-menu-file="${safeKey}" aria-label="File options">
524
+ ${ICONS.more}
525
+ </button>
526
+ </div>
527
+ `;
528
+ } else {
529
+ html += `
530
+ <div class="fm-item ${isStarred ? 'starred' : ''}" data-type="file" data-key="${safeKey}" tabindex="0">
531
+ <div class="fm-item-checkbox">
532
+ <input type="checkbox" class="file-select-cb" data-key="${safeKey}">
533
+ </div>
534
+ <div class="fm-item-icon" style="${isImg ? '' : `background:${typeCfg.bg};color:${typeCfg.color}`}">
535
+ <span class="ext">${typeCfg.label}</span>
536
+ </div>
537
+ <div class="fm-item-name" title="${safeName}">${safeName}</div>
538
+ <div class="fm-item-size fm-item-meta">${formattedSize}</div>
539
+ <div class="fm-item-date fm-item-meta">${formattedDate}</div>
540
+ <button type="button" class="fm-star ${isStarred ? 'on' : ''}" data-star-key="${safeKey}" aria-label="${isStarred ? 'Unstar' : 'Star'}">
541
+ ${ICONS.starred}
542
+ </button>
543
+ <button type="button" class="fm-item-menu" data-menu-file="${safeKey}" aria-label="File options">
544
+ ${ICONS.more}
545
+ </button>
546
+ </div>
547
+ `;
548
+ }
549
+ });
550
+
551
+ gridEl.innerHTML = html;
552
+ }
553
+
554
+ // Load Files API Call
555
+ async function loadFiles(page = 1, prefix = '', search = '') {
556
+ currentPage = page;
557
+ currentPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
558
+ currentSearch = search;
559
+
560
+ syncStateUrl();
561
+
562
+ const gridEl = document.getElementById('fm-grid');
563
+ if (gridEl) {
564
+ gridEl.innerHTML = `
565
+ <div class="fm-skeleton-grid">
566
+ ${[...Array(6)].map(() => `
567
+ <div class="fm-skeleton-card">
568
+ <div class="fm-skeleton-icon"></div>
569
+ <div class="fm-skeleton-line w-75"></div>
570
+ <div class="fm-skeleton-line w-50"></div>
571
+ </div>
572
+ `).join('')}
573
+ </div>
574
+ `;
575
+ }
576
+
577
+ try {
578
+ const searchParam = search ? `&search=${encodeURIComponent(search)}` : '';
579
+ const sortParam = `&sort=${currentSort}&order=${currentOrder}`;
580
+ const recursiveParam = `&recursive=false`;
581
+ const url = withBucketUrl(`/mbkbucket/api/files?page=${page}&prefix=${encodeURIComponent(currentPrefix)}${searchParam}${sortParam}${recursiveParam}`);
582
+
583
+ const response = await fetch(url);
584
+ const data = await response.json();
585
+
586
+ if (!data.success) {
587
+ throw new Error(data.error || 'Failed to load files');
588
+ }
589
+
590
+ currentNextToken = data.nextContinuationToken || null;
591
+ currentApiPrefix = data.prefix || '';
592
+ pageFiles = data.files || [];
593
+
594
+ if (data.mode === 'optimized') {
595
+ pageFolders = (data.folders || []).map(folder => stripApiPrefixFromPath(folder, currentApiPrefix)).filter(Boolean);
596
+ } else {
597
+ pageFolders = currentSearch
598
+ ? []
599
+ : extractFoldersAtCurrentLevel(pageFiles, currentApiPrefix || currentPrefix)
600
+ .map(folder => stripApiPrefixFromPath(folder, currentApiPrefix))
601
+ .filter(Boolean);
602
+ }
603
+
604
+ renderTree();
605
+ renderBreadcrumbs();
606
+ renderFiles();
607
+ renderPagination(data);
608
+ } catch (err) {
609
+ console.error('Error loading files:', err);
610
+ showToast(err.message || 'Error loading files', 'error');
611
+ if (gridEl) {
612
+ gridEl.innerHTML = `
613
+ <div class="fm-empty">
614
+ <div class="empty-title">Error Loading Files</div>
615
+ <div class="empty-desc">${escapeHtml(err.message)}</div>
616
+ <button type="button" class="btn btn-primary btn-sm mt-3" onclick="window.mbkbucket.loadFiles(${page}, '${escapeAttr(currentPrefix)}')">Retry</button>
617
+ </div>
618
+ `;
619
+ }
620
+ }
621
+ }
622
+
623
+ // Pagination Renderer
624
+ function renderPagination(data) {
625
+ const paginationContainer = document.getElementById('paginationContainer');
626
+ const paginationList = document.getElementById('paginationList');
627
+ if (!paginationContainer || !paginationList) return;
628
+
629
+ if (!data.totalPages || data.totalPages <= 1) {
630
+ paginationContainer.style.display = 'none';
631
+ return;
632
+ }
633
+
634
+ paginationContainer.style.display = 'flex';
635
+ let html = '';
636
+
637
+ // Prev
638
+ html += `
639
+ <li class="page-item ${data.currentPage <= 1 ? 'disabled' : ''}">
640
+ <button type="button" class="page-link" data-page="${data.currentPage - 1}">‹</button>
641
+ </li>
642
+ `;
643
+
644
+ // Pages
645
+ for (let i = 1; i <= data.totalPages; i++) {
646
+ if (i === 1 || i === data.totalPages || (i >= data.currentPage - 2 && i <= data.currentPage + 2)) {
647
+ html += `
648
+ <li class="page-item ${i === data.currentPage ? 'active' : ''}">
649
+ <button type="button" class="page-link" data-page="${i}">${i}</button>
650
+ </li>
651
+ `;
652
+ } else if (i === data.currentPage - 3 || i === data.currentPage + 3) {
653
+ html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
654
+ }
655
+ }
656
+
657
+ // Next
658
+ html += `
659
+ <li class="page-item ${data.currentPage >= data.totalPages ? 'disabled' : ''}">
660
+ <button type="button" class="page-link" data-page="${data.currentPage + 1}">›</button>
661
+ </li>
662
+ `;
663
+
664
+ paginationList.innerHTML = html;
665
+ }
666
+
667
+ // Context Menu
668
+ function openContextMenu(x, y, items) {
669
+ const menu = document.getElementById('fm-context-menu');
670
+ if (!menu) return;
671
+
672
+ menu.innerHTML = items.map(item => {
673
+ if (item === '-') return '<div class="fm-context-divider"></div>';
674
+ return `<button type="button" class="fm-context-item ${item.danger ? 'danger' : ''}" data-action="${item.id}">${item.icon || ''} ${escapeHtml(item.label)}</button>`;
675
+ }).join('');
676
+
677
+ menu.style.display = 'block';
678
+
679
+ const menuWidth = 180;
680
+ const menuHeight = menu.offsetHeight || 150;
681
+ const posX = Math.min(x, window.innerWidth - menuWidth - 10);
682
+ const posY = Math.min(y, window.innerHeight - menuHeight - 10);
683
+
684
+ menu.style.left = `${posX}px`;
685
+ menu.style.top = `${posY}px`;
686
+
687
+ menu.querySelectorAll('.fm-context-item').forEach(btn => {
688
+ btn.addEventListener('click', (e) => {
689
+ e.stopPropagation();
690
+ const actionId = btn.dataset.action;
691
+ const targetItem = items.find(it => it.id === actionId);
692
+ menu.style.display = 'none';
693
+ if (targetItem && typeof targetItem.action === 'function') {
694
+ targetItem.action();
695
+ }
696
+ });
697
+ });
698
+ }
699
+
700
+ function closeContextMenu() {
701
+ const menu = document.getElementById('fm-context-menu');
702
+ if (menu) menu.style.display = 'none';
703
+ }
704
+
705
+ // Actions: Star, Download, Preview, Delete, New Folder
706
+ function toggleStar(key) {
707
+ if (starredFiles.has(key)) {
708
+ starredFiles.delete(key);
709
+ showToast('Removed from Starred');
710
+ } else {
711
+ starredFiles.add(key);
712
+ showToast('Added to Starred', 'success');
713
+ }
714
+ localStorage.setItem('mbk_starred_keys', JSON.stringify([...starredFiles]));
715
+ renderTree();
716
+ renderFiles();
717
+ }
718
+
719
+ async function downloadFile(key) {
720
+ const fileName = getBaseName(key);
721
+ const downloadUrl = getDownloadUrl(key);
722
+ showToast(`Downloading ${fileName}...`);
723
+ try {
724
+ const response = await fetch(downloadUrl);
725
+ if (!response.ok) throw new Error('Download failed');
726
+ const blob = await response.blob();
727
+ const url = URL.createObjectURL(blob);
728
+ const a = document.createElement('a');
729
+ a.href = url;
730
+ a.download = fileName;
731
+ document.body.appendChild(a);
732
+ a.click();
733
+ document.body.removeChild(a);
734
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
735
+ } catch (err) {
736
+ showToast(err.message, 'error');
737
+ }
738
+ }
739
+
740
+ function copyShareLink(key) {
741
+ const viewUrl = window.location.origin + getViewUrl(key);
742
+ navigator.clipboard.writeText(viewUrl).then(() => {
743
+ showToast('Link copied to clipboard', 'success');
744
+ }).catch(() => {
745
+ showToast('Failed to copy link', 'error');
746
+ });
747
+ }
748
+
749
+ // Preview Modal
750
+ function openPreview(key) {
751
+ const modal = document.getElementById('previewModal');
752
+ const titleEl = document.getElementById('previewFileName');
753
+ const contentEl = document.getElementById('previewContent');
754
+ const openBtn = document.getElementById('previewOpenBtn');
755
+ const downloadBtn = document.getElementById('previewDownloadBtn');
756
+
757
+ if (!modal || !contentEl) return;
758
+
759
+ const fileName = getBaseName(key);
760
+ const viewUrl = getViewUrl(key);
761
+ const downloadUrl = getDownloadUrl(key);
762
+
763
+ if (titleEl) titleEl.textContent = fileName;
764
+ if (openBtn) openBtn.href = viewUrl;
765
+ if (downloadBtn) downloadBtn.href = downloadUrl;
766
+
767
+ contentEl.innerHTML = '<div class="empty-preview"><div class="spinner-border text-light"></div><div class="mt-2 text-light">Loading preview...</div></div>';
768
+ modal.classList.add('show');
769
+
770
+ if (isImage(fileName)) {
771
+ contentEl.innerHTML = `<img src="${viewUrl}" alt="${escapeAttr(fileName)}">`;
772
+ } else if (isVideo(fileName)) {
773
+ contentEl.innerHTML = `<video controls autoplay class="w-100" style="max-height: 70vh;"><source src="${viewUrl}"></video>`;
774
+ } else if (isAudio(fileName)) {
775
+ contentEl.innerHTML = `<div class="p-4 text-center"><audio controls autoplay class="w-100"><source src="${viewUrl}"></audio></div>`;
776
+ } else if (isPdf(fileName)) {
777
+ contentEl.innerHTML = `<iframe src="${viewUrl}" class="preview-iframe"></iframe>`;
778
+ } else if (isTextOrCode(fileName)) {
779
+ fetch(viewUrl).then(res => res.text()).then(text => {
780
+ contentEl.innerHTML = `<pre class="preview-code-block"><code>${escapeHtml(text)}</code></pre>`;
781
+ }).catch(err => {
782
+ contentEl.innerHTML = `<div class="p-4 text-danger text-center">Failed to load text preview: ${escapeHtml(err.message)}</div>`;
783
+ });
784
+ } else {
785
+ contentEl.innerHTML = `
786
+ <div class="p-5 text-center text-light">
787
+ <h5>No inline preview available for this file type.</h5>
788
+ <p class="text-muted small">You can open it in a new tab or download it directly.</p>
789
+ </div>
790
+ `;
791
+ }
792
+ }
793
+
794
+ window.closePreviewModal = function () {
795
+ const modal = document.getElementById('previewModal');
796
+ if (modal) modal.classList.remove('show');
797
+ };
798
+
799
+ // Delete Modal
800
+ function promptDelete(key, isFolder = false) {
801
+ const modal = document.getElementById('deleteModal');
802
+ const deleteFileName = document.getElementById('deleteFileName');
803
+ const deleteKey = document.getElementById('deleteKey');
804
+ const deleteIsFolder = document.getElementById('deleteIsFolder');
805
+ const deleteConfirmLabel = document.getElementById('deleteConfirmLabel');
806
+
807
+ if (!modal) return;
808
+
809
+ if (deleteFileName) deleteFileName.textContent = key;
810
+ if (deleteKey) deleteKey.value = key;
811
+ if (deleteIsFolder) deleteIsFolder.value = isFolder ? 'true' : '';
812
+ if (deleteConfirmLabel) deleteConfirmLabel.textContent = isFolder ? 'Delete Folder' : 'Delete File';
813
+
814
+ modal.classList.add('show');
815
+ }
816
+
817
+ window.closeModal = function () {
818
+ const deleteModal = document.getElementById('deleteModal');
819
+ if (deleteModal) deleteModal.classList.remove('show');
820
+ };
821
+
822
+ // New Folder Modal
823
+ function openNewFolderModal() {
824
+ const modal = document.getElementById('newFolderModal');
825
+ const input = document.getElementById('newFolderNameInput');
826
+ if (!modal) return;
827
+ if (input) input.value = '';
828
+ modal.classList.add('show');
829
+ if (input) setTimeout(() => input.focus(), 50);
830
+ }
831
+
832
+ window.closeNewFolderModal = function () {
833
+ const modal = document.getElementById('newFolderModal');
834
+ if (modal) modal.classList.remove('show');
835
+ };
836
+
837
+ window.submitCreateFolder = async function () {
838
+ const input = document.getElementById('newFolderNameInput');
839
+ const name = input ? input.value.trim() : '';
840
+ if (!name) {
841
+ showToast('Please enter a folder name', 'warning');
842
+ return;
843
+ }
844
+
845
+ try {
846
+ const response = await fetch(withBucketUrl('/mbkbucket/create-folder'), {
847
+ method: 'POST',
848
+ headers: { 'Content-Type': 'application/json' },
849
+ body: JSON.stringify({ folderName: name, prefix: currentPrefix })
850
+ });
851
+ const data = await response.json();
852
+ if (!data.success) throw new Error(data.error || 'Failed to create folder');
853
+
854
+ closeNewFolderModal();
855
+ showToast(`Folder "${name}" created`, 'success');
856
+ loadFiles(1, currentPrefix);
857
+ } catch (err) {
858
+ showToast(err.message, 'error');
859
+ }
860
+ };
861
+
862
+ // Upload Modal & Flow
863
+ function openUploadModal() {
864
+ const modal = document.getElementById('uploadModal');
865
+ const prefixInput = document.getElementById('uploadPrefixInput');
866
+ if (prefixInput) prefixInput.value = currentPrefix;
867
+ if (modal) modal.classList.add('show');
868
+ }
869
+
870
+ window.closeUploadModal = function () {
871
+ const modal = document.getElementById('uploadModal');
872
+ if (modal) modal.classList.remove('show');
873
+ };
874
+
875
+ function updateUploadQueueUI() {
876
+ const queueEl = document.getElementById('uploadQueue');
877
+ const clearBtn = document.getElementById('clearQueueBtn');
878
+ if (!queueEl) return;
879
+
880
+ if (uploadQueue.length === 0) {
881
+ queueEl.innerHTML = '';
882
+ if (clearBtn) clearBtn.style.display = 'none';
883
+ return;
884
+ }
885
+
886
+ if (clearBtn) clearBtn.style.display = 'inline-flex';
887
+ queueEl.innerHTML = uploadQueue.map((file, idx) => `
888
+ <div class="upload-queue-item">
889
+ <div class="d-flex align-items-center gap-2">
890
+ <span class="font-medium">${escapeHtml(file.name)}</span>
891
+ <span class="upload-queue-meta">${formatFileSize(file.size)}</span>
892
+ </div>
893
+ <button type="button" class="btn-close" data-remove-queue="${idx}" style="font-size: 14px;">&times;</button>
894
+ </div>
895
+ `).join('');
896
+ }
897
+
898
+ async function processUploads() {
899
+ if (isUploading || uploadQueue.length === 0) return;
900
+ isUploading = true;
901
+
902
+ const progressWrap = document.getElementById('uploadProgress');
903
+ const progressBar = document.getElementById('progressBar');
904
+ const percentEl = document.getElementById('uploadPercent');
905
+ const statusLabel = document.getElementById('uploadStatusLabel');
906
+ const startBtn = document.getElementById('startUploadBtn');
907
+
908
+ if (progressWrap) progressWrap.style.display = 'block';
909
+ if (startBtn) startBtn.disabled = true;
910
+
911
+ const totalFiles = uploadQueue.length;
912
+ let completed = 0;
913
+
914
+ for (let i = 0; i < uploadQueue.length; i++) {
915
+ const file = uploadQueue[i];
916
+ if (statusLabel) statusLabel.textContent = `Uploading ${file.name} (${i + 1}/${totalFiles})...`;
917
+
918
+ const formData = new FormData();
919
+ formData.append('file', file);
920
+ formData.append('prefix', currentPrefix);
921
+
922
+ try {
923
+ const response = await fetch(withBucketUrl('/mbkbucket/upload'), {
924
+ method: 'POST',
925
+ body: formData
926
+ });
927
+ const result = await response.json();
928
+ if (!result.success) throw new Error(result.error || 'Upload failed');
929
+ completed++;
930
+ } catch (err) {
931
+ showToast(`Failed to upload ${file.name}: ${err.message}`, 'error');
932
+ }
933
+
934
+ const percent = Math.round(((i + 1) / totalFiles) * 100);
935
+ if (progressBar) progressBar.style.width = `${percent}%`;
936
+ if (percentEl) percentEl.textContent = `${percent}%`;
937
+ }
938
+
939
+ showToast(`Successfully uploaded ${completed} file(s)`, 'success');
940
+ uploadQueue = [];
941
+ isUploading = false;
942
+ if (startBtn) startBtn.disabled = false;
943
+ if (progressWrap) progressWrap.style.display = 'none';
944
+ closeUploadModal();
945
+ updateUploadQueueUI();
946
+ loadFiles(1, currentPrefix);
947
+ }
948
+
949
+ // Event Listeners Setup
950
+ function initEventListeners() {
951
+ // Tree clicks
952
+ document.getElementById('fm-tree')?.addEventListener('click', (e) => {
953
+ const link = e.target.closest('.tree-link');
954
+ if (!link) return;
955
+ e.preventDefault();
956
+ const filter = link.dataset.treeFilter;
957
+ const prefix = link.dataset.treePrefix;
958
+
959
+ if (filter && filter !== 'all') {
960
+ currentQuickFilter = filter;
961
+ document.querySelectorAll('.quick-filter-btn').forEach(b => b.classList.remove('active'));
962
+ renderTree();
963
+ renderBreadcrumbs();
964
+ renderFiles();
965
+ } else {
966
+ currentQuickFilter = 'all';
967
+ document.querySelectorAll('.quick-filter-btn').forEach(b => {
968
+ b.classList.toggle('active', b.dataset.filter === 'all');
969
+ });
970
+ loadFiles(1, prefix !== undefined ? prefix : '');
971
+ }
972
+ });
973
+
974
+ // Breadcrumb clicks
975
+ document.getElementById('fm-breadcrumb')?.addEventListener('click', (e) => {
976
+ const link = e.target.closest('[data-nav-prefix]');
977
+ if (!link) return;
978
+ e.preventDefault();
979
+ currentQuickFilter = 'all';
980
+ loadFiles(1, link.dataset.navPrefix || '');
981
+ });
982
+
983
+ // Quick filters
984
+ document.querySelectorAll('.quick-filter-btn').forEach(btn => {
985
+ btn.addEventListener('click', () => {
986
+ document.querySelectorAll('.quick-filter-btn').forEach(b => b.classList.remove('active'));
987
+ btn.classList.add('active');
988
+ currentQuickFilter = btn.dataset.filter || 'all';
989
+ renderTree();
990
+ renderBreadcrumbs();
991
+ renderFiles();
992
+ syncStateUrl();
993
+ });
994
+ });
995
+
996
+ // View mode toggle
997
+ const gridBtn = document.getElementById('view-grid');
998
+ const listBtn = document.getElementById('view-list');
999
+
1000
+ gridBtn?.addEventListener('click', () => {
1001
+ currentViewMode = 'grid';
1002
+ localStorage.setItem('mbk_view_mode', 'grid');
1003
+ gridBtn.classList.add('active');
1004
+ listBtn?.classList.remove('active');
1005
+ renderFiles();
1006
+ syncStateUrl();
1007
+ });
1008
+
1009
+ listBtn?.addEventListener('click', () => {
1010
+ currentViewMode = 'list';
1011
+ localStorage.setItem('mbk_view_mode', 'list');
1012
+ listBtn.classList.add('active');
1013
+ gridBtn?.classList.remove('active');
1014
+ renderFiles();
1015
+ syncStateUrl();
1016
+ });
1017
+
1018
+ // Search input
1019
+ const searchInput = document.getElementById('searchInput');
1020
+ const clearSearchBtn = document.getElementById('searchClearBtn');
1021
+
1022
+ let searchTimer = null;
1023
+ searchInput?.addEventListener('input', (e) => {
1024
+ const val = e.target.value;
1025
+ if (clearSearchBtn) clearSearchBtn.style.display = val ? 'block' : 'none';
1026
+
1027
+ clearTimeout(searchTimer);
1028
+ searchTimer = setTimeout(() => {
1029
+ currentSearch = val.trim();
1030
+ renderFiles();
1031
+ }, 250);
1032
+ });
1033
+
1034
+ clearSearchBtn?.addEventListener('click', () => {
1035
+ if (searchInput) searchInput.value = '';
1036
+ clearSearchBtn.style.display = 'none';
1037
+ currentSearch = '';
1038
+ renderFiles();
1039
+ });
1040
+
1041
+ // Grid Item Interactions (Click, Double Click, Star, Context Menu)
1042
+ document.getElementById('fm-grid')?.addEventListener('click', (e) => {
1043
+ // Star click
1044
+ const starBtn = e.target.closest('[data-star-key]');
1045
+ if (starBtn) {
1046
+ e.stopPropagation();
1047
+ toggleStar(starBtn.dataset.starKey);
1048
+ return;
1049
+ }
1050
+
1051
+ // Menu click
1052
+ const menuBtn = e.target.closest('.fm-item-menu');
1053
+ if (menuBtn) {
1054
+ e.stopPropagation();
1055
+ const rect = menuBtn.getBoundingClientRect();
1056
+ const fileKey = menuBtn.dataset.menuFile;
1057
+ const folderPath = menuBtn.dataset.menuFolder;
1058
+
1059
+ if (fileKey) {
1060
+ const isStarred = starredFiles.has(fileKey);
1061
+ openContextMenu(rect.left, rect.bottom + 4, [
1062
+ { id: 'view', label: 'Preview', icon: '<i class="fas fa-eye"></i>', action: () => openPreview(fileKey) },
1063
+ { id: 'download', label: 'Download', icon: '<i class="fas fa-download"></i>', action: () => downloadFile(fileKey) },
1064
+ { id: 'share', label: 'Copy link', icon: '<i class="fas fa-link"></i>', action: () => copyShareLink(fileKey) },
1065
+ { id: 'star', label: isStarred ? 'Unstar' : 'Star', icon: '<i class="fas fa-star"></i>', action: () => toggleStar(fileKey) },
1066
+ '-',
1067
+ { id: 'delete', label: 'Delete', icon: '<i class="fas fa-trash"></i>', danger: true, action: () => promptDelete(fileKey, false) }
1068
+ ]);
1069
+ } else if (folderPath) {
1070
+ openContextMenu(rect.left, rect.bottom + 4, [
1071
+ { id: 'open', label: 'Open Folder', icon: '<i class="fas fa-folder-open"></i>', action: () => loadFiles(1, folderPath) },
1072
+ '-',
1073
+ { id: 'delete', label: 'Delete Folder', icon: '<i class="fas fa-trash"></i>', danger: true, action: () => promptDelete(folderPath + '/', true) }
1074
+ ]);
1075
+ }
1076
+ return;
1077
+ }
1078
+
1079
+ // Folder click to navigate
1080
+ const folderItem = e.target.closest('[data-type="folder"]');
1081
+ if (folderItem) {
1082
+ e.stopPropagation();
1083
+ loadFiles(1, folderItem.dataset.folderPath);
1084
+ return;
1085
+ }
1086
+
1087
+ // File click to preview
1088
+ const fileItem = e.target.closest('[data-type="file"]');
1089
+ if (fileItem && !e.target.closest('.file-select-cb')) {
1090
+ e.stopPropagation();
1091
+ openPreview(fileItem.dataset.key);
1092
+ }
1093
+ });
1094
+
1095
+ // Bucket Selector
1096
+ document.getElementById('bucketSelector')?.addEventListener('change', (e) => {
1097
+ const newBucket = e.target.value;
1098
+ if (!newBucket) return;
1099
+ activeBucket = newBucket;
1100
+ loadFiles(1, '');
1101
+ });
1102
+
1103
+ // Top action buttons & Modals
1104
+ document.querySelectorAll('[data-action="upload-files"]').forEach(btn => {
1105
+ btn.addEventListener('click', openUploadModal);
1106
+ });
1107
+
1108
+ document.querySelectorAll('[data-action="create-folder"]').forEach(btn => {
1109
+ btn.addEventListener('click', openNewFolderModal);
1110
+ });
1111
+
1112
+ // Drag and Drop Upload Zone
1113
+ const dropZone = document.getElementById('dropZone');
1114
+ const fileInput = document.getElementById('fileInput');
1115
+
1116
+ dropZone?.addEventListener('click', () => fileInput?.click());
1117
+
1118
+ dropZone?.addEventListener('dragover', (e) => {
1119
+ e.preventDefault();
1120
+ dropZone.classList.add('dragover');
1121
+ });
1122
+
1123
+ dropZone?.addEventListener('dragleave', () => {
1124
+ dropZone.classList.remove('dragover');
1125
+ });
1126
+
1127
+ dropZone?.addEventListener('drop', (e) => {
1128
+ e.preventDefault();
1129
+ dropZone.classList.remove('dragover');
1130
+ if (e.dataTransfer.files?.length) {
1131
+ uploadQueue.push(...Array.from(e.dataTransfer.files));
1132
+ updateUploadQueueUI();
1133
+ }
1134
+ });
1135
+
1136
+ fileInput?.addEventListener('change', (e) => {
1137
+ if (e.target.files?.length) {
1138
+ uploadQueue.push(...Array.from(e.target.files));
1139
+ updateUploadQueueUI();
1140
+ }
1141
+ });
1142
+
1143
+ document.getElementById('uploadQueue')?.addEventListener('click', (e) => {
1144
+ const removeBtn = e.target.closest('[data-remove-queue]');
1145
+ if (removeBtn) {
1146
+ const idx = parseInt(removeBtn.dataset.removeQueue, 10);
1147
+ uploadQueue.splice(idx, 1);
1148
+ updateUploadQueueUI();
1149
+ }
1150
+ });
1151
+
1152
+ document.getElementById('clearQueueBtn')?.addEventListener('click', () => {
1153
+ uploadQueue = [];
1154
+ updateUploadQueueUI();
1155
+ });
1156
+
1157
+ document.getElementById('startUploadBtn')?.addEventListener('click', processUploads);
1158
+
1159
+ // Fullscreen Preview Toggle
1160
+ document.getElementById('previewFullscreenBtn')?.addEventListener('click', () => {
1161
+ document.querySelector('.preview-modal-dialog')?.classList.toggle('is-fullscreen');
1162
+ });
1163
+
1164
+ // Pagination clicks
1165
+ document.getElementById('paginationList')?.addEventListener('click', (e) => {
1166
+ const link = e.target.closest('[data-page]');
1167
+ if (!link) return;
1168
+ const page = parseInt(link.dataset.page, 10);
1169
+ if (!isNaN(page) && page > 0) {
1170
+ loadFiles(page, currentPrefix, currentSearch);
1171
+ }
1172
+ });
1173
+
1174
+ // Global click to close context menu
1175
+ document.addEventListener('click', closeContextMenu);
1176
+
1177
+ // Handle delete form submission
1178
+ const deleteForm = document.getElementById('deleteForm');
1179
+ deleteForm?.addEventListener('submit', async (e) => {
1180
+ e.preventDefault();
1181
+ const formData = new FormData(deleteForm);
1182
+ const key = formData.get('key');
1183
+ const isFolder = formData.get('folder') === 'true';
1184
+
1185
+ try {
1186
+ const response = await fetch(withBucketUrl('/mbkbucket/delete'), {
1187
+ method: 'POST',
1188
+ headers: { 'Content-Type': 'application/json' },
1189
+ body: JSON.stringify({ key, isFolder, prefix: currentPrefix })
1190
+ });
1191
+ const data = await response.json();
1192
+ if (!data.success) throw new Error(data.error || 'Delete failed');
1193
+
1194
+ closeModal();
1195
+ showToast(`Successfully deleted ${key}`, 'success');
1196
+ loadFiles(currentPage, currentPrefix);
1197
+ } catch (err) {
1198
+ showToast(err.message, 'error');
1199
+ }
1200
+ });
1201
+ }
1202
+
1203
+ // Initialization
1204
+ document.addEventListener('DOMContentLoaded', () => {
1205
+ // Sync view mode buttons
1206
+ if (currentViewMode === 'list') {
1207
+ document.getElementById('view-list')?.classList.add('active');
1208
+ document.getElementById('view-grid')?.classList.remove('active');
1209
+ }
1210
+
1211
+ initEventListeners();
1212
+
1213
+ // Initial load with URL params
1214
+ const initialFolder = urlParams.get('folder') || '';
1215
+ const initialPage = parseInt(urlParams.get('page') || '1', 10);
1216
+ const initialSearch = urlParams.get('search') || '';
1217
+
1218
+ loadFiles(initialPage, initialFolder, initialSearch);
1219
+ });
1220
+
1221
+ // Expose global methods if needed
1222
+ window.mbkbucket = {
1223
+ loadFiles,
1224
+ openPreview,
1225
+ downloadFile,
1226
+ showToast
1227
+ };
1228
+
1229
+ })();