mbkbucket 3.8.3 → 4.0.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 (35) hide show
  1. package/.env.example +15 -14
  2. package/README.md +561 -353
  3. package/index.js +32 -30
  4. package/lib/public/bucketadmin.css +2042 -1708
  5. package/lib/public/bucketadmin.js +1622 -1286
  6. package/lib/public/mbkbucket-helper-demo.html +4 -24
  7. package/lib/public/mbkbucket-helper.css +28 -34
  8. package/lib/public/mbkbucket-helper.js +4 -3
  9. package/lib/src/config/validation.js +86 -73
  10. package/lib/src/controllers/api.controller.js +459 -410
  11. package/lib/src/controllers/dashboard.controller.js +69 -49
  12. package/lib/src/controllers/info.controller.js +34 -3
  13. package/lib/src/controllers/view.controller.js +233 -231
  14. package/lib/src/core/storage/errors.js +62 -0
  15. package/lib/src/core/storage/gdrive-auth.js +193 -0
  16. package/lib/src/core/storage/gdrive-provider.js +656 -0
  17. package/lib/src/core/storage/index.js +14 -0
  18. package/lib/src/core/storage/models.js +80 -0
  19. package/lib/src/core/storage/path-strategy.js +65 -0
  20. package/lib/src/core/storage/s3-provider.js +491 -0
  21. package/lib/src/core/storage/storage-manager.js +203 -0
  22. package/lib/src/core/storage/storage-provider.js +217 -0
  23. package/lib/src/middleware/bucket-resolver.js +37 -37
  24. package/lib/src/routes/api.routes.js +5 -1
  25. package/lib/src/routes/index.js +60 -60
  26. package/lib/src/routes/view.routes.js +28 -25
  27. package/lib/src/services/s3.service.js +42 -594
  28. package/lib/src/services/storage.service.js +213 -0
  29. package/lib/src/utils/errors.js +56 -46
  30. package/package.json +1 -1
  31. package/views/bucket.hbs +47 -71
  32. package/views/bucketadmin_move_modal.hbs +35 -0
  33. package/views/bucketadmin_preview_modal.hbs +108 -40
  34. package/views/bucketadmincontent.hbs +193 -144
  35. package/views/mbkbucket_info.hbs +134 -232
@@ -1,1286 +1,1622 @@
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 showThumbnails = localStorage.getItem('mbk_show_thumbnails') === 'true'; // Fast Mode by default (saves Vercel resources & tokens)
24
- let thumbObserver = null;
25
- let pageFiles = [];
26
- let pageFolders = [];
27
- let currentApiPrefix = '';
28
- let currentNextToken = null;
29
- let selectedFiles = new Set();
30
- let starredFiles = new Set(JSON.parse(localStorage.getItem('mbk_starred_keys') || '[]'));
31
-
32
- // Upload State
33
- let uploadQueue = [];
34
- let isUploading = false;
35
-
36
- // DOM Elements & Config
37
- const bucketAppData = document.getElementById('bucketAppData');
38
- const bucketAppName = bucketAppData?.dataset.appName || '';
39
- const urlParams = new URLSearchParams(window.location.search);
40
- const selectedBucket = urlParams.get('bucket') || '';
41
- let activeBucket = selectedBucket;
42
- const app = String(bucketAppName || '').toLowerCase();
43
- const isRootApp = app === 'portal' || app === 'mbkbucket';
44
- const rootLabel = isRootApp ? 'My Drive' : (bucketAppName || 'Bucket Drive');
45
-
46
- // File categories & extensions
47
- const MEDIA_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'mp4', 'webm', 'ogg', 'avi', 'mov', 'mp3', 'wav', 'flac', 'aac', 'm4a']);
48
- const DOC_EXTS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'rtf']);
49
- 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']);
50
-
51
- const FILE_TYPES = {
52
- pdf: { color: 'var(--red)', bg: 'var(--red-lt)', label: 'PDF' },
53
- doc: { color: 'var(--blue)', bg: 'var(--blue-lt)', label: 'DOC' },
54
- docx: { color: 'var(--blue)', bg: 'var(--blue-lt)', label: 'DOC' },
55
- xls: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'XLS' },
56
- xlsx: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'XLS' },
57
- csv: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'CSV' },
58
- ppt: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'PPT' },
59
- pptx: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'PPT' },
60
- txt: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'TXT' },
61
- md: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'MD' },
62
- jpg: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
63
- jpeg: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
64
- png: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
65
- gif: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'GIF' },
66
- webp: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
67
- svg: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'SVG' },
68
- mp4: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
69
- webm: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
70
- mov: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
71
- mp3: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'AUD' },
72
- wav: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'AUD' },
73
- zip: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'ZIP' },
74
- tar: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'TAR' },
75
- gz: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'GZ' },
76
- code: { color: 'var(--cyan)', bg: 'var(--cyan-lt)', label: 'CODE' }
77
- };
78
-
79
- // SVGs
80
- const ICONS = {
81
- 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>',
82
- 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>',
83
- 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>',
84
- 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>',
85
- 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>',
86
- 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>',
87
- 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>',
88
- 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>'
89
- };
90
-
91
- // Helper Functions
92
- function escapeHtml(str) {
93
- return String(str || '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
94
- }
95
-
96
- function escapeAttr(str) {
97
- return escapeHtml(str);
98
- }
99
-
100
- function getBaseName(path) {
101
- const val = String(path || '').replace(/\/+$/, '');
102
- const lastSlash = val.lastIndexOf('/');
103
- return lastSlash === -1 ? val : val.substring(lastSlash + 1);
104
- }
105
-
106
- function getFileExt(fileName) {
107
- const name = getBaseName(fileName);
108
- const dot = name.lastIndexOf('.');
109
- return dot > -1 ? name.slice(dot + 1).toLowerCase() : '';
110
- }
111
-
112
- function formatFileSize(bytes) {
113
- if (!bytes || bytes === 0) return '0 B';
114
- const k = 1024;
115
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
116
- const i = Math.floor(Math.log(bytes) / Math.log(k));
117
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
118
- }
119
-
120
- function formatDate(dateStr) {
121
- if (!dateStr) return '—';
122
- const d = new Date(dateStr);
123
- if (isNaN(d.getTime())) return String(dateStr);
124
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
125
- }
126
-
127
- function withBucketUrl(url) {
128
- if (!activeBucket) return url;
129
- const sep = url.includes('?') ? '&' : '?';
130
- return `${url}${sep}bucket=${encodeURIComponent(activeBucket)}`;
131
- }
132
-
133
- function getViewUrl(key) {
134
- return withBucketUrl(`/mbkbucket/view/${encodeURIComponent(key)}`);
135
- }
136
-
137
- function getDownloadUrl(key) {
138
- return withBucketUrl(`/mbkbucket/download/${encodeURIComponent(key)}`);
139
- }
140
-
141
- function isImage(fileName) {
142
- const ext = getFileExt(fileName);
143
- return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext);
144
- }
145
-
146
- function isVideo(fileName) {
147
- const ext = getFileExt(fileName);
148
- return ['mp4', 'webm', 'ogg', 'mov', 'mkv'].includes(ext);
149
- }
150
-
151
- function isAudio(fileName) {
152
- const ext = getFileExt(fileName);
153
- return ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext);
154
- }
155
-
156
- function isTextOrCode(fileName) {
157
- const ext = getFileExt(fileName);
158
- return CODE_EXTS.has(ext) || ['txt', 'md', 'csv', 'log', 'json', 'xml', 'env'].includes(ext);
159
- }
160
-
161
- function isPdf(fileName) {
162
- return getFileExt(fileName) === 'pdf';
163
- }
164
-
165
- function getFileTypeConfig(fileName) {
166
- const ext = getFileExt(fileName);
167
- if (FILE_TYPES[ext]) return FILE_TYPES[ext];
168
- if (CODE_EXTS.has(ext)) return FILE_TYPES.code;
169
- return { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: ext.toUpperCase().slice(0, 4) || 'FILE' };
170
- }
171
-
172
- function stripApiPrefixFromPath(path, apiPrefix = '') {
173
- const normalizedPath = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
174
- const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
175
- if (!normalizedApiPrefix) return normalizedPath;
176
- if (normalizedPath === normalizedApiPrefix || normalizedPath === `${normalizedApiPrefix}/`) return '';
177
- if (normalizedPath.startsWith(`${normalizedApiPrefix}/`)) {
178
- return normalizedPath.slice(normalizedApiPrefix.length + 1);
179
- }
180
- return normalizedPath;
181
- }
182
-
183
- function toApiFolderPath(prefix = '', apiPrefix = '') {
184
- const uiPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
185
- const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
186
- if (!uiPrefix) return '';
187
- if (!normalizedApiPrefix) return uiPrefix;
188
- if (uiPrefix === normalizedApiPrefix || uiPrefix.startsWith(`${normalizedApiPrefix}/`)) return uiPrefix;
189
- return `${normalizedApiPrefix}/${uiPrefix}`;
190
- }
191
-
192
- function extractFoldersAtCurrentLevel(files, prefix = '') {
193
- const folderSet = new Set();
194
- const cleanPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
195
- const prefixOffset = cleanPrefix ? cleanPrefix.length + 1 : 0;
196
-
197
- (files || []).forEach(file => {
198
- const key = String(file.Key || '').replace(/^\/+/, '');
199
- if (cleanPrefix && !key.startsWith(cleanPrefix + '/')) return;
200
- const relPath = cleanPrefix ? key.substring(prefixOffset) : key;
201
- const slashIdx = relPath.indexOf('/');
202
- if (slashIdx !== -1) {
203
- const folder = relPath.substring(0, slashIdx);
204
- folderSet.add(cleanPrefix ? `${cleanPrefix}/${folder}` : folder);
205
- }
206
- });
207
-
208
- return Array.from(folderSet).sort();
209
- }
210
-
211
- function syncStateUrl() {
212
- try {
213
- const parts = [];
214
- if (activeBucket) parts.push(`bucket=${encodeURIComponent(activeBucket)}`);
215
- if (currentPrefix) {
216
- const folderPath = toApiFolderPath(currentPrefix, currentApiPrefix);
217
- parts.push(`folder=${encodeURIComponent(folderPath).replace(/%2F/g, '/')}`);
218
- }
219
- if (currentPage > 1) parts.push(`page=${currentPage}`);
220
- if (currentSearch) parts.push(`search=${encodeURIComponent(currentSearch)}`);
221
- if (currentSort !== 'name') parts.push(`sort=${encodeURIComponent(currentSort)}`);
222
- if (currentOrder !== 'asc') parts.push(`order=${encodeURIComponent(currentOrder)}`);
223
- if (currentViewMode !== 'grid') parts.push(`view=${encodeURIComponent(currentViewMode)}`);
224
- if (currentQuickFilter !== 'all') parts.push(`filter=${encodeURIComponent(currentQuickFilter)}`);
225
-
226
- const newUrl = '/mbkbucket' + (parts.length ? '?' + parts.join('&') : '');
227
- window.history.replaceState(null, '', newUrl);
228
- } catch (e) {
229
- console.warn('History update failed', e);
230
- }
231
- }
232
-
233
- // Toast System
234
- function showToast(message, type = 'success') {
235
- const container = document.getElementById('toast-container');
236
- if (!container) return;
237
-
238
- const toast = document.createElement('div');
239
- toast.className = `toast-notification ${type}`;
240
- toast.innerHTML = `
241
- <span>${escapeHtml(message)}</span>
242
- <button type="button" class="toast-close-btn">&times;</button>
243
- `;
244
-
245
- toast.querySelector('.toast-close-btn').addEventListener('click', () => {
246
- toast.remove();
247
- });
248
-
249
- container.appendChild(toast);
250
-
251
- setTimeout(() => {
252
- toast.style.opacity = '0';
253
- toast.style.transform = 'translateY(10px)';
254
- setTimeout(() => toast.remove(), 250);
255
- }, 4000);
256
- }
257
-
258
- // Tree Renderer
259
- function renderTree() {
260
- const treeEl = document.getElementById('fm-tree');
261
- if (!treeEl) return;
262
-
263
- const allFolders = pageFolders || [];
264
- const isStarredActive = currentQuickFilter === 'starred';
265
- const isRootActive = !currentPrefix && currentQuickFilter === 'all';
266
-
267
- let treeHtml = `
268
- <div class="tree-group">
269
- <button type="button" class="tree-link ${isRootActive ? 'active' : ''}" data-tree-prefix="" data-tree-filter="all">
270
- ${ICONS.drive}
271
- <span>${escapeHtml(rootLabel)}</span>
272
- </button>
273
- </div>
274
- `;
275
-
276
- // Folder hierarchy
277
- if (allFolders.length > 0) {
278
- treeHtml += `
279
- <div class="tree-group">
280
- <div class="tree-subs">
281
- ${allFolders.map(folder => {
282
- const cleanFolder = String(folder || '').replace(/\/+$/, '');
283
- const folderName = getBaseName(cleanFolder);
284
- const folderPathForAction = currentPrefix && !cleanFolder.startsWith(currentPrefix + '/') && cleanFolder !== currentPrefix
285
- ? `${currentPrefix}/${cleanFolder}`
286
- : cleanFolder;
287
- const isFolderActive = currentPrefix === folderPathForAction;
288
- return `
289
- <button type="button" class="tree-link tree-sub ${isFolderActive ? 'active' : ''}" data-tree-prefix="${escapeAttr(folderPathForAction)}" data-tree-filter="all">
290
- ${ICONS.folderSm}
291
- <span>${escapeHtml(folderName)}</span>
292
- </button>
293
- `;
294
- }).join('')}
295
- </div>
296
- </div>
297
- `;
298
- }
299
-
300
- // Quick navigation filters
301
- treeHtml += `
302
- <div class="tree-group" style="margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border-color-light);">
303
- <button type="button" class="tree-link ${isStarredActive ? 'active' : ''}" data-tree-filter="starred">
304
- ${ICONS.starred}
305
- <span>Starred</span>
306
- ${starredFiles.size > 0 ? `<span class="tree-badge">${starredFiles.size}</span>` : ''}
307
- </button>
308
- <button type="button" class="tree-link ${currentQuickFilter === 'media' ? 'active' : ''}" data-tree-filter="media">
309
- ${ICONS.media}
310
- <span>Media</span>
311
- </button>
312
- <button type="button" class="tree-link ${currentQuickFilter === 'docs' ? 'active' : ''}" data-tree-filter="docs">
313
- ${ICONS.docs}
314
- <span>Documents</span>
315
- </button>
316
- <button type="button" class="tree-link ${currentQuickFilter === 'code' ? 'active' : ''}" data-tree-filter="code">
317
- ${ICONS.code}
318
- <span>Code</span>
319
- </button>
320
- </div>
321
- `;
322
-
323
- treeEl.innerHTML = treeHtml;
324
- }
325
-
326
- // Breadcrumbs Renderer
327
- function renderBreadcrumbs() {
328
- const bcEl = document.getElementById('fm-breadcrumb');
329
- if (!bcEl) return;
330
-
331
- if (!currentPrefix && currentQuickFilter === 'all') {
332
- bcEl.innerHTML = `<button type="button" class="bc-link current">${escapeHtml(rootLabel)}</button>`;
333
- return;
334
- }
335
-
336
- if (currentQuickFilter !== 'all') {
337
- bcEl.innerHTML = `
338
- <button type="button" class="bc-link" data-nav-prefix="">${escapeHtml(rootLabel)}</button>
339
- <span class="sep">›</span>
340
- <button type="button" class="bc-link current">${escapeHtml(currentQuickFilter.charAt(0).toUpperCase() + currentQuickFilter.slice(1))}</button>
341
- `;
342
- return;
343
- }
344
-
345
- const parts = currentPrefix.split('/').filter(Boolean);
346
- let accum = '';
347
- const breadcrumbHtml = [
348
- `<button type="button" class="bc-link" data-nav-prefix="">${escapeHtml(rootLabel)}</button>`
349
- ];
350
-
351
- parts.forEach((part, index) => {
352
- accum += (accum ? '/' : '') + part;
353
- const isLast = index === parts.length - 1;
354
- breadcrumbHtml.push('<span class="sep">›</span>');
355
- if (isLast) {
356
- breadcrumbHtml.push(`<button type="button" class="bc-link current">${escapeHtml(part)}</button>`);
357
- } else {
358
- breadcrumbHtml.push(`<button type="button" class="bc-link" data-nav-prefix="${escapeAttr(accum)}">${escapeHtml(part)}</button>`);
359
- }
360
- });
361
-
362
- bcEl.innerHTML = breadcrumbHtml.join('');
363
- }
364
-
365
- // File Filtering Logic
366
- function getFilteredFiles() {
367
- let list = (pageFiles || []).filter(f => !String(f.Key || '').endsWith('/'));
368
-
369
- // Search filter
370
- if (currentSearch) {
371
- const q = currentSearch.toLowerCase();
372
- list = list.filter(f => getBaseName(f.Key).toLowerCase().includes(q));
373
- }
374
-
375
- // Quick filter
376
- if (currentQuickFilter === 'media') {
377
- list = list.filter(f => MEDIA_EXTS.has(getFileExt(f.Key)));
378
- } else if (currentQuickFilter === 'docs') {
379
- list = list.filter(f => DOC_EXTS.has(getFileExt(f.Key)));
380
- } else if (currentQuickFilter === 'code') {
381
- list = list.filter(f => CODE_EXTS.has(getFileExt(f.Key)));
382
- } else if (currentQuickFilter === 'starred') {
383
- list = list.filter(f => starredFiles.has(f.Key));
384
- }
385
-
386
- return list;
387
- }
388
-
389
- function getFilteredFolders() {
390
- if (currentQuickFilter !== 'all') return [];
391
- if (currentSearch) {
392
- const q = currentSearch.toLowerCase();
393
- return (pageFolders || []).filter(f => getBaseName(f).toLowerCase().includes(q));
394
- }
395
- return pageFolders || [];
396
- }
397
-
398
- // Main Files & Grid Renderer
399
- function renderFiles() {
400
- const gridEl = document.getElementById('fm-grid');
401
- const emptyEl = document.getElementById('fm-empty');
402
- if (!gridEl) return;
403
-
404
- const files = getFilteredFiles();
405
- const folders = getFilteredFolders();
406
-
407
- // Update stats
408
- const totalCountEl = document.getElementById('totalFilesCount');
409
- const filterInfoEl = document.getElementById('filterInfo');
410
- const totalItems = files.length + folders.length;
411
-
412
- if (totalCountEl) {
413
- totalCountEl.textContent = `${totalItems} item${totalItems === 1 ? '' : 's'}`;
414
- }
415
- if (filterInfoEl) {
416
- filterInfoEl.textContent = activeBucket ? `Bucket: ${activeBucket}` : 'Default bucket';
417
- }
418
-
419
- if (totalItems === 0) {
420
- gridEl.innerHTML = '';
421
- if (emptyEl) {
422
- emptyEl.hidden = false;
423
- emptyEl.style.display = 'flex';
424
- }
425
- return;
426
- }
427
-
428
- if (emptyEl) {
429
- emptyEl.hidden = true;
430
- emptyEl.style.display = 'none';
431
- }
432
- gridEl.className = `fm-grid view-${currentViewMode}`;
433
-
434
- let html = '';
435
-
436
- // If List view, add header row
437
- if (currentViewMode === 'list') {
438
- html += `
439
- <div class="fm-list-header">
440
- <div></div>
441
- <div></div>
442
- <div>Name</div>
443
- <div>Size</div>
444
- <div>Modified</div>
445
- <div></div>
446
- <div></div>
447
- </div>
448
- `;
449
- }
450
-
451
- // Render Folders
452
- folders.forEach(folder => {
453
- const cleanFolder = String(folder || '').replace(/\/+$/, '');
454
- const folderName = getBaseName(cleanFolder);
455
- const folderPathForAction = currentPrefix && !cleanFolder.startsWith(currentPrefix + '/') && cleanFolder !== currentPrefix
456
- ? `${currentPrefix}/${cleanFolder}`
457
- : cleanFolder;
458
- const safePath = escapeAttr(folderPathForAction);
459
- const safeName = escapeHtml(folderName);
460
-
461
- if (currentViewMode === 'grid') {
462
- html += `
463
- <div class="fm-item" data-type="folder" data-folder-path="${safePath}" tabindex="0">
464
- <div class="fm-item-icon">
465
- ${ICONS.folder}
466
- </div>
467
- <div class="fm-item-details">
468
- <div class="fm-item-name" title="${safeName}">${safeName}</div>
469
- <div class="fm-item-meta">Folder</div>
470
- </div>
471
- <button type="button" class="fm-item-menu" data-menu-folder="${safePath}" aria-label="Folder options">
472
- ${ICONS.more}
473
- </button>
474
- </div>
475
- `;
476
- } else {
477
- html += `
478
- <div class="fm-item" data-type="folder" data-folder-path="${safePath}" tabindex="0">
479
- <div class="fm-item-checkbox"></div>
480
- <div class="fm-item-icon">
481
- ${ICONS.folderSm}
482
- </div>
483
- <div class="fm-item-name" title="${safeName}">${safeName}</div>
484
- <div class="fm-item-size fm-item-meta">—</div>
485
- <div class="fm-item-date fm-item-meta">—</div>
486
- <div class="fm-star-wrap"></div>
487
- <button type="button" class="fm-item-menu" data-menu-folder="${safePath}" aria-label="Folder options">
488
- ${ICONS.more}
489
- </button>
490
- </div>
491
- `;
492
- }
493
- });
494
-
495
- // Render Files
496
- files.forEach(file => {
497
- const rawKey = String(file.Key || '');
498
- const fileName = getBaseName(rawKey);
499
- const safeKey = escapeAttr(rawKey);
500
- const safeName = escapeHtml(fileName);
501
- const isStarred = starredFiles.has(rawKey);
502
- const isImg = isImage(fileName);
503
- const typeCfg = getFileTypeConfig(fileName);
504
- const formattedSize = file.Size ? formatFileSize(file.Size) : '—';
505
- const formattedDate = file.LastModified ? formatDate(file.LastModified) : '—';
506
-
507
- const showImgThumb = isImg && showThumbnails;
508
- let iconInner = `<span class="ext">${typeCfg.label}</span>`;
509
- if (showImgThumb) {
510
- iconInner = `<img data-src="${getViewUrl(rawKey)}" class="fm-item-thumb fm-lazy-thumb" alt="${safeName}" onload="this.classList.add('is-loaded')" onerror="this.outerHTML='<span class=\\'ext\\'>IMG</span>'">`;
511
- }
512
-
513
- if (currentViewMode === 'grid') {
514
- html += `
515
- <div class="fm-item ${isStarred ? 'starred' : ''}" data-type="file" data-key="${safeKey}" tabindex="0">
516
- <div class="fm-item-icon" style="${showImgThumb ? '' : `background:${typeCfg.bg};color:${typeCfg.color}`}">
517
- ${iconInner}
518
- </div>
519
- <div class="fm-item-details">
520
- <div class="fm-item-name" title="${safeName}">${safeName}</div>
521
- <div class="fm-item-meta">${formattedSize} · ${formattedDate}</div>
522
- </div>
523
- <button type="button" class="fm-star ${isStarred ? 'on' : ''}" data-star-key="${safeKey}" aria-label="${isStarred ? 'Unstar' : 'Star'}">
524
- ${ICONS.starred}
525
- </button>
526
- <button type="button" class="fm-item-menu" data-menu-file="${safeKey}" aria-label="File options">
527
- ${ICONS.more}
528
- </button>
529
- </div>
530
- `;
531
- } else {
532
- html += `
533
- <div class="fm-item ${isStarred ? 'starred' : ''}" data-type="file" data-key="${safeKey}" tabindex="0">
534
- <div class="fm-item-checkbox">
535
- <input type="checkbox" class="file-select-cb" data-key="${safeKey}">
536
- </div>
537
- <div class="fm-item-icon" style="${isImg ? '' : `background:${typeCfg.bg};color:${typeCfg.color}`}">
538
- <span class="ext">${typeCfg.label}</span>
539
- </div>
540
- <div class="fm-item-name" title="${safeName}">${safeName}</div>
541
- <div class="fm-item-size fm-item-meta">${formattedSize}</div>
542
- <div class="fm-item-date fm-item-meta">${formattedDate}</div>
543
- <button type="button" class="fm-star ${isStarred ? 'on' : ''}" data-star-key="${safeKey}" aria-label="${isStarred ? 'Unstar' : 'Star'}">
544
- ${ICONS.starred}
545
- </button>
546
- <button type="button" class="fm-item-menu" data-menu-file="${safeKey}" aria-label="File options">
547
- ${ICONS.more}
548
- </button>
549
- </div>
550
- `;
551
- }
552
- });
553
-
554
- gridEl.innerHTML = html;
555
- observeThumbnails();
556
- }
557
-
558
- function observeThumbnails() {
559
- const lazyImages = document.querySelectorAll('img.fm-lazy-thumb[data-src]');
560
- if (!lazyImages.length) return;
561
-
562
- if (!('IntersectionObserver' in window)) {
563
- lazyImages.forEach(img => {
564
- if (img.dataset.src) {
565
- img.src = img.dataset.src;
566
- img.removeAttribute('data-src');
567
- }
568
- });
569
- return;
570
- }
571
-
572
- if (thumbObserver) {
573
- thumbObserver.disconnect();
574
- }
575
-
576
- thumbObserver = new IntersectionObserver((entries, observer) => {
577
- entries.forEach(entry => {
578
- if (entry.isIntersecting) {
579
- const img = entry.target;
580
- if (img.dataset.src) {
581
- img.src = img.dataset.src;
582
- img.removeAttribute('data-src');
583
- }
584
- observer.unobserve(img);
585
- }
586
- });
587
- }, {
588
- rootMargin: '100px 0px',
589
- threshold: 0.01
590
- });
591
-
592
- lazyImages.forEach(img => thumbObserver.observe(img));
593
- }
594
-
595
- // Load Files API Call
596
- async function loadFiles(page = 1, prefix = '', search = '') {
597
- currentPage = page;
598
- currentPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
599
- currentSearch = search;
600
-
601
- syncStateUrl();
602
-
603
- const gridEl = document.getElementById('fm-grid');
604
- if (gridEl) {
605
- gridEl.innerHTML = `
606
- <div class="fm-skeleton-grid">
607
- ${[...Array(6)].map(() => `
608
- <div class="fm-skeleton-card">
609
- <div class="fm-skeleton-icon"></div>
610
- <div class="fm-skeleton-line w-75"></div>
611
- <div class="fm-skeleton-line w-50"></div>
612
- </div>
613
- `).join('')}
614
- </div>
615
- `;
616
- }
617
-
618
- try {
619
- const searchParam = search ? `&search=${encodeURIComponent(search)}` : '';
620
- const sortParam = `&sort=${currentSort}&order=${currentOrder}`;
621
- const recursiveParam = `&recursive=false`;
622
- const url = withBucketUrl(`/mbkbucket/api/files?page=${page}&prefix=${encodeURIComponent(currentPrefix)}${searchParam}${sortParam}${recursiveParam}`);
623
-
624
- const response = await fetch(url);
625
- const data = await response.json();
626
-
627
- if (!data.success) {
628
- throw new Error(data.error || 'Failed to load files');
629
- }
630
-
631
- currentNextToken = data.nextContinuationToken || null;
632
- currentApiPrefix = data.prefix || '';
633
- pageFiles = data.files || [];
634
-
635
- if (data.mode === 'optimized') {
636
- pageFolders = (data.folders || []).map(folder => stripApiPrefixFromPath(folder, currentApiPrefix)).filter(Boolean);
637
- } else {
638
- pageFolders = currentSearch
639
- ? []
640
- : extractFoldersAtCurrentLevel(pageFiles, currentApiPrefix || currentPrefix)
641
- .map(folder => stripApiPrefixFromPath(folder, currentApiPrefix))
642
- .filter(Boolean);
643
- }
644
-
645
- renderTree();
646
- renderBreadcrumbs();
647
- renderFiles();
648
- renderPagination(data);
649
- } catch (err) {
650
- console.error('Error loading files:', err);
651
- showToast(err.message || 'Error loading files', 'error');
652
- if (gridEl) {
653
- gridEl.innerHTML = `
654
- <div class="fm-empty">
655
- <div class="empty-title">Error Loading Files</div>
656
- <div class="empty-desc">${escapeHtml(err.message)}</div>
657
- <button type="button" class="btn btn-primary btn-sm mt-3" onclick="window.mbkbucket.loadFiles(${page}, '${escapeAttr(currentPrefix)}')">Retry</button>
658
- </div>
659
- `;
660
- }
661
- }
662
- }
663
-
664
- // Pagination Renderer
665
- function renderPagination(data) {
666
- const paginationContainer = document.getElementById('paginationContainer');
667
- const paginationList = document.getElementById('paginationList');
668
- if (!paginationContainer || !paginationList) return;
669
-
670
- if (!data.totalPages || data.totalPages <= 1) {
671
- paginationContainer.style.display = 'none';
672
- return;
673
- }
674
-
675
- paginationContainer.style.display = 'flex';
676
- let html = '';
677
-
678
- // Prev
679
- html += `
680
- <li class="page-item ${data.currentPage <= 1 ? 'disabled' : ''}">
681
- <button type="button" class="page-link" data-page="${data.currentPage - 1}">‹</button>
682
- </li>
683
- `;
684
-
685
- // Pages
686
- for (let i = 1; i <= data.totalPages; i++) {
687
- if (i === 1 || i === data.totalPages || (i >= data.currentPage - 2 && i <= data.currentPage + 2)) {
688
- html += `
689
- <li class="page-item ${i === data.currentPage ? 'active' : ''}">
690
- <button type="button" class="page-link" data-page="${i}">${i}</button>
691
- </li>
692
- `;
693
- } else if (i === data.currentPage - 3 || i === data.currentPage + 3) {
694
- html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
695
- }
696
- }
697
-
698
- // Next
699
- html += `
700
- <li class="page-item ${data.currentPage >= data.totalPages ? 'disabled' : ''}">
701
- <button type="button" class="page-link" data-page="${data.currentPage + 1}">›</button>
702
- </li>
703
- `;
704
-
705
- paginationList.innerHTML = html;
706
- }
707
-
708
- // Context Menu
709
- function openContextMenu(x, y, items) {
710
- const menu = document.getElementById('fm-context-menu');
711
- if (!menu) return;
712
-
713
- menu.innerHTML = items.map(item => {
714
- if (item === '-') return '<div class="fm-context-divider"></div>';
715
- return `<button type="button" class="fm-context-item ${item.danger ? 'danger' : ''}" data-action="${item.id}">${item.icon || ''} ${escapeHtml(item.label)}</button>`;
716
- }).join('');
717
-
718
- menu.style.display = 'block';
719
-
720
- const menuWidth = 180;
721
- const menuHeight = menu.offsetHeight || 150;
722
- const posX = Math.min(x, window.innerWidth - menuWidth - 10);
723
- const posY = Math.min(y, window.innerHeight - menuHeight - 10);
724
-
725
- menu.style.left = `${posX}px`;
726
- menu.style.top = `${posY}px`;
727
-
728
- menu.querySelectorAll('.fm-context-item').forEach(btn => {
729
- btn.addEventListener('click', (e) => {
730
- e.stopPropagation();
731
- const actionId = btn.dataset.action;
732
- const targetItem = items.find(it => it.id === actionId);
733
- menu.style.display = 'none';
734
- if (targetItem && typeof targetItem.action === 'function') {
735
- targetItem.action();
736
- }
737
- });
738
- });
739
- }
740
-
741
- function closeContextMenu() {
742
- const menu = document.getElementById('fm-context-menu');
743
- if (menu) menu.style.display = 'none';
744
- }
745
-
746
- // Actions: Star, Download, Preview, Delete, New Folder
747
- function toggleStar(key) {
748
- if (starredFiles.has(key)) {
749
- starredFiles.delete(key);
750
- showToast('Removed from Starred');
751
- } else {
752
- starredFiles.add(key);
753
- showToast('Added to Starred', 'success');
754
- }
755
- localStorage.setItem('mbk_starred_keys', JSON.stringify([...starredFiles]));
756
- renderTree();
757
- renderFiles();
758
- }
759
-
760
- async function downloadFile(key) {
761
- const fileName = getBaseName(key);
762
- const downloadUrl = getDownloadUrl(key);
763
- showToast(`Downloading ${fileName}...`);
764
- try {
765
- const response = await fetch(downloadUrl);
766
- if (!response.ok) throw new Error('Download failed');
767
- const blob = await response.blob();
768
- const url = URL.createObjectURL(blob);
769
- const a = document.createElement('a');
770
- a.href = url;
771
- a.download = fileName;
772
- document.body.appendChild(a);
773
- a.click();
774
- document.body.removeChild(a);
775
- setTimeout(() => URL.revokeObjectURL(url), 1000);
776
- } catch (err) {
777
- showToast(err.message, 'error');
778
- }
779
- }
780
-
781
- function copyShareLink(key) {
782
- const viewUrl = window.location.origin + getViewUrl(key);
783
- navigator.clipboard.writeText(viewUrl).then(() => {
784
- showToast('Link copied to clipboard', 'success');
785
- }).catch(() => {
786
- showToast('Failed to copy link', 'error');
787
- });
788
- }
789
-
790
- // Preview Modal
791
- function openPreview(key) {
792
- const modal = document.getElementById('previewModal');
793
- const titleEl = document.getElementById('previewFileName');
794
- const contentEl = document.getElementById('previewContent');
795
- const openBtn = document.getElementById('previewOpenBtn');
796
- const downloadBtn = document.getElementById('previewDownloadBtn');
797
-
798
- if (!modal || !contentEl) return;
799
-
800
- const fileName = getBaseName(key);
801
- const viewUrl = getViewUrl(key);
802
- const downloadUrl = getDownloadUrl(key);
803
-
804
- if (titleEl) titleEl.textContent = fileName;
805
- if (openBtn) openBtn.href = viewUrl;
806
- if (downloadBtn) downloadBtn.href = downloadUrl;
807
-
808
- contentEl.innerHTML = '<div class="empty-preview"><div class="spinner-border text-light"></div><div class="mt-2 text-light">Loading preview...</div></div>';
809
- modal.classList.add('show');
810
-
811
- if (isImage(fileName)) {
812
- contentEl.innerHTML = `<img src="${viewUrl}" alt="${escapeAttr(fileName)}">`;
813
- } else if (isVideo(fileName)) {
814
- contentEl.innerHTML = `<video controls autoplay class="w-100" style="max-height: 70vh;"><source src="${viewUrl}"></video>`;
815
- } else if (isAudio(fileName)) {
816
- contentEl.innerHTML = `<div class="p-4 text-center"><audio controls autoplay class="w-100"><source src="${viewUrl}"></audio></div>`;
817
- } else if (isPdf(fileName)) {
818
- contentEl.innerHTML = `<iframe src="${viewUrl}" class="preview-iframe"></iframe>`;
819
- } else if (isTextOrCode(fileName)) {
820
- fetch(viewUrl).then(res => res.text()).then(text => {
821
- contentEl.innerHTML = `<pre class="preview-code-block"><code>${escapeHtml(text)}</code></pre>`;
822
- }).catch(err => {
823
- contentEl.innerHTML = `<div class="p-4 text-danger text-center">Failed to load text preview: ${escapeHtml(err.message)}</div>`;
824
- });
825
- } else {
826
- contentEl.innerHTML = `
827
- <div class="p-5 text-center text-light">
828
- <h5>No inline preview available for this file type.</h5>
829
- <p class="text-muted small">You can open it in a new tab or download it directly.</p>
830
- </div>
831
- `;
832
- }
833
- }
834
-
835
- window.closePreviewModal = function () {
836
- const modal = document.getElementById('previewModal');
837
- if (modal) modal.classList.remove('show');
838
- };
839
-
840
- // Delete Modal
841
- function promptDelete(key, isFolder = false) {
842
- const modal = document.getElementById('deleteModal');
843
- const deleteFileName = document.getElementById('deleteFileName');
844
- const deleteKey = document.getElementById('deleteKey');
845
- const deleteIsFolder = document.getElementById('deleteIsFolder');
846
- const deleteConfirmLabel = document.getElementById('deleteConfirmLabel');
847
-
848
- if (!modal) return;
849
-
850
- if (deleteFileName) deleteFileName.textContent = key;
851
- if (deleteKey) deleteKey.value = key;
852
- if (deleteIsFolder) deleteIsFolder.value = isFolder ? 'true' : '';
853
- if (deleteConfirmLabel) deleteConfirmLabel.textContent = isFolder ? 'Delete Folder' : 'Delete File';
854
-
855
- modal.classList.add('show');
856
- }
857
-
858
- window.closeModal = function () {
859
- const deleteModal = document.getElementById('deleteModal');
860
- if (deleteModal) deleteModal.classList.remove('show');
861
- };
862
-
863
- // New Folder Modal
864
- function openNewFolderModal() {
865
- const modal = document.getElementById('newFolderModal');
866
- const input = document.getElementById('newFolderNameInput');
867
- if (!modal) return;
868
- if (input) input.value = '';
869
- modal.classList.add('show');
870
- if (input) setTimeout(() => input.focus(), 50);
871
- }
872
-
873
- window.closeNewFolderModal = function () {
874
- const modal = document.getElementById('newFolderModal');
875
- if (modal) modal.classList.remove('show');
876
- };
877
-
878
- window.submitCreateFolder = async function () {
879
- const input = document.getElementById('newFolderNameInput');
880
- const name = input ? input.value.trim() : '';
881
- if (!name) {
882
- showToast('Please enter a folder name', 'warning');
883
- return;
884
- }
885
-
886
- try {
887
- const response = await fetch(withBucketUrl('/mbkbucket/create-folder'), {
888
- method: 'POST',
889
- headers: { 'Content-Type': 'application/json' },
890
- body: JSON.stringify({ folderName: name, prefix: currentPrefix })
891
- });
892
- const data = await response.json();
893
- if (!data.success) throw new Error(data.error || 'Failed to create folder');
894
-
895
- closeNewFolderModal();
896
- showToast(`Folder "${name}" created`, 'success');
897
- loadFiles(1, currentPrefix);
898
- } catch (err) {
899
- showToast(err.message, 'error');
900
- }
901
- };
902
-
903
- // Upload Modal & Flow
904
- function openUploadModal() {
905
- const modal = document.getElementById('uploadModal');
906
- const prefixInput = document.getElementById('uploadPrefixInput');
907
- if (prefixInput) prefixInput.value = currentPrefix;
908
- if (modal) modal.classList.add('show');
909
- }
910
-
911
- window.closeUploadModal = function () {
912
- const modal = document.getElementById('uploadModal');
913
- if (modal) modal.classList.remove('show');
914
- };
915
-
916
- function updateUploadQueueUI() {
917
- const queueEl = document.getElementById('uploadQueue');
918
- const clearBtn = document.getElementById('clearQueueBtn');
919
- if (!queueEl) return;
920
-
921
- if (uploadQueue.length === 0) {
922
- queueEl.innerHTML = '';
923
- if (clearBtn) clearBtn.style.display = 'none';
924
- return;
925
- }
926
-
927
- if (clearBtn) clearBtn.style.display = 'inline-flex';
928
- queueEl.innerHTML = uploadQueue.map((file, idx) => `
929
- <div class="upload-queue-item">
930
- <div class="d-flex align-items-center gap-2">
931
- <span class="font-medium">${escapeHtml(file.name)}</span>
932
- <span class="upload-queue-meta">${formatFileSize(file.size)}</span>
933
- </div>
934
- <button type="button" class="btn-close" data-remove-queue="${idx}" style="font-size: 14px;">&times;</button>
935
- </div>
936
- `).join('');
937
- }
938
-
939
- async function processUploads() {
940
- if (isUploading || uploadQueue.length === 0) return;
941
- isUploading = true;
942
-
943
- const progressWrap = document.getElementById('uploadProgress');
944
- const progressBar = document.getElementById('progressBar');
945
- const percentEl = document.getElementById('uploadPercent');
946
- const statusLabel = document.getElementById('uploadStatusLabel');
947
- const startBtn = document.getElementById('startUploadBtn');
948
-
949
- if (progressWrap) progressWrap.style.display = 'block';
950
- if (startBtn) startBtn.disabled = true;
951
-
952
- const totalFiles = uploadQueue.length;
953
- let completed = 0;
954
-
955
- for (let i = 0; i < uploadQueue.length; i++) {
956
- const file = uploadQueue[i];
957
- if (statusLabel) statusLabel.textContent = `Uploading ${file.name} (${i + 1}/${totalFiles})...`;
958
-
959
- const formData = new FormData();
960
- formData.append('file', file);
961
- formData.append('prefix', currentPrefix);
962
-
963
- try {
964
- const response = await fetch(withBucketUrl('/mbkbucket/upload'), {
965
- method: 'POST',
966
- body: formData
967
- });
968
- const result = await response.json();
969
- if (!result.success) throw new Error(result.error || 'Upload failed');
970
- completed++;
971
- } catch (err) {
972
- showToast(`Failed to upload ${file.name}: ${err.message}`, 'error');
973
- }
974
-
975
- const percent = Math.round(((i + 1) / totalFiles) * 100);
976
- if (progressBar) progressBar.style.width = `${percent}%`;
977
- if (percentEl) percentEl.textContent = `${percent}%`;
978
- }
979
-
980
- showToast(`Successfully uploaded ${completed} file(s)`, 'success');
981
- uploadQueue = [];
982
- isUploading = false;
983
- if (startBtn) startBtn.disabled = false;
984
- if (progressWrap) progressWrap.style.display = 'none';
985
- closeUploadModal();
986
- updateUploadQueueUI();
987
- loadFiles(1, currentPrefix);
988
- }
989
-
990
- // Event Listeners Setup
991
- function initEventListeners() {
992
- // Tree clicks
993
- document.getElementById('fm-tree')?.addEventListener('click', (e) => {
994
- const link = e.target.closest('.tree-link');
995
- if (!link) return;
996
- e.preventDefault();
997
- const filter = link.dataset.treeFilter;
998
- const prefix = link.dataset.treePrefix;
999
-
1000
- if (filter && filter !== 'all') {
1001
- currentQuickFilter = filter;
1002
- document.querySelectorAll('.quick-filter-btn').forEach(b => b.classList.remove('active'));
1003
- renderTree();
1004
- renderBreadcrumbs();
1005
- renderFiles();
1006
- } else {
1007
- currentQuickFilter = 'all';
1008
- document.querySelectorAll('.quick-filter-btn').forEach(b => {
1009
- b.classList.toggle('active', b.dataset.filter === 'all');
1010
- });
1011
- loadFiles(1, prefix !== undefined ? prefix : '');
1012
- }
1013
- });
1014
-
1015
- // Breadcrumb clicks
1016
- document.getElementById('fm-breadcrumb')?.addEventListener('click', (e) => {
1017
- const link = e.target.closest('[data-nav-prefix]');
1018
- if (!link) return;
1019
- e.preventDefault();
1020
- currentQuickFilter = 'all';
1021
- loadFiles(1, link.dataset.navPrefix || '');
1022
- });
1023
-
1024
- // Quick filters
1025
- document.querySelectorAll('.quick-filter-btn').forEach(btn => {
1026
- btn.addEventListener('click', () => {
1027
- document.querySelectorAll('.quick-filter-btn').forEach(b => b.classList.remove('active'));
1028
- btn.classList.add('active');
1029
- currentQuickFilter = btn.dataset.filter || 'all';
1030
- renderTree();
1031
- renderBreadcrumbs();
1032
- renderFiles();
1033
- syncStateUrl();
1034
- });
1035
- });
1036
-
1037
- // View mode toggle
1038
- const gridBtn = document.getElementById('view-grid');
1039
- const listBtn = document.getElementById('view-list');
1040
- const thumbsBtn = document.getElementById('view-thumbs');
1041
-
1042
- if (showThumbnails) {
1043
- thumbsBtn?.classList.add('active');
1044
- }
1045
-
1046
- thumbsBtn?.addEventListener('click', () => {
1047
- showThumbnails = !showThumbnails;
1048
- localStorage.setItem('mbk_show_thumbnails', String(showThumbnails));
1049
- thumbsBtn.classList.toggle('active', showThumbnails);
1050
- renderFiles();
1051
- showToast(showThumbnails ? 'Image previews enabled' : 'Fast Mode: Previews disabled (saving tokens & bandwidth)', 'info');
1052
- });
1053
-
1054
- gridBtn?.addEventListener('click', () => {
1055
- currentViewMode = 'grid';
1056
- localStorage.setItem('mbk_view_mode', 'grid');
1057
- gridBtn.classList.add('active');
1058
- listBtn?.classList.remove('active');
1059
- renderFiles();
1060
- syncStateUrl();
1061
- });
1062
-
1063
- listBtn?.addEventListener('click', () => {
1064
- currentViewMode = 'list';
1065
- localStorage.setItem('mbk_view_mode', 'list');
1066
- listBtn.classList.add('active');
1067
- gridBtn?.classList.remove('active');
1068
- renderFiles();
1069
- syncStateUrl();
1070
- });
1071
-
1072
- // Search input
1073
- const searchInput = document.getElementById('searchInput');
1074
- const clearSearchBtn = document.getElementById('searchClearBtn');
1075
-
1076
- let searchTimer = null;
1077
- searchInput?.addEventListener('input', (e) => {
1078
- const val = e.target.value;
1079
- if (clearSearchBtn) clearSearchBtn.style.display = val ? 'block' : 'none';
1080
-
1081
- clearTimeout(searchTimer);
1082
- searchTimer = setTimeout(() => {
1083
- currentSearch = val.trim();
1084
- renderFiles();
1085
- }, 250);
1086
- });
1087
-
1088
- clearSearchBtn?.addEventListener('click', () => {
1089
- if (searchInput) searchInput.value = '';
1090
- clearSearchBtn.style.display = 'none';
1091
- currentSearch = '';
1092
- renderFiles();
1093
- });
1094
-
1095
- // Grid Item Interactions (Click, Double Click, Star, Context Menu)
1096
- document.getElementById('fm-grid')?.addEventListener('click', (e) => {
1097
- // Star click
1098
- const starBtn = e.target.closest('[data-star-key]');
1099
- if (starBtn) {
1100
- e.stopPropagation();
1101
- toggleStar(starBtn.dataset.starKey);
1102
- return;
1103
- }
1104
-
1105
- // Menu click
1106
- const menuBtn = e.target.closest('.fm-item-menu');
1107
- if (menuBtn) {
1108
- e.stopPropagation();
1109
- const rect = menuBtn.getBoundingClientRect();
1110
- const fileKey = menuBtn.dataset.menuFile;
1111
- const folderPath = menuBtn.dataset.menuFolder;
1112
-
1113
- if (fileKey) {
1114
- const isStarred = starredFiles.has(fileKey);
1115
- openContextMenu(rect.left, rect.bottom + 4, [
1116
- { id: 'view', label: 'Preview', icon: '<i class="fas fa-eye"></i>', action: () => openPreview(fileKey) },
1117
- { id: 'download', label: 'Download', icon: '<i class="fas fa-download"></i>', action: () => downloadFile(fileKey) },
1118
- { id: 'share', label: 'Copy link', icon: '<i class="fas fa-link"></i>', action: () => copyShareLink(fileKey) },
1119
- { id: 'star', label: isStarred ? 'Unstar' : 'Star', icon: '<i class="fas fa-star"></i>', action: () => toggleStar(fileKey) },
1120
- '-',
1121
- { id: 'delete', label: 'Delete', icon: '<i class="fas fa-trash"></i>', danger: true, action: () => promptDelete(fileKey, false) }
1122
- ]);
1123
- } else if (folderPath) {
1124
- openContextMenu(rect.left, rect.bottom + 4, [
1125
- { id: 'open', label: 'Open Folder', icon: '<i class="fas fa-folder-open"></i>', action: () => loadFiles(1, folderPath) },
1126
- '-',
1127
- { id: 'delete', label: 'Delete Folder', icon: '<i class="fas fa-trash"></i>', danger: true, action: () => promptDelete(folderPath + '/', true) }
1128
- ]);
1129
- }
1130
- return;
1131
- }
1132
-
1133
- // Folder click to navigate
1134
- const folderItem = e.target.closest('[data-type="folder"]');
1135
- if (folderItem) {
1136
- e.stopPropagation();
1137
- loadFiles(1, folderItem.dataset.folderPath);
1138
- return;
1139
- }
1140
-
1141
- // File click to preview
1142
- const fileItem = e.target.closest('[data-type="file"]');
1143
- if (fileItem && !e.target.closest('.file-select-cb')) {
1144
- e.stopPropagation();
1145
- openPreview(fileItem.dataset.key);
1146
- }
1147
- });
1148
-
1149
- // Bucket Selector
1150
- document.getElementById('bucketSelector')?.addEventListener('change', (e) => {
1151
- const newBucket = e.target.value;
1152
- if (!newBucket) return;
1153
- activeBucket = newBucket;
1154
- loadFiles(1, '');
1155
- });
1156
-
1157
- // Top action buttons & Modals
1158
- document.querySelectorAll('[data-action="upload-files"]').forEach(btn => {
1159
- btn.addEventListener('click', openUploadModal);
1160
- });
1161
-
1162
- document.querySelectorAll('[data-action="create-folder"]').forEach(btn => {
1163
- btn.addEventListener('click', openNewFolderModal);
1164
- });
1165
-
1166
- // Drag and Drop Upload Zone
1167
- const dropZone = document.getElementById('dropZone');
1168
- const fileInput = document.getElementById('fileInput');
1169
-
1170
- dropZone?.addEventListener('click', () => fileInput?.click());
1171
-
1172
- dropZone?.addEventListener('dragover', (e) => {
1173
- e.preventDefault();
1174
- dropZone.classList.add('dragover');
1175
- });
1176
-
1177
- dropZone?.addEventListener('dragleave', () => {
1178
- dropZone.classList.remove('dragover');
1179
- });
1180
-
1181
- dropZone?.addEventListener('drop', (e) => {
1182
- e.preventDefault();
1183
- dropZone.classList.remove('dragover');
1184
- if (e.dataTransfer.files?.length) {
1185
- uploadQueue.push(...Array.from(e.dataTransfer.files));
1186
- updateUploadQueueUI();
1187
- }
1188
- });
1189
-
1190
- fileInput?.addEventListener('change', (e) => {
1191
- if (e.target.files?.length) {
1192
- uploadQueue.push(...Array.from(e.target.files));
1193
- updateUploadQueueUI();
1194
- }
1195
- });
1196
-
1197
- document.getElementById('uploadQueue')?.addEventListener('click', (e) => {
1198
- const removeBtn = e.target.closest('[data-remove-queue]');
1199
- if (removeBtn) {
1200
- const idx = parseInt(removeBtn.dataset.removeQueue, 10);
1201
- uploadQueue.splice(idx, 1);
1202
- updateUploadQueueUI();
1203
- }
1204
- });
1205
-
1206
- document.getElementById('clearQueueBtn')?.addEventListener('click', () => {
1207
- uploadQueue = [];
1208
- updateUploadQueueUI();
1209
- });
1210
-
1211
- document.getElementById('startUploadBtn')?.addEventListener('click', processUploads);
1212
-
1213
- // Fullscreen Preview Toggle
1214
- document.getElementById('previewFullscreenBtn')?.addEventListener('click', () => {
1215
- document.querySelector('.preview-modal-dialog')?.classList.toggle('is-fullscreen');
1216
- });
1217
-
1218
- // Pagination clicks
1219
- document.getElementById('paginationList')?.addEventListener('click', (e) => {
1220
- const link = e.target.closest('[data-page]');
1221
- if (!link) return;
1222
- const page = parseInt(link.dataset.page, 10);
1223
- if (!isNaN(page) && page > 0) {
1224
- loadFiles(page, currentPrefix, currentSearch);
1225
- }
1226
- });
1227
-
1228
- // Global click to close context menu
1229
- document.addEventListener('click', closeContextMenu);
1230
-
1231
- // Handle delete form submission
1232
- const deleteForm = document.getElementById('deleteForm');
1233
- deleteForm?.addEventListener('submit', async (e) => {
1234
- e.preventDefault();
1235
- const formData = new FormData(deleteForm);
1236
- const key = formData.get('key');
1237
- const isFolder = formData.get('folder') === 'true';
1238
-
1239
- try {
1240
- const response = await fetch(withBucketUrl('/mbkbucket/delete'), {
1241
- method: 'POST',
1242
- headers: { 'Content-Type': 'application/json' },
1243
- body: JSON.stringify({ key, isFolder, prefix: currentPrefix })
1244
- });
1245
- const data = await response.json();
1246
- if (!data.success) throw new Error(data.error || 'Delete failed');
1247
-
1248
- closeModal();
1249
- showToast(`Successfully deleted ${key}`, 'success');
1250
- loadFiles(currentPage, currentPrefix);
1251
- } catch (err) {
1252
- showToast(err.message, 'error');
1253
- }
1254
- });
1255
- }
1256
-
1257
- // Initialization
1258
- document.addEventListener('DOMContentLoaded', () => {
1259
- // Sync view mode buttons
1260
- if (currentViewMode === 'list') {
1261
- document.getElementById('view-list')?.classList.add('active');
1262
- document.getElementById('view-grid')?.classList.remove('active');
1263
- }
1264
- if (showThumbnails) {
1265
- document.getElementById('view-thumbs')?.classList.add('active');
1266
- }
1267
-
1268
- initEventListeners();
1269
-
1270
- // Initial load with URL params
1271
- const initialFolder = urlParams.get('folder') || '';
1272
- const initialPage = parseInt(urlParams.get('page') || '1', 10);
1273
- const initialSearch = urlParams.get('search') || '';
1274
-
1275
- loadFiles(initialPage, initialFolder, initialSearch);
1276
- });
1277
-
1278
- // Expose global methods if needed
1279
- window.mbkbucket = {
1280
- loadFiles,
1281
- openPreview,
1282
- downloadFile,
1283
- showToast
1284
- };
1285
-
1286
- })();
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 showThumbnails = localStorage.getItem('mbk_show_thumbnails') === 'true'; // Fast Mode by default
24
+ let thumbObserver = null;
25
+ let pageFiles = [];
26
+ let pageFolders = [];
27
+ let currentApiPrefix = '';
28
+ let currentNextToken = null;
29
+ let selectedFiles = new Set();
30
+ let starredFiles = new Set(JSON.parse(localStorage.getItem('mbk_starred_keys') || '[]'));
31
+ let currentPreviewKey = '';
32
+
33
+ // Upload State
34
+ let uploadQueue = [];
35
+ let isUploading = false;
36
+
37
+ // DOM Elements & Config
38
+ const bucketAppData = document.getElementById('bucketAppData');
39
+ const bucketAppName = bucketAppData?.dataset.appName || '';
40
+ const isPublicViewEnabled = bucketAppData?.dataset.publicViewEnabled === 'true';
41
+ const urlParams = new URLSearchParams(window.location.search);
42
+ const selectedBucket = urlParams.get('bucket') || '';
43
+ let activeBucket = selectedBucket;
44
+ const app = String(bucketAppName || '').toLowerCase();
45
+ const isRootApp = app === 'portal' || app === 'mbkbucket';
46
+ const rootLabel = isRootApp ? 'My Drive' : (bucketAppName || 'Bucket Drive');
47
+
48
+ // File categories & extensions
49
+ const MEDIA_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'mp4', 'webm', 'ogg', 'avi', 'mov', 'mp3', 'wav', 'flac', 'aac', 'm4a']);
50
+ const DOC_EXTS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'rtf']);
51
+ 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', 'env']);
52
+
53
+ const FILE_TYPES = {
54
+ pdf: { color: 'var(--red)', bg: 'var(--red-lt)', label: 'PDF' },
55
+ doc: { color: 'var(--blue)', bg: 'var(--blue-lt)', label: 'DOC' },
56
+ docx: { color: 'var(--blue)', bg: 'var(--blue-lt)', label: 'DOC' },
57
+ xls: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'XLS' },
58
+ xlsx: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'XLS' },
59
+ csv: { color: 'var(--green)', bg: 'var(--green-lt)', label: 'CSV' },
60
+ ppt: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'PPT' },
61
+ pptx: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'PPT' },
62
+ txt: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'TXT' },
63
+ md: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'MD' },
64
+ jpg: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
65
+ jpeg: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
66
+ png: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
67
+ gif: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'GIF' },
68
+ webp: { color: 'var(--yellow)', bg: 'var(--yellow-lt)', label: 'IMG' },
69
+ svg: { color: 'var(--orange)', bg: 'rgba(247, 103, 7, 0.1)', label: 'SVG' },
70
+ mp4: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
71
+ webm: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
72
+ mov: { color: 'var(--azure)', bg: 'var(--azure-lt)', label: 'VID' },
73
+ mp3: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'AUD' },
74
+ wav: { color: 'var(--purple)', bg: 'var(--purple-lt)', label: 'AUD' },
75
+ zip: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'ZIP' },
76
+ tar: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'TAR' },
77
+ gz: { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: 'GZ' },
78
+ code: { color: 'var(--cyan)', bg: 'var(--cyan-lt)', label: 'CODE' }
79
+ };
80
+
81
+ // SVGs
82
+ const ICONS = {
83
+ 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>',
84
+ 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>',
85
+ 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>',
86
+ 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>',
87
+ 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>',
88
+ 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>',
89
+ 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>',
90
+ upload: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 11v3H2v-3"/><path d="M11 6L8 3 5 6"/><line x1="8" y1="3" x2="8" y2="11"/></svg>',
91
+ download: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 11v3H2v-3"/><path d="M5 8l3 3 3-3"/><line x1="8" y1="2" x2="8" y2="11"/></svg>',
92
+ 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>'
93
+ };
94
+
95
+ // Helper Functions
96
+ function escapeHtml(str) {
97
+ return String(str || '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
98
+ }
99
+
100
+ function escapeAttr(str) {
101
+ return escapeHtml(str);
102
+ }
103
+
104
+ function getBaseName(path) {
105
+ const val = String(path || '').replace(/\/+$/, '');
106
+ const lastSlash = val.lastIndexOf('/');
107
+ return lastSlash === -1 ? val : val.substring(lastSlash + 1);
108
+ }
109
+
110
+ function getFileExt(fileName) {
111
+ const name = getBaseName(fileName);
112
+ const dot = name.lastIndexOf('.');
113
+ return dot > -1 ? name.slice(dot + 1).toLowerCase() : '';
114
+ }
115
+
116
+ function formatFileSize(bytes) {
117
+ if (!bytes || bytes === 0) return '0 B';
118
+ const k = 1024;
119
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
120
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
121
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
122
+ }
123
+
124
+ function formatDate(dateStr) {
125
+ if (!dateStr) return '—';
126
+ const d = new Date(dateStr);
127
+ if (isNaN(d.getTime())) return String(dateStr);
128
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
129
+ }
130
+
131
+ function withBucketUrl(url) {
132
+ if (!activeBucket) return url;
133
+ const sep = url.includes('?') ? '&' : '?';
134
+ return `${url}${sep}bucket=${encodeURIComponent(activeBucket)}`;
135
+ }
136
+
137
+ function getViewUrl(key) {
138
+ return withBucketUrl(`/mbkbucket/view/${encodeURIComponent(key)}`);
139
+ }
140
+
141
+ function getPublicViewUrl(key) {
142
+ return withBucketUrl(`/mbkbucket/p_view/${encodeURIComponent(key)}`);
143
+ }
144
+
145
+ function getDownloadUrl(key) {
146
+ return withBucketUrl(`/mbkbucket/download/${encodeURIComponent(key)}`);
147
+ }
148
+
149
+ function isImage(fileName) {
150
+ const ext = getFileExt(fileName);
151
+ return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext);
152
+ }
153
+
154
+ function isVideo(fileName) {
155
+ const ext = getFileExt(fileName);
156
+ return ['mp4', 'webm', 'ogg', 'mov', 'mkv'].includes(ext);
157
+ }
158
+
159
+ function isAudio(fileName) {
160
+ const ext = getFileExt(fileName);
161
+ return ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext);
162
+ }
163
+
164
+ function isTextOrCode(fileName) {
165
+ const ext = getFileExt(fileName);
166
+ return CODE_EXTS.has(ext) || ['txt', 'md', 'csv', 'log', 'json', 'xml', 'env'].includes(ext);
167
+ }
168
+
169
+ function isMarkdown(fileName) {
170
+ return getFileExt(fileName) === 'md';
171
+ }
172
+
173
+ function isPdf(fileName) {
174
+ return getFileExt(fileName) === 'pdf';
175
+ }
176
+
177
+ function getFileTypeConfig(fileName) {
178
+ const ext = getFileExt(fileName);
179
+ if (FILE_TYPES[ext]) return FILE_TYPES[ext];
180
+ if (CODE_EXTS.has(ext)) return FILE_TYPES.code;
181
+ return { color: 'var(--text-secondary)', bg: 'var(--bg-surface-secondary)', label: ext.toUpperCase().slice(0, 4) || 'FILE' };
182
+ }
183
+
184
+ function stripApiPrefixFromPath(path, apiPrefix = '') {
185
+ const normalizedPath = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
186
+ const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
187
+ if (!normalizedApiPrefix) return normalizedPath;
188
+ if (normalizedPath === normalizedApiPrefix || normalizedPath === `${normalizedApiPrefix}/`) return '';
189
+ if (normalizedPath.startsWith(`${normalizedApiPrefix}/`)) {
190
+ return normalizedPath.slice(normalizedApiPrefix.length + 1);
191
+ }
192
+ return normalizedPath;
193
+ }
194
+
195
+ function toApiFolderPath(prefix = '', apiPrefix = '') {
196
+ const uiPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
197
+ const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
198
+ if (!uiPrefix) return '';
199
+ if (!normalizedApiPrefix) return uiPrefix;
200
+ if (uiPrefix === normalizedApiPrefix || uiPrefix.startsWith(`${normalizedApiPrefix}/`)) return uiPrefix;
201
+ return `${normalizedApiPrefix}/${uiPrefix}`;
202
+ }
203
+
204
+ function extractFoldersAtCurrentLevel(files, prefix = '') {
205
+ const folderSet = new Set();
206
+ const cleanPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
207
+ const prefixOffset = cleanPrefix ? cleanPrefix.length + 1 : 0;
208
+
209
+ (files || []).forEach(file => {
210
+ const key = String(file.Key || '').replace(/^\/+/, '');
211
+ if (cleanPrefix && !key.startsWith(cleanPrefix + '/')) return;
212
+ const relPath = cleanPrefix ? key.substring(prefixOffset) : key;
213
+ const slashIdx = relPath.indexOf('/');
214
+ if (slashIdx !== -1) {
215
+ const folder = relPath.substring(0, slashIdx);
216
+ folderSet.add(cleanPrefix ? `${cleanPrefix}/${folder}` : folder);
217
+ }
218
+ });
219
+
220
+ return Array.from(folderSet).sort();
221
+ }
222
+
223
+ function renderMarkdownToHtml(mdText) {
224
+ let html = escapeHtml(mdText);
225
+ html = html.replace(/```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g, (_m, _lang, code) => `<pre><code>${code}</code></pre>`);
226
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
227
+ html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
228
+ html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
229
+ html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
230
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
231
+ html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
232
+ html = html.replace(/^\> (.*$)/gim, '<blockquote>$1</blockquote>');
233
+ html = html.replace(/^\s*[\-\*] (.*$)/gim, '<li>$1</li>');
234
+ html = html.replace(/(<li>.*<\/li>)/gim, '<ul>$1</ul>');
235
+ html = html.replace(/\n\n/g, '<br><br>');
236
+ return `<div class="rendered-markdown">${html}</div>`;
237
+ }
238
+
239
+ function syncStateUrl() {
240
+ try {
241
+ const parts = [];
242
+ if (activeBucket) parts.push(`bucket=${encodeURIComponent(activeBucket)}`);
243
+ if (currentPrefix) {
244
+ const folderPath = toApiFolderPath(currentPrefix, currentApiPrefix);
245
+ parts.push(`folder=${encodeURIComponent(folderPath).replace(/%2F/g, '/')}`);
246
+ }
247
+ if (currentPage > 1) parts.push(`page=${currentPage}`);
248
+ if (currentSearch) parts.push(`search=${encodeURIComponent(currentSearch)}`);
249
+ if (currentSort !== 'name') parts.push(`sort=${encodeURIComponent(currentSort)}`);
250
+ if (currentOrder !== 'asc') parts.push(`order=${encodeURIComponent(currentOrder)}`);
251
+ if (currentViewMode !== 'grid') parts.push(`view=${encodeURIComponent(currentViewMode)}`);
252
+ if (currentQuickFilter !== 'all') parts.push(`filter=${encodeURIComponent(currentQuickFilter)}`);
253
+
254
+ const newUrl = '/mbkbucket' + (parts.length ? '?' + parts.join('&') : '');
255
+ window.history.replaceState(null, '', newUrl);
256
+ } catch (e) {
257
+ console.warn('History update failed', e);
258
+ }
259
+ }
260
+
261
+ // Toast System
262
+ function showToast(message, type = 'success') {
263
+ const container = document.getElementById('toast-container');
264
+ if (!container) return;
265
+
266
+ const toast = document.createElement('div');
267
+ toast.className = `toast-notification ${type}`;
268
+ toast.innerHTML = `
269
+ <span>${escapeHtml(message)}</span>
270
+ <button type="button" class="toast-close-btn">&times;</button>
271
+ `;
272
+
273
+ toast.querySelector('.toast-close-btn').addEventListener('click', () => {
274
+ toast.remove();
275
+ });
276
+
277
+ container.appendChild(toast);
278
+
279
+ setTimeout(() => {
280
+ toast.style.opacity = '0';
281
+ toast.style.transform = 'translateY(10px)';
282
+ setTimeout(() => toast.remove(), 250);
283
+ }, 4000);
284
+ }
285
+
286
+ // Live Health Check
287
+ async function checkConnectionHealth(silent = true) {
288
+ const healthBadge = document.getElementById('topHealthBadge');
289
+ const healthText = document.getElementById('topHealthText');
290
+ const latencyTag = document.getElementById('storageLatency');
291
+
292
+ if (healthBadge) {
293
+ healthBadge.className = 'health-status-badge checking';
294
+ if (healthText) healthText.textContent = 'Checking...';
295
+ }
296
+
297
+ const startTime = Date.now();
298
+ try {
299
+ const response = await fetch(withBucketUrl('/mbkbucket/api/health'));
300
+ const data = await response.json();
301
+ const latency = data.responseTime || (Date.now() - startTime);
302
+
303
+ if (data.status === 'healthy' || data.success) {
304
+ if (healthBadge) {
305
+ healthBadge.className = 'health-status-badge healthy';
306
+ if (healthText) healthText.textContent = 'Healthy';
307
+ }
308
+ if (latencyTag) latencyTag.textContent = `${latency}ms`;
309
+ if (!silent) showToast(`Storage connection healthy (${latency}ms)`, 'success');
310
+ } else {
311
+ if (healthBadge) {
312
+ healthBadge.className = 'health-status-badge unhealthy';
313
+ if (healthText) healthText.textContent = 'Unhealthy';
314
+ }
315
+ if (latencyTag) latencyTag.textContent = 'err';
316
+ if (!silent) showToast(`Connection error: ${data.error || 'Check configuration'}`, 'error');
317
+ }
318
+ } catch (err) {
319
+ if (healthBadge) {
320
+ healthBadge.className = 'health-status-badge unhealthy';
321
+ if (healthText) healthText.textContent = 'Offline';
322
+ }
323
+ if (latencyTag) latencyTag.textContent = 'offline';
324
+ if (!silent) showToast(`Connection test failed: ${err.message}`, 'error');
325
+ }
326
+ }
327
+
328
+ // Batch Selection System
329
+ function updateBatchBar() {
330
+ const bar = document.getElementById('batchActionBar');
331
+ const countEl = document.getElementById('batchSelectedCount');
332
+ if (!bar) return;
333
+
334
+ const count = selectedFiles.size;
335
+ if (count > 0) {
336
+ bar.style.display = 'block';
337
+ if (countEl) countEl.textContent = count;
338
+ } else {
339
+ bar.style.display = 'none';
340
+ }
341
+ }
342
+
343
+ function clearBatchSelection() {
344
+ selectedFiles.clear();
345
+ document.querySelectorAll('.file-select-cb').forEach(cb => { cb.checked = false; });
346
+ document.querySelectorAll('.fm-item.is-selected').forEach(item => { item.classList.remove('is-selected'); });
347
+ updateBatchBar();
348
+ }
349
+
350
+ // Tree Renderer
351
+ function renderTree() {
352
+ const treeEl = document.getElementById('fm-tree');
353
+ if (!treeEl) return;
354
+
355
+ const allFolders = pageFolders || [];
356
+ const isStarredActive = currentQuickFilter === 'starred';
357
+ const isRootActive = !currentPrefix && currentQuickFilter === 'all';
358
+
359
+ let treeHtml = `
360
+ <div class="tree-group">
361
+ <button type="button" class="tree-link ${isRootActive ? 'active' : ''}" data-tree-prefix="" data-tree-filter="all">
362
+ ${ICONS.drive}
363
+ <span>${escapeHtml(rootLabel)}</span>
364
+ </button>
365
+ </div>
366
+ `;
367
+
368
+ // Folder hierarchy
369
+ if (allFolders.length > 0) {
370
+ treeHtml += `
371
+ <div class="tree-group">
372
+ <div class="tree-subs">
373
+ ${allFolders.map(folder => {
374
+ const cleanFolder = String(folder || '').replace(/\/+$/, '');
375
+ const folderName = getBaseName(cleanFolder);
376
+ const folderPathForAction = currentPrefix && !cleanFolder.startsWith(currentPrefix + '/') && cleanFolder !== currentPrefix
377
+ ? `${currentPrefix}/${cleanFolder}`
378
+ : cleanFolder;
379
+ const isFolderActive = currentPrefix === folderPathForAction;
380
+ return `
381
+ <button type="button" class="tree-link tree-sub ${isFolderActive ? 'active' : ''}" data-tree-prefix="${escapeAttr(folderPathForAction)}" data-tree-filter="all">
382
+ ${ICONS.folderSm}
383
+ <span>${escapeHtml(folderName)}</span>
384
+ </button>
385
+ `;
386
+ }).join('')}
387
+ </div>
388
+ </div>
389
+ `;
390
+ }
391
+
392
+ // Quick navigation filters
393
+ treeHtml += `
394
+ <div class="tree-group" style="margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border-color-light);">
395
+ <button type="button" class="tree-link ${isStarredActive ? 'active' : ''}" data-tree-filter="starred">
396
+ ${ICONS.starred}
397
+ <span>Starred</span>
398
+ ${starredFiles.size > 0 ? `<span class="tree-badge">${starredFiles.size}</span>` : ''}
399
+ </button>
400
+ <button type="button" class="tree-link ${currentQuickFilter === 'media' ? 'active' : ''}" data-tree-filter="media">
401
+ ${ICONS.media}
402
+ <span>Media</span>
403
+ </button>
404
+ <button type="button" class="tree-link ${currentQuickFilter === 'docs' ? 'active' : ''}" data-tree-filter="docs">
405
+ ${ICONS.docs}
406
+ <span>Documents</span>
407
+ </button>
408
+ <button type="button" class="tree-link ${currentQuickFilter === 'code' ? 'active' : ''}" data-tree-filter="code">
409
+ ${ICONS.code}
410
+ <span>Code</span>
411
+ </button>
412
+ </div>
413
+ `;
414
+
415
+ treeEl.innerHTML = treeHtml;
416
+ }
417
+
418
+ // Breadcrumbs Renderer
419
+ function renderBreadcrumbs() {
420
+ const bcEl = document.getElementById('fm-breadcrumb');
421
+ if (!bcEl) return;
422
+
423
+ if (!currentPrefix && currentQuickFilter === 'all') {
424
+ bcEl.innerHTML = `<button type="button" class="bc-link current">${escapeHtml(rootLabel)}</button>`;
425
+ return;
426
+ }
427
+
428
+ if (currentQuickFilter !== 'all') {
429
+ bcEl.innerHTML = `
430
+ <button type="button" class="bc-link" data-nav-prefix="">${escapeHtml(rootLabel)}</button>
431
+ <span class="sep">›</span>
432
+ <button type="button" class="bc-link current">${escapeHtml(currentQuickFilter.charAt(0).toUpperCase() + currentQuickFilter.slice(1))}</button>
433
+ `;
434
+ return;
435
+ }
436
+
437
+ const parts = currentPrefix.split('/').filter(Boolean);
438
+ let accum = '';
439
+ const breadcrumbHtml = [
440
+ `<button type="button" class="bc-link" data-nav-prefix="">${escapeHtml(rootLabel)}</button>`
441
+ ];
442
+
443
+ parts.forEach((part, index) => {
444
+ accum += (accum ? '/' : '') + part;
445
+ const isLast = index === parts.length - 1;
446
+ breadcrumbHtml.push('<span class="sep">›</span>');
447
+ if (isLast) {
448
+ breadcrumbHtml.push(`<button type="button" class="bc-link current">${escapeHtml(part)}</button>`);
449
+ } else {
450
+ breadcrumbHtml.push(`<button type="button" class="bc-link" data-nav-prefix="${escapeAttr(accum)}">${escapeHtml(part)}</button>`);
451
+ }
452
+ });
453
+
454
+ bcEl.innerHTML = breadcrumbHtml.join('');
455
+ }
456
+
457
+ // File Filtering Logic
458
+ function getFilteredFiles() {
459
+ let list = (pageFiles || []).filter(f => !String(f.Key || '').endsWith('/'));
460
+
461
+ if (currentSearch) {
462
+ const q = currentSearch.toLowerCase();
463
+ list = list.filter(f => getBaseName(f.Key).toLowerCase().includes(q));
464
+ }
465
+
466
+ if (currentQuickFilter === 'media') {
467
+ list = list.filter(f => MEDIA_EXTS.has(getFileExt(f.Key)));
468
+ } else if (currentQuickFilter === 'docs') {
469
+ list = list.filter(f => DOC_EXTS.has(getFileExt(f.Key)));
470
+ } else if (currentQuickFilter === 'code') {
471
+ list = list.filter(f => CODE_EXTS.has(getFileExt(f.Key)));
472
+ } else if (currentQuickFilter === 'starred') {
473
+ list = list.filter(f => starredFiles.has(f.Key));
474
+ }
475
+
476
+ return list;
477
+ }
478
+
479
+ function getFilteredFolders() {
480
+ if (currentQuickFilter !== 'all') return [];
481
+ if (currentSearch) {
482
+ const q = currentSearch.toLowerCase();
483
+ return (pageFolders || []).filter(f => getBaseName(f).toLowerCase().includes(q));
484
+ }
485
+ return pageFolders || [];
486
+ }
487
+
488
+ // Main Files & Grid Renderer
489
+ function renderFiles() {
490
+ const gridEl = document.getElementById('fm-grid');
491
+ const emptyEl = document.getElementById('fm-empty');
492
+ if (!gridEl) return;
493
+
494
+ const files = getFilteredFiles();
495
+ const folders = getFilteredFolders();
496
+
497
+ const totalCountEl = document.getElementById('totalFilesCount');
498
+ const filterInfoEl = document.getElementById('filterInfo');
499
+ const totalItems = files.length + folders.length;
500
+
501
+ if (totalCountEl) {
502
+ totalCountEl.textContent = `${totalItems} item${totalItems === 1 ? '' : 's'}`;
503
+ }
504
+ if (filterInfoEl) {
505
+ filterInfoEl.textContent = activeBucket ? `Bucket: ${activeBucket}` : 'Default bucket';
506
+ }
507
+
508
+ if (totalItems === 0) {
509
+ gridEl.innerHTML = '';
510
+ if (emptyEl) {
511
+ emptyEl.hidden = false;
512
+ emptyEl.style.display = 'flex';
513
+ }
514
+ return;
515
+ }
516
+
517
+ if (emptyEl) {
518
+ emptyEl.hidden = true;
519
+ emptyEl.style.display = 'none';
520
+ }
521
+ gridEl.className = `fm-grid view-${currentViewMode}`;
522
+
523
+ let html = '';
524
+
525
+ if (currentViewMode === 'list') {
526
+ html += `
527
+ <div class="fm-list-header">
528
+ <div><input type="checkbox" id="selectAllCb" title="Select All"></div>
529
+ <div></div>
530
+ <div>Name</div>
531
+ <div>Size</div>
532
+ <div>Modified</div>
533
+ <div></div>
534
+ <div></div>
535
+ </div>
536
+ `;
537
+ }
538
+
539
+ // Render Folders
540
+ folders.forEach(folder => {
541
+ const cleanFolder = String(folder || '').replace(/\/+$/, '');
542
+ const folderName = getBaseName(cleanFolder);
543
+ const folderPathForAction = currentPrefix && !cleanFolder.startsWith(currentPrefix + '/') && cleanFolder !== currentPrefix
544
+ ? `${currentPrefix}/${cleanFolder}`
545
+ : cleanFolder;
546
+ const safePath = escapeAttr(folderPathForAction);
547
+ const safeName = escapeHtml(folderName);
548
+
549
+ if (currentViewMode === 'grid') {
550
+ html += `
551
+ <div class="fm-item" data-type="folder" data-folder-path="${safePath}" tabindex="0">
552
+ <div class="fm-item-icon">
553
+ ${ICONS.folder}
554
+ </div>
555
+ <div class="fm-item-details">
556
+ <div class="fm-item-name" title="${safeName}">${safeName}</div>
557
+ <div class="fm-item-meta">Folder</div>
558
+ </div>
559
+ <button type="button" class="fm-item-menu" data-menu-folder="${safePath}" aria-label="Folder options">
560
+ ${ICONS.more}
561
+ </button>
562
+ </div>
563
+ `;
564
+ } else {
565
+ html += `
566
+ <div class="fm-item" data-type="folder" data-folder-path="${safePath}" tabindex="0">
567
+ <div class="fm-item-checkbox"></div>
568
+ <div class="fm-item-icon">
569
+ ${ICONS.folderSm}
570
+ </div>
571
+ <div class="fm-item-name" title="${safeName}">${safeName}</div>
572
+ <div class="fm-item-size fm-item-meta">—</div>
573
+ <div class="fm-item-date fm-item-meta">—</div>
574
+ <div class="fm-star-wrap"></div>
575
+ <button type="button" class="fm-item-menu" data-menu-folder="${safePath}" aria-label="Folder options">
576
+ ${ICONS.more}
577
+ </button>
578
+ </div>
579
+ `;
580
+ }
581
+ });
582
+
583
+ // Render Files
584
+ files.forEach(file => {
585
+ const rawKey = String(file.Key || '');
586
+ const fileName = getBaseName(rawKey);
587
+ const safeKey = escapeAttr(rawKey);
588
+ const safeName = escapeHtml(fileName);
589
+ const isStarred = starredFiles.has(rawKey);
590
+ const isSelected = selectedFiles.has(rawKey);
591
+ const isImg = isImage(fileName);
592
+ const typeCfg = getFileTypeConfig(fileName);
593
+ const formattedSize = file.Size ? formatFileSize(file.Size) : '—';
594
+ const formattedDate = file.LastModified ? formatDate(file.LastModified) : '—';
595
+
596
+ const showImgThumb = isImg && showThumbnails;
597
+ let iconInner = `<span class="ext">${typeCfg.label}</span>`;
598
+ if (showImgThumb) {
599
+ iconInner = `<img data-src="${getViewUrl(rawKey)}" class="fm-item-thumb fm-lazy-thumb" alt="${safeName}" onload="this.classList.add('is-loaded')" onerror="this.outerHTML='<span class=\\'ext\\'>IMG</span>'">`;
600
+ }
601
+
602
+ if (currentViewMode === 'grid') {
603
+ html += `
604
+ <div class="fm-item ${isStarred ? 'starred' : ''} ${isSelected ? 'is-selected' : ''}" data-type="file" data-key="${safeKey}" tabindex="0">
605
+ <div class="fm-item-checkbox-grid">
606
+ <input type="checkbox" class="file-select-cb" data-key="${safeKey}" ${isSelected ? 'checked' : ''} title="Select">
607
+ </div>
608
+ <div class="fm-item-icon" style="${showImgThumb ? '' : `background:${typeCfg.bg};color:${typeCfg.color}`}">
609
+ ${iconInner}
610
+ </div>
611
+ <div class="fm-item-details">
612
+ <div class="fm-item-name" title="${safeName}">${safeName}</div>
613
+ <div class="fm-item-meta">${formattedSize} · ${formattedDate}</div>
614
+ </div>
615
+ <button type="button" class="fm-star ${isStarred ? 'on' : ''}" data-star-key="${safeKey}" aria-label="${isStarred ? 'Unstar' : 'Star'}">
616
+ ${ICONS.starred}
617
+ </button>
618
+ <button type="button" class="fm-item-menu" data-menu-file="${safeKey}" aria-label="File options">
619
+ ${ICONS.more}
620
+ </button>
621
+ </div>
622
+ `;
623
+ } else {
624
+ html += `
625
+ <div class="fm-item ${isStarred ? 'starred' : ''} ${isSelected ? 'is-selected' : ''}" data-type="file" data-key="${safeKey}" tabindex="0">
626
+ <div class="fm-item-checkbox">
627
+ <input type="checkbox" class="file-select-cb" data-key="${safeKey}" ${isSelected ? 'checked' : ''} title="Select">
628
+ </div>
629
+ <div class="fm-item-icon" style="${isImg ? '' : `background:${typeCfg.bg};color:${typeCfg.color}`}">
630
+ <span class="ext">${typeCfg.label}</span>
631
+ </div>
632
+ <div class="fm-item-name" title="${safeName}">${safeName}</div>
633
+ <div class="fm-item-size fm-item-meta">${formattedSize}</div>
634
+ <div class="fm-item-date fm-item-meta">${formattedDate}</div>
635
+ <button type="button" class="fm-star ${isStarred ? 'on' : ''}" data-star-key="${safeKey}" aria-label="${isStarred ? 'Unstar' : 'Star'}">
636
+ ${ICONS.starred}
637
+ </button>
638
+ <button type="button" class="fm-item-menu" data-menu-file="${safeKey}" aria-label="File options">
639
+ ${ICONS.more}
640
+ </button>
641
+ </div>
642
+ `;
643
+ }
644
+ });
645
+
646
+ gridEl.innerHTML = html;
647
+ observeThumbnails();
648
+ updateBatchBar();
649
+ }
650
+
651
+ function observeThumbnails() {
652
+ const lazyImages = document.querySelectorAll('img.fm-lazy-thumb[data-src]');
653
+ if (!lazyImages.length) return;
654
+
655
+ if (!('IntersectionObserver' in window)) {
656
+ lazyImages.forEach(img => {
657
+ if (img.dataset.src) {
658
+ img.src = img.dataset.src;
659
+ img.removeAttribute('data-src');
660
+ }
661
+ });
662
+ return;
663
+ }
664
+
665
+ if (thumbObserver) {
666
+ thumbObserver.disconnect();
667
+ }
668
+
669
+ thumbObserver = new IntersectionObserver((entries, observer) => {
670
+ entries.forEach(entry => {
671
+ if (entry.isIntersecting) {
672
+ const img = entry.target;
673
+ if (img.dataset.src) {
674
+ img.src = img.dataset.src;
675
+ img.removeAttribute('data-src');
676
+ }
677
+ observer.unobserve(img);
678
+ }
679
+ });
680
+ }, {
681
+ rootMargin: '100px 0px',
682
+ threshold: 0.01
683
+ });
684
+
685
+ lazyImages.forEach(img => thumbObserver.observe(img));
686
+ }
687
+
688
+ // Load Files API Call
689
+ async function loadFiles(page = 1, prefix = '', search = '') {
690
+ currentPage = page;
691
+ currentPrefix = String(prefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
692
+ currentSearch = search;
693
+
694
+ syncStateUrl();
695
+
696
+ const gridEl = document.getElementById('fm-grid');
697
+ if (gridEl) {
698
+ gridEl.innerHTML = `
699
+ <div class="fm-skeleton-grid">
700
+ ${[...Array(6)].map(() => `
701
+ <div class="fm-skeleton-card">
702
+ <div class="fm-skeleton-icon"></div>
703
+ <div class="fm-skeleton-line w-75"></div>
704
+ <div class="fm-skeleton-line w-50"></div>
705
+ </div>
706
+ `).join('')}
707
+ </div>
708
+ `;
709
+ }
710
+
711
+ try {
712
+ const searchParam = search ? `&search=${encodeURIComponent(search)}` : '';
713
+ const sortParam = `&sort=${currentSort}&order=${currentOrder}`;
714
+ const recursiveParam = `&recursive=false`;
715
+ const url = withBucketUrl(`/mbkbucket/api/files?page=${page}&prefix=${encodeURIComponent(currentPrefix)}${searchParam}${sortParam}${recursiveParam}`);
716
+
717
+ const response = await fetch(url);
718
+ const data = await response.json();
719
+
720
+ if (!data.success) {
721
+ throw new Error(data.error || 'Failed to load files');
722
+ }
723
+
724
+ currentNextToken = data.nextContinuationToken || null;
725
+ currentApiPrefix = data.prefix || '';
726
+ pageFiles = data.files || [];
727
+
728
+ if (data.mode === 'optimized') {
729
+ pageFolders = (data.folders || []).map(folder => stripApiPrefixFromPath(folder, currentApiPrefix)).filter(Boolean);
730
+ } else {
731
+ pageFolders = currentSearch
732
+ ? []
733
+ : extractFoldersAtCurrentLevel(pageFiles, currentApiPrefix || currentPrefix)
734
+ .map(folder => stripApiPrefixFromPath(folder, currentApiPrefix))
735
+ .filter(Boolean);
736
+ }
737
+
738
+ renderTree();
739
+ renderBreadcrumbs();
740
+ renderFiles();
741
+ renderPagination(data);
742
+ checkConnectionHealth(true);
743
+ } catch (err) {
744
+ console.error('Error loading files:', err);
745
+ showToast(err.message || 'Error loading files', 'error');
746
+ if (gridEl) {
747
+ gridEl.innerHTML = `
748
+ <div class="fm-empty">
749
+ <div class="empty-title">Error Loading Files</div>
750
+ <div class="empty-desc">${escapeHtml(err.message)}</div>
751
+ <button type="button" class="btn btn-primary btn-sm mt-3" onclick="window.mbkbucket.loadFiles(${page}, '${escapeAttr(currentPrefix)}')">Retry</button>
752
+ </div>
753
+ `;
754
+ }
755
+ }
756
+ }
757
+
758
+ // Pagination Renderer
759
+ function renderPagination(data) {
760
+ const paginationContainer = document.getElementById('paginationContainer');
761
+ const paginationList = document.getElementById('paginationList');
762
+ if (!paginationContainer || !paginationList) return;
763
+
764
+ if (!data.totalPages || data.totalPages <= 1) {
765
+ paginationContainer.style.display = 'none';
766
+ return;
767
+ }
768
+
769
+ paginationContainer.style.display = 'flex';
770
+ let html = '';
771
+
772
+ // Prev
773
+ html += `
774
+ <li class="page-item ${data.currentPage <= 1 ? 'disabled' : ''}">
775
+ <button type="button" class="page-link" data-page="${data.currentPage - 1}">‹</button>
776
+ </li>
777
+ `;
778
+
779
+ // Pages
780
+ for (let i = 1; i <= data.totalPages; i++) {
781
+ if (i === 1 || i === data.totalPages || (i >= data.currentPage - 2 && i <= data.currentPage + 2)) {
782
+ html += `
783
+ <li class="page-item ${i === data.currentPage ? 'active' : ''}">
784
+ <button type="button" class="page-link" data-page="${i}">${i}</button>
785
+ </li>
786
+ `;
787
+ } else if (i === data.currentPage - 3 || i === data.currentPage + 3) {
788
+ html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
789
+ }
790
+ }
791
+
792
+ // Next
793
+ html += `
794
+ <li class="page-item ${data.currentPage >= data.totalPages ? 'disabled' : ''}">
795
+ <button type="button" class="page-link" data-page="${data.currentPage + 1}">›</button>
796
+ </li>
797
+ `;
798
+
799
+ paginationList.innerHTML = html;
800
+ }
801
+
802
+ // Context Menu
803
+ function openContextMenu(x, y, items) {
804
+ const menu = document.getElementById('fm-context-menu');
805
+ if (!menu) return;
806
+
807
+ menu.innerHTML = items.map(item => {
808
+ if (item === '-') return '<div class="fm-context-divider"></div>';
809
+ return `<button type="button" class="fm-context-item ${item.danger ? 'danger' : ''}" data-action="${item.id}">${item.icon || ''} ${escapeHtml(item.label)}</button>`;
810
+ }).join('');
811
+
812
+ menu.style.display = 'block';
813
+
814
+ const menuWidth = 190;
815
+ const menuHeight = menu.offsetHeight || 160;
816
+ const posX = Math.min(x, window.innerWidth - menuWidth - 10);
817
+ const posY = Math.min(y, window.innerHeight - menuHeight - 10);
818
+
819
+ menu.style.left = `${posX}px`;
820
+ menu.style.top = `${posY}px`;
821
+
822
+ menu.querySelectorAll('.fm-context-item').forEach(btn => {
823
+ btn.addEventListener('click', (e) => {
824
+ e.stopPropagation();
825
+ const actionId = btn.dataset.action;
826
+ const targetItem = items.find(it => it.id === actionId);
827
+ menu.style.display = 'none';
828
+ if (targetItem && typeof targetItem.action === 'function') {
829
+ targetItem.action();
830
+ }
831
+ });
832
+ });
833
+ }
834
+
835
+ function closeContextMenu() {
836
+ const menu = document.getElementById('fm-context-menu');
837
+ if (menu) menu.style.display = 'none';
838
+ }
839
+
840
+ // Actions: Star, Download, Preview, Delete, New Folder, Move
841
+ function toggleStar(key) {
842
+ if (starredFiles.has(key)) {
843
+ starredFiles.delete(key);
844
+ showToast('Removed from Starred');
845
+ } else {
846
+ starredFiles.add(key);
847
+ showToast('Added to Starred', 'success');
848
+ }
849
+ localStorage.setItem('mbk_starred_keys', JSON.stringify([...starredFiles]));
850
+ renderTree();
851
+ renderFiles();
852
+ }
853
+
854
+ async function downloadFile(key) {
855
+ const fileName = getBaseName(key);
856
+ const downloadUrl = getDownloadUrl(key);
857
+ showToast(`Downloading ${fileName}...`);
858
+ try {
859
+ const response = await fetch(downloadUrl);
860
+ if (!response.ok) throw new Error('Download failed');
861
+ const blob = await response.blob();
862
+ const url = URL.createObjectURL(blob);
863
+ const a = document.createElement('a');
864
+ a.href = url;
865
+ a.download = fileName;
866
+ document.body.appendChild(a);
867
+ a.click();
868
+ document.body.removeChild(a);
869
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
870
+ } catch (err) {
871
+ showToast(err.message, 'error');
872
+ }
873
+ }
874
+
875
+ function copyShareLink(key) {
876
+ const viewUrl = window.location.origin + getViewUrl(key);
877
+ navigator.clipboard.writeText(viewUrl).then(() => {
878
+ showToast('Link copied to clipboard', 'success');
879
+ }).catch(() => {
880
+ showToast('Failed to copy link', 'error');
881
+ });
882
+ }
883
+
884
+ function copyPublicLink(key) {
885
+ const publicUrl = window.location.origin + getPublicViewUrl(key);
886
+ navigator.clipboard.writeText(publicUrl).then(() => {
887
+ showToast('Public link copied to clipboard', 'success');
888
+ }).catch(() => {
889
+ showToast('Failed to copy public link', 'error');
890
+ });
891
+ }
892
+
893
+ // Preview Modal
894
+ async function openPreview(key) {
895
+ currentPreviewKey = key;
896
+ const modal = document.getElementById('previewModal');
897
+ const titleEl = document.getElementById('previewFileName');
898
+ const contentEl = document.getElementById('previewContent');
899
+ const openBtn = document.getElementById('previewOpenBtn');
900
+ const downloadBtn = document.getElementById('previewDownloadBtn');
901
+
902
+ if (!modal || !contentEl) return;
903
+
904
+ // Reset tabs
905
+ document.querySelectorAll('.preview-tab').forEach(t => t.classList.toggle('active', t.dataset.previewTab === 'preview'));
906
+ const panePreview = document.getElementById('pane-preview');
907
+ const paneInfo = document.getElementById('pane-info');
908
+ if (panePreview) panePreview.style.display = 'block';
909
+ if (paneInfo) paneInfo.style.display = 'none';
910
+
911
+ const fileName = getBaseName(key);
912
+ const viewUrl = getViewUrl(key);
913
+ const downloadUrl = getDownloadUrl(key);
914
+
915
+ if (titleEl) titleEl.textContent = fileName;
916
+ if (openBtn) openBtn.href = viewUrl;
917
+ if (downloadBtn) downloadBtn.href = downloadUrl;
918
+
919
+ contentEl.innerHTML = '<div class="empty-preview"><div class="spinner-border text-light"></div><div class="mt-2 text-light">Loading preview...</div></div>';
920
+ modal.classList.add('show');
921
+
922
+ // Populate file info tab
923
+ const fileInfoKey = document.getElementById('infoFileKey');
924
+ const fileInfoSize = document.getElementById('infoFileSize');
925
+ const fileInfoMime = document.getElementById('infoMimeType');
926
+ const fileInfoDate = document.getElementById('infoLastModified');
927
+ const fileInfoETag = document.getElementById('infoETag');
928
+
929
+ if (fileInfoKey) fileInfoKey.textContent = key;
930
+
931
+ // Async fetch full metadata
932
+ fetch(withBucketUrl(`/mbkbucket/api/file-info/${encodeURIComponent(key)}`))
933
+ .then(r => r.json())
934
+ .then(info => {
935
+ if (fileInfoSize) fileInfoSize.textContent = info.ContentLength ? formatFileSize(info.ContentLength) : '—';
936
+ if (fileInfoMime) fileInfoMime.textContent = info.ContentType || '—';
937
+ if (fileInfoDate) fileInfoDate.textContent = info.LastModified ? formatDate(info.LastModified) : '—';
938
+ if (fileInfoETag) fileInfoETag.textContent = info.ETag || '—';
939
+ }).catch(() => {});
940
+
941
+ // Configure public link button and metadata card
942
+ const copyPublicBtn = document.getElementById('previewCopyPublicLinkBtn');
943
+ if (copyPublicBtn) {
944
+ copyPublicBtn.style.display = isPublicViewEnabled ? '' : 'none';
945
+ }
946
+
947
+ const publicUrl = window.location.origin + getPublicViewUrl(key);
948
+ const infoPublicLinkCard = document.getElementById('infoPublicLinkCard');
949
+ const infoPublicUrl = document.getElementById('infoPublicUrl');
950
+ if (infoPublicLinkCard) {
951
+ infoPublicLinkCard.style.display = isPublicViewEnabled ? '' : 'none';
952
+ }
953
+ if (infoPublicUrl) {
954
+ infoPublicUrl.textContent = publicUrl;
955
+ }
956
+
957
+ // Render Preview Content
958
+ if (isImage(fileName)) {
959
+ contentEl.innerHTML = `<img src="${viewUrl}" alt="${escapeAttr(fileName)}">`;
960
+ } else if (isVideo(fileName)) {
961
+ contentEl.innerHTML = `<video controls autoplay class="w-100" style="max-height: 70vh;"><source src="${viewUrl}"></video>`;
962
+ } else if (isAudio(fileName)) {
963
+ contentEl.innerHTML = `<div class="p-4 text-center"><audio controls autoplay class="w-100"><source src="${viewUrl}"></audio></div>`;
964
+ } else if (isPdf(fileName)) {
965
+ contentEl.innerHTML = `<iframe src="${viewUrl}" class="preview-iframe"></iframe>`;
966
+ } else if (isMarkdown(fileName)) {
967
+ fetch(viewUrl).then(res => res.text()).then(text => {
968
+ contentEl.innerHTML = renderMarkdownToHtml(text);
969
+ }).catch(err => {
970
+ contentEl.innerHTML = `<div class="p-4 text-danger text-center">Failed to load preview: ${escapeHtml(err.message)}</div>`;
971
+ });
972
+ } else if (isTextOrCode(fileName)) {
973
+ fetch(viewUrl).then(res => res.text()).then(text => {
974
+ contentEl.innerHTML = `<pre class="preview-code-block"><code>${escapeHtml(text)}</code></pre>`;
975
+ }).catch(err => {
976
+ contentEl.innerHTML = `<div class="p-4 text-danger text-center">Failed to load text preview: ${escapeHtml(err.message)}</div>`;
977
+ });
978
+ } else {
979
+ contentEl.innerHTML = `
980
+ <div class="p-5 text-center text-light">
981
+ <h5>No inline preview available for this file type.</h5>
982
+ <p class="text-muted small">You can open it in a new tab or download it directly.</p>
983
+ </div>
984
+ `;
985
+ }
986
+ }
987
+
988
+ window.closePreviewModal = function () {
989
+ const modal = document.getElementById('previewModal');
990
+ if (modal) modal.classList.remove('show');
991
+ };
992
+
993
+ window.copyCurrentPreviewLink = function () {
994
+ if (currentPreviewKey) copyShareLink(currentPreviewKey);
995
+ };
996
+
997
+ window.copyCurrentPreviewPublicLink = function () {
998
+ if (currentPreviewKey) copyPublicLink(currentPreviewKey);
999
+ };
1000
+
1001
+ // Move & Rename Modal
1002
+ function promptMove(key, isCopy = false) {
1003
+ const modal = document.getElementById('moveModal');
1004
+ const title = document.getElementById('moveModalTitle');
1005
+ const preview = document.getElementById('moveSourcePathPreview');
1006
+ const srcInput = document.getElementById('moveSourceKeyInput');
1007
+ const destInput = document.getElementById('moveDestNameInput');
1008
+ const copyInput = document.getElementById('moveIsCopyInput');
1009
+ const label = document.getElementById('moveDestNameLabel');
1010
+
1011
+ if (!modal) return;
1012
+
1013
+ if (title) title.textContent = isCopy ? 'Copy File' : 'Move or Rename File';
1014
+ if (preview) preview.textContent = key;
1015
+ if (srcInput) srcInput.value = key;
1016
+ if (copyInput) copyInput.value = isCopy ? 'true' : 'false';
1017
+ if (destInput) destInput.value = key;
1018
+ if (label) label.textContent = isCopy ? 'Destination Key / Name' : 'New Key / Name';
1019
+
1020
+ modal.classList.add('show');
1021
+ setTimeout(() => destInput?.focus(), 50);
1022
+ }
1023
+
1024
+ window.closeMoveModal = function () {
1025
+ const modal = document.getElementById('moveModal');
1026
+ if (modal) modal.classList.remove('show');
1027
+ };
1028
+
1029
+ window.submitMoveItem = async function () {
1030
+ const srcKey = document.getElementById('moveSourceKeyInput')?.value;
1031
+ const destKey = document.getElementById('moveDestNameInput')?.value?.trim();
1032
+ const isCopy = document.getElementById('moveIsCopyInput')?.value === 'true';
1033
+
1034
+ if (!srcKey || !destKey) {
1035
+ showToast('Source and destination paths are required', 'warning');
1036
+ return;
1037
+ }
1038
+
1039
+ const endpoint = isCopy ? '/mbkbucket/copy' : '/mbkbucket/move';
1040
+ try {
1041
+ const response = await fetch(withBucketUrl(endpoint), {
1042
+ method: 'POST',
1043
+ headers: { 'Content-Type': 'application/json' },
1044
+ body: JSON.stringify({ sourceKey: srcKey, destKey })
1045
+ });
1046
+ const data = await response.json();
1047
+ if (!data.success) throw new Error(data.error || `${isCopy ? 'Copy' : 'Move'} failed`);
1048
+
1049
+ window.closeMoveModal();
1050
+ showToast(`${isCopy ? 'Copied' : 'Moved'} to ${destKey}`, 'success');
1051
+ loadFiles(currentPage, currentPrefix);
1052
+ } catch (err) {
1053
+ showToast(err.message, 'error');
1054
+ }
1055
+ };
1056
+
1057
+ // Delete Modal
1058
+ function promptDelete(key, isFolder = false) {
1059
+ const modal = document.getElementById('deleteModal');
1060
+ const deleteFileName = document.getElementById('deleteFileName');
1061
+ const deleteKey = document.getElementById('deleteKey');
1062
+ const deleteIsFolder = document.getElementById('deleteIsFolder');
1063
+ const deleteConfirmLabel = document.getElementById('deleteConfirmLabel');
1064
+
1065
+ if (!modal) return;
1066
+
1067
+ if (deleteFileName) deleteFileName.textContent = key;
1068
+ if (deleteKey) deleteKey.value = key;
1069
+ if (deleteIsFolder) deleteIsFolder.value = isFolder ? 'true' : '';
1070
+ if (deleteConfirmLabel) deleteConfirmLabel.textContent = isFolder ? 'Delete Folder' : 'Delete File';
1071
+
1072
+ modal.classList.add('show');
1073
+ }
1074
+
1075
+ window.closeModal = function () {
1076
+ const deleteModal = document.getElementById('deleteModal');
1077
+ if (deleteModal) deleteModal.classList.remove('show');
1078
+ };
1079
+
1080
+ // New Folder Modal
1081
+ function openNewFolderModal() {
1082
+ const modal = document.getElementById('newFolderModal');
1083
+ const input = document.getElementById('newFolderNameInput');
1084
+ if (!modal) return;
1085
+ if (input) input.value = '';
1086
+ modal.classList.add('show');
1087
+ if (input) setTimeout(() => input.focus(), 50);
1088
+ }
1089
+
1090
+ window.closeNewFolderModal = function () {
1091
+ const modal = document.getElementById('newFolderModal');
1092
+ if (modal) modal.classList.remove('show');
1093
+ };
1094
+
1095
+ window.submitCreateFolder = async function () {
1096
+ const input = document.getElementById('newFolderNameInput');
1097
+ const name = input ? input.value.trim() : '';
1098
+ if (!name) {
1099
+ showToast('Please enter a folder name', 'warning');
1100
+ return;
1101
+ }
1102
+
1103
+ try {
1104
+ const response = await fetch(withBucketUrl('/mbkbucket/create-folder'), {
1105
+ method: 'POST',
1106
+ headers: { 'Content-Type': 'application/json' },
1107
+ body: JSON.stringify({ folderName: name, prefix: currentPrefix })
1108
+ });
1109
+ const data = await response.json();
1110
+ if (!data.success) throw new Error(data.error || 'Failed to create folder');
1111
+
1112
+ closeNewFolderModal();
1113
+ showToast(`Folder "${name}" created`, 'success');
1114
+ loadFiles(1, currentPrefix);
1115
+ } catch (err) {
1116
+ showToast(err.message, 'error');
1117
+ }
1118
+ };
1119
+
1120
+ // Upload Modal & Flow
1121
+ function openUploadModal() {
1122
+ const modal = document.getElementById('uploadModal');
1123
+ const prefixInput = document.getElementById('uploadPrefixInput');
1124
+ if (prefixInput) prefixInput.value = currentPrefix;
1125
+ if (modal) modal.classList.add('show');
1126
+ }
1127
+
1128
+ window.closeUploadModal = function () {
1129
+ const modal = document.getElementById('uploadModal');
1130
+ if (modal) modal.classList.remove('show');
1131
+ };
1132
+
1133
+ function updateUploadQueueUI() {
1134
+ const queueEl = document.getElementById('uploadQueue');
1135
+ const clearBtn = document.getElementById('clearQueueBtn');
1136
+ if (!queueEl) return;
1137
+
1138
+ if (uploadQueue.length === 0) {
1139
+ queueEl.innerHTML = '';
1140
+ if (clearBtn) clearBtn.style.display = 'none';
1141
+ return;
1142
+ }
1143
+
1144
+ if (clearBtn) clearBtn.style.display = 'inline-flex';
1145
+ queueEl.innerHTML = uploadQueue.map((file, idx) => `
1146
+ <div class="upload-queue-item">
1147
+ <div class="d-flex align-items-center gap-2">
1148
+ <span class="font-medium">${escapeHtml(file.name)}</span>
1149
+ <span class="upload-queue-meta">${formatFileSize(file.size)}</span>
1150
+ </div>
1151
+ <button type="button" class="btn-close" data-remove-queue="${idx}" style="font-size: 14px;">&times;</button>
1152
+ </div>
1153
+ `).join('');
1154
+ }
1155
+
1156
+ async function processUploads() {
1157
+ if (isUploading || uploadQueue.length === 0) return;
1158
+ isUploading = true;
1159
+
1160
+ const progressWrap = document.getElementById('uploadProgress');
1161
+ const progressBar = document.getElementById('progressBar');
1162
+ const percentEl = document.getElementById('uploadPercent');
1163
+ const statusLabel = document.getElementById('uploadStatusLabel');
1164
+ const startBtn = document.getElementById('startUploadBtn');
1165
+
1166
+ if (progressWrap) progressWrap.style.display = 'block';
1167
+ if (startBtn) startBtn.disabled = true;
1168
+
1169
+ const totalFiles = uploadQueue.length;
1170
+ let completed = 0;
1171
+
1172
+ for (let i = 0; i < uploadQueue.length; i++) {
1173
+ const file = uploadQueue[i];
1174
+ if (statusLabel) statusLabel.textContent = `Uploading ${file.name} (${i + 1}/${totalFiles})...`;
1175
+
1176
+ const formData = new FormData();
1177
+ formData.append('file', file);
1178
+ formData.append('prefix', currentPrefix);
1179
+
1180
+ try {
1181
+ const response = await fetch(withBucketUrl('/mbkbucket/upload'), {
1182
+ method: 'POST',
1183
+ body: formData
1184
+ });
1185
+ const result = await response.json();
1186
+ if (!result.success) throw new Error(result.error || 'Upload failed');
1187
+ completed++;
1188
+ } catch (err) {
1189
+ showToast(`Failed to upload ${file.name}: ${err.message}`, 'error');
1190
+ }
1191
+
1192
+ const percent = Math.round(((i + 1) / totalFiles) * 100);
1193
+ if (progressBar) progressBar.style.width = `${percent}%`;
1194
+ if (percentEl) percentEl.textContent = `${percent}%`;
1195
+ }
1196
+
1197
+ showToast(`Successfully uploaded ${completed} file(s)`, 'success');
1198
+ uploadQueue = [];
1199
+ isUploading = false;
1200
+ if (startBtn) startBtn.disabled = false;
1201
+ if (progressWrap) progressWrap.style.display = 'none';
1202
+ closeUploadModal();
1203
+ updateUploadQueueUI();
1204
+ loadFiles(1, currentPrefix);
1205
+ }
1206
+
1207
+ // Event Listeners Setup
1208
+ function initEventListeners() {
1209
+ // Refresh button
1210
+ document.getElementById('refresh-btn')?.addEventListener('click', () => {
1211
+ loadFiles(currentPage, currentPrefix, currentSearch);
1212
+ checkConnectionHealth(false);
1213
+ });
1214
+
1215
+ // Health check button
1216
+ document.getElementById('checkHealthBtn')?.addEventListener('click', () => {
1217
+ checkConnectionHealth(false);
1218
+ });
1219
+
1220
+ // Preview Tabs switcher
1221
+ document.querySelector('.preview-tabs')?.addEventListener('click', (e) => {
1222
+ const btn = e.target.closest('.preview-tab');
1223
+ if (!btn) return;
1224
+ document.querySelectorAll('.preview-tab').forEach(b => b.classList.remove('active'));
1225
+ btn.classList.add('active');
1226
+ const target = btn.dataset.previewTab;
1227
+ const pPane = document.getElementById('pane-preview');
1228
+ const iPane = document.getElementById('pane-info');
1229
+ if (target === 'preview') {
1230
+ if (pPane) pPane.style.display = 'block';
1231
+ if (iPane) iPane.style.display = 'none';
1232
+ } else {
1233
+ if (pPane) pPane.style.display = 'none';
1234
+ if (iPane) iPane.style.display = 'block';
1235
+ }
1236
+ });
1237
+
1238
+ // Tree clicks
1239
+ document.getElementById('fm-tree')?.addEventListener('click', (e) => {
1240
+ const link = e.target.closest('.tree-link');
1241
+ if (!link) return;
1242
+ e.preventDefault();
1243
+ const filter = link.dataset.treeFilter;
1244
+ const prefix = link.dataset.treePrefix;
1245
+
1246
+ if (filter && filter !== 'all') {
1247
+ currentQuickFilter = filter;
1248
+ document.querySelectorAll('.quick-filter-btn').forEach(b => b.classList.remove('active'));
1249
+ renderTree();
1250
+ renderBreadcrumbs();
1251
+ renderFiles();
1252
+ } else {
1253
+ currentQuickFilter = 'all';
1254
+ document.querySelectorAll('.quick-filter-btn').forEach(b => {
1255
+ b.classList.toggle('active', b.dataset.filter === 'all');
1256
+ });
1257
+ loadFiles(1, prefix !== undefined ? prefix : '');
1258
+ }
1259
+ });
1260
+
1261
+ // Breadcrumb clicks
1262
+ document.getElementById('fm-breadcrumb')?.addEventListener('click', (e) => {
1263
+ const link = e.target.closest('[data-nav-prefix]');
1264
+ if (!link) return;
1265
+ e.preventDefault();
1266
+ currentQuickFilter = 'all';
1267
+ loadFiles(1, link.dataset.navPrefix || '');
1268
+ });
1269
+
1270
+ // Quick filters
1271
+ document.querySelectorAll('.quick-filter-btn').forEach(btn => {
1272
+ btn.addEventListener('click', () => {
1273
+ document.querySelectorAll('.quick-filter-btn').forEach(b => b.classList.remove('active'));
1274
+ btn.classList.add('active');
1275
+ currentQuickFilter = btn.dataset.filter || 'all';
1276
+ renderTree();
1277
+ renderBreadcrumbs();
1278
+ renderFiles();
1279
+ syncStateUrl();
1280
+ });
1281
+ });
1282
+
1283
+ // View mode toggle
1284
+ const gridBtn = document.getElementById('view-grid');
1285
+ const listBtn = document.getElementById('view-list');
1286
+ const thumbsBtn = document.getElementById('view-thumbs');
1287
+
1288
+ if (showThumbnails) {
1289
+ thumbsBtn?.classList.add('active');
1290
+ }
1291
+
1292
+ thumbsBtn?.addEventListener('click', () => {
1293
+ showThumbnails = !showThumbnails;
1294
+ localStorage.setItem('mbk_show_thumbnails', String(showThumbnails));
1295
+ thumbsBtn.classList.toggle('active', showThumbnails);
1296
+ renderFiles();
1297
+ showToast(showThumbnails ? 'Image previews enabled' : 'Fast Mode: Previews disabled (saving tokens & bandwidth)', 'info');
1298
+ });
1299
+
1300
+ gridBtn?.addEventListener('click', () => {
1301
+ currentViewMode = 'grid';
1302
+ localStorage.setItem('mbk_view_mode', 'grid');
1303
+ gridBtn.classList.add('active');
1304
+ listBtn?.classList.remove('active');
1305
+ renderFiles();
1306
+ syncStateUrl();
1307
+ });
1308
+
1309
+ listBtn?.addEventListener('click', () => {
1310
+ currentViewMode = 'list';
1311
+ localStorage.setItem('mbk_view_mode', 'list');
1312
+ listBtn.classList.add('active');
1313
+ gridBtn?.classList.remove('active');
1314
+ renderFiles();
1315
+ syncStateUrl();
1316
+ });
1317
+
1318
+ // Search input
1319
+ const searchInput = document.getElementById('searchInput');
1320
+ const clearSearchBtn = document.getElementById('searchClearBtn');
1321
+
1322
+ let searchTimer = null;
1323
+ searchInput?.addEventListener('input', (e) => {
1324
+ const val = e.target.value;
1325
+ if (clearSearchBtn) clearSearchBtn.style.display = val ? 'block' : 'none';
1326
+
1327
+ clearTimeout(searchTimer);
1328
+ searchTimer = setTimeout(() => {
1329
+ currentSearch = val.trim();
1330
+ renderFiles();
1331
+ }, 250);
1332
+ });
1333
+
1334
+ clearSearchBtn?.addEventListener('click', () => {
1335
+ if (searchInput) searchInput.value = '';
1336
+ clearSearchBtn.style.display = 'none';
1337
+ currentSearch = '';
1338
+ renderFiles();
1339
+ });
1340
+
1341
+ // Grid Item Interactions (Click, Double Click, Star, Checkbox, Context Menu)
1342
+ document.getElementById('fm-grid')?.addEventListener('click', (e) => {
1343
+ // Checkbox selection
1344
+ const cb = e.target.closest('.file-select-cb');
1345
+ if (cb) {
1346
+ e.stopPropagation();
1347
+ const key = cb.dataset.key;
1348
+ if (cb.checked) {
1349
+ selectedFiles.add(key);
1350
+ } else {
1351
+ selectedFiles.delete(key);
1352
+ }
1353
+ const parentItem = cb.closest('.fm-item');
1354
+ if (parentItem) parentItem.classList.toggle('is-selected', cb.checked);
1355
+ updateBatchBar();
1356
+ return;
1357
+ }
1358
+
1359
+ // Select All in list header
1360
+ const selectAll = e.target.closest('#selectAllCb');
1361
+ if (selectAll) {
1362
+ e.stopPropagation();
1363
+ const checked = selectAll.checked;
1364
+ const visibleFiles = getFilteredFiles();
1365
+ visibleFiles.forEach(f => {
1366
+ if (checked) selectedFiles.add(f.Key);
1367
+ else selectedFiles.delete(f.Key);
1368
+ });
1369
+ document.querySelectorAll('.file-select-cb').forEach(c => { c.checked = checked; });
1370
+ document.querySelectorAll('.fm-item[data-type="file"]').forEach(item => item.classList.toggle('is-selected', checked));
1371
+ updateBatchBar();
1372
+ return;
1373
+ }
1374
+
1375
+ // Star click
1376
+ const starBtn = e.target.closest('[data-star-key]');
1377
+ if (starBtn) {
1378
+ e.stopPropagation();
1379
+ toggleStar(starBtn.dataset.starKey);
1380
+ return;
1381
+ }
1382
+
1383
+ // Menu click
1384
+ const menuBtn = e.target.closest('.fm-item-menu');
1385
+ if (menuBtn) {
1386
+ e.stopPropagation();
1387
+ const rect = menuBtn.getBoundingClientRect();
1388
+ const fileKey = menuBtn.dataset.menuFile;
1389
+ const folderPath = menuBtn.dataset.menuFolder;
1390
+
1391
+ if (fileKey) {
1392
+ const isStarred = starredFiles.has(fileKey);
1393
+ const menuItems = [
1394
+ { id: 'view', label: 'Preview & Info', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5-7-5-7-5z"/><circle cx="8" cy="8" r="2.5"/></svg>', action: () => openPreview(fileKey) },
1395
+ { id: 'download', label: 'Download', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 11v3H2v-3"/><path d="M5 8l3 3 3-3"/><line x1="8" y1="2" x2="8" y2="11"/></svg>', action: () => downloadFile(fileKey) },
1396
+ { id: 'share', label: 'Copy Link', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 2h8a1 1 0 011 1v8M3 5h8a1 1 0 011 1v8a1 1 0 01-1 1H3a1 1 0 01-1-1V6a1 1 0 011-1z"/></svg>', action: () => copyShareLink(fileKey) },
1397
+ ];
1398
+
1399
+ if (isPublicViewEnabled) {
1400
+ menuItems.push({
1401
+ id: 'public-share',
1402
+ label: 'Copy Public Link',
1403
+ icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.25"/><path d="M1.75 8h12.5M8 1.75a10.5 10.5 0 010 12.5 10.5 10.5 0 010-12.5z"/></svg>',
1404
+ action: () => copyPublicLink(fileKey)
1405
+ });
1406
+ }
1407
+
1408
+ menuItems.push(
1409
+ { id: 'move', label: 'Rename / Move', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M11 2L14 5L11 8M14 5H5C3.34315 5 2 6.34315 2 8V14"/></svg>', action: () => promptMove(fileKey, false) },
1410
+ { id: 'copy', label: 'Duplicate / Copy', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M3 11V3a1.5 1.5 0 011.5-1.5h8"/></svg>', action: () => promptMove(fileKey, true) },
1411
+ { id: 'star', label: isStarred ? 'Unstar' : 'Star', icon: '<svg width="13" height="13" 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>', action: () => toggleStar(fileKey) },
1412
+ '-',
1413
+ { id: 'delete', label: 'Delete', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4l1 9h4l1-9"/></svg>', danger: true, action: () => promptDelete(fileKey, false) }
1414
+ );
1415
+
1416
+ openContextMenu(rect.left, rect.bottom + 4, menuItems);
1417
+ } else if (folderPath) {
1418
+ openContextMenu(rect.left, rect.bottom + 4, [
1419
+ { id: 'open', label: 'Open Folder', icon: '<svg width="13" height="13" 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>', action: () => loadFiles(1, folderPath) },
1420
+ '-',
1421
+ { id: 'delete', label: 'Delete Folder', icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4l1 9h4l1-9"/></svg>', danger: true, action: () => promptDelete(folderPath + '/', true) }
1422
+ ]);
1423
+ }
1424
+ return;
1425
+ }
1426
+
1427
+ // Folder click to navigate
1428
+ const folderItem = e.target.closest('[data-type="folder"]');
1429
+ if (folderItem) {
1430
+ e.stopPropagation();
1431
+ loadFiles(1, folderItem.dataset.folderPath);
1432
+ return;
1433
+ }
1434
+
1435
+ // File click to preview
1436
+ const fileItem = e.target.closest('[data-type="file"]');
1437
+ if (fileItem && !e.target.closest('.file-select-cb') && !e.target.closest('.fm-star')) {
1438
+ e.stopPropagation();
1439
+ openPreview(fileItem.dataset.key);
1440
+ }
1441
+ });
1442
+
1443
+ // Batch Bar Actions
1444
+ document.getElementById('batchClearBtn')?.addEventListener('click', clearBatchSelection);
1445
+
1446
+ document.getElementById('batchDownloadBtn')?.addEventListener('click', async () => {
1447
+ const keys = Array.from(selectedFiles);
1448
+ if (!keys.length) return;
1449
+ showToast(`Starting download for ${keys.length} item(s)...`);
1450
+ for (const key of keys) {
1451
+ await downloadFile(key);
1452
+ }
1453
+ });
1454
+
1455
+ document.getElementById('batchDeleteBtn')?.addEventListener('click', () => {
1456
+ const keys = Array.from(selectedFiles);
1457
+ if (!keys.length) return;
1458
+ promptDelete(`${keys.length} selected files`, false);
1459
+ });
1460
+
1461
+ document.getElementById('batchMoveBtn')?.addEventListener('click', () => {
1462
+ const keys = Array.from(selectedFiles);
1463
+ if (!keys.length) return;
1464
+ promptMove(keys[0], false);
1465
+ });
1466
+
1467
+ // Bucket Selector
1468
+ document.getElementById('bucketSelector')?.addEventListener('change', (e) => {
1469
+ const opt = e.target.selectedOptions?.[0];
1470
+ const newBucket = e.target.value;
1471
+ if (!newBucket) return;
1472
+ activeBucket = newBucket;
1473
+
1474
+ // Update provider badge
1475
+ const providerType = opt?.dataset?.type || 's3';
1476
+ const badge = document.getElementById('topProviderBadge');
1477
+ if (badge) {
1478
+ badge.className = `provider-badge provider-${providerType}`;
1479
+ const lbl = badge.querySelector('.provider-label');
1480
+ if (lbl) lbl.textContent = providerType;
1481
+ }
1482
+
1483
+ clearBatchSelection();
1484
+ loadFiles(1, '');
1485
+ });
1486
+
1487
+ // Top action buttons & Modals
1488
+ document.querySelectorAll('[data-action="upload-files"]').forEach(btn => {
1489
+ btn.addEventListener('click', openUploadModal);
1490
+ });
1491
+
1492
+ document.querySelectorAll('[data-action="create-folder"]').forEach(btn => {
1493
+ btn.addEventListener('click', openNewFolderModal);
1494
+ });
1495
+
1496
+ // Drag and Drop Upload Zone
1497
+ const dropZone = document.getElementById('dropZone');
1498
+ const fileInput = document.getElementById('fileInput');
1499
+
1500
+ dropZone?.addEventListener('click', () => fileInput?.click());
1501
+
1502
+ dropZone?.addEventListener('dragover', (e) => {
1503
+ e.preventDefault();
1504
+ dropZone.classList.add('dragover');
1505
+ });
1506
+
1507
+ dropZone?.addEventListener('dragleave', () => {
1508
+ dropZone.classList.remove('dragover');
1509
+ });
1510
+
1511
+ dropZone?.addEventListener('drop', (e) => {
1512
+ e.preventDefault();
1513
+ dropZone.classList.remove('dragover');
1514
+ if (e.dataTransfer.files?.length) {
1515
+ uploadQueue.push(...Array.from(e.dataTransfer.files));
1516
+ updateUploadQueueUI();
1517
+ }
1518
+ });
1519
+
1520
+ fileInput?.addEventListener('change', (e) => {
1521
+ if (e.target.files?.length) {
1522
+ uploadQueue.push(...Array.from(e.target.files));
1523
+ updateUploadQueueUI();
1524
+ }
1525
+ });
1526
+
1527
+ document.getElementById('uploadQueue')?.addEventListener('click', (e) => {
1528
+ const removeBtn = e.target.closest('[data-remove-queue]');
1529
+ if (removeBtn) {
1530
+ const idx = parseInt(removeBtn.dataset.removeQueue, 10);
1531
+ uploadQueue.splice(idx, 1);
1532
+ updateUploadQueueUI();
1533
+ }
1534
+ });
1535
+
1536
+ document.getElementById('clearQueueBtn')?.addEventListener('click', () => {
1537
+ uploadQueue = [];
1538
+ updateUploadQueueUI();
1539
+ });
1540
+
1541
+ document.getElementById('startUploadBtn')?.addEventListener('click', processUploads);
1542
+
1543
+ // Fullscreen Preview Toggle
1544
+ document.getElementById('previewFullscreenBtn')?.addEventListener('click', () => {
1545
+ document.querySelector('.preview-modal-dialog')?.classList.toggle('is-fullscreen');
1546
+ });
1547
+
1548
+ // Pagination clicks
1549
+ document.getElementById('paginationList')?.addEventListener('click', (e) => {
1550
+ const link = e.target.closest('[data-page]');
1551
+ if (!link) return;
1552
+ const page = parseInt(link.dataset.page, 10);
1553
+ if (!isNaN(page) && page > 0) {
1554
+ loadFiles(page, currentPrefix, currentSearch);
1555
+ }
1556
+ });
1557
+
1558
+ // Global click to close context menu
1559
+ document.addEventListener('click', closeContextMenu);
1560
+
1561
+ // Handle delete form submission
1562
+ const deleteForm = document.getElementById('deleteForm');
1563
+ deleteForm?.addEventListener('submit', async (e) => {
1564
+ e.preventDefault();
1565
+ const formData = new FormData(deleteForm);
1566
+ const key = formData.get('key');
1567
+ const isFolder = formData.get('folder') === 'true';
1568
+
1569
+ const payload = selectedFiles.size > 0 && key?.includes('selected')
1570
+ ? { keys: Array.from(selectedFiles), prefix: currentPrefix }
1571
+ : { key, isFolder, prefix: currentPrefix };
1572
+
1573
+ try {
1574
+ const response = await fetch(withBucketUrl('/mbkbucket/delete'), {
1575
+ method: 'POST',
1576
+ headers: { 'Content-Type': 'application/json' },
1577
+ body: JSON.stringify(payload)
1578
+ });
1579
+ const data = await response.json();
1580
+ if (!data.success) throw new Error(data.error || 'Delete failed');
1581
+
1582
+ closeModal();
1583
+ clearBatchSelection();
1584
+ showToast('Deleted successfully', 'success');
1585
+ loadFiles(currentPage, currentPrefix);
1586
+ } catch (err) {
1587
+ showToast(err.message, 'error');
1588
+ }
1589
+ });
1590
+ }
1591
+
1592
+ // Initialization
1593
+ document.addEventListener('DOMContentLoaded', () => {
1594
+ if (currentViewMode === 'list') {
1595
+ document.getElementById('view-list')?.classList.add('active');
1596
+ document.getElementById('view-grid')?.classList.remove('active');
1597
+ }
1598
+ if (showThumbnails) {
1599
+ document.getElementById('view-thumbs')?.classList.add('active');
1600
+ }
1601
+
1602
+ initEventListeners();
1603
+
1604
+ const initialFolder = urlParams.get('folder') || '';
1605
+ const initialPage = parseInt(urlParams.get('page') || '1', 10);
1606
+ const initialSearch = urlParams.get('search') || '';
1607
+
1608
+ loadFiles(initialPage, initialFolder, initialSearch);
1609
+ checkConnectionHealth(true);
1610
+ });
1611
+
1612
+ // Expose global methods
1613
+ window.mbkbucket = {
1614
+ loadFiles,
1615
+ openPreview,
1616
+ downloadFile,
1617
+ promptMove,
1618
+ checkConnectionHealth,
1619
+ showToast
1620
+ };
1621
+
1622
+ })();