mbkbucket 3.2.0 → 3.3.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.
@@ -0,0 +1,1550 @@
1
+ /**
2
+ * mbkbucket-helper.js v1.2.0
3
+ * ─────────────────────────────────────────────────────────────────────────────
4
+ * Drop-in frontend helper utilities for mbkbucket.
5
+ * Include this script (and mbkbucket-helper.css) in any HTML page.
6
+ *
7
+ * Exposes: window.MBKBucket
8
+ *
9
+ * Features:
10
+ * - Gallery Browser Modal (MBKBucket.openGallery) with multi-select & in-modal upload
11
+ * - File Picker Input (MBKBucket.createPicker / data-mbk-picker) with live preview binding
12
+ * - Upload Widget (MBKBucket.createUploader / data-mbk-uploader)
13
+ * - URL Builders (MBKBucket.url.*)
14
+ * - API Helpers (MBKBucket.api.*)
15
+ * - Toast Notifications (MBKBucket.toast.*)
16
+ *
17
+ * Auth: uses existing cookie session (same origin).
18
+ * ─────────────────────────────────────────────────────────────────────────────
19
+ */
20
+ (function (global) {
21
+ 'use strict';
22
+
23
+ // ─────────────────────────────────────────────────────────────────────────
24
+ // Config & Helpers
25
+ // ─────────────────────────────────────────────────────────────────────────
26
+
27
+ const BASE = '/mbkbucket';
28
+ const MULTIPART_THRESHOLD = 10 * 1024 * 1024; // 10 MB → multipart upload
29
+ const CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB chunks
30
+
31
+ function esc(str) {
32
+ return String(str == null ? '' : str)
33
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
34
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
35
+ }
36
+
37
+ function baseName(key) {
38
+ const s = String(key || '').replace(/\/+$/, '');
39
+ const i = s.lastIndexOf('/');
40
+ return i === -1 ? s : s.slice(i + 1);
41
+ }
42
+
43
+ function ext(fileName) {
44
+ const n = baseName(fileName);
45
+ const d = n.lastIndexOf('.');
46
+ return d > -1 ? n.slice(d + 1).toLowerCase() : '';
47
+ }
48
+
49
+ function formatBytes(bytes) {
50
+ if (!bytes || bytes < 0) return '';
51
+ if (bytes < 1024) return bytes + ' B';
52
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
53
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
54
+ return (bytes / 1073741824).toFixed(2) + ' GB';
55
+ }
56
+
57
+ function buildUrl(path, params) {
58
+ const u = new URL(path, location.origin);
59
+ if (params) {
60
+ Object.entries(params).forEach(([k, v]) => {
61
+ if (v !== undefined && v !== null && v !== '') {
62
+ u.searchParams.set(k, v);
63
+ }
64
+ });
65
+ }
66
+ return u.toString();
67
+ }
68
+
69
+ function apiFetch(url, options) {
70
+ return fetch(url, { credentials: 'include', ...options })
71
+ .then(async r => {
72
+ const json = await r.json().catch(() => ({ success: false, error: r.statusText }));
73
+ if (!r.ok) throw Object.assign(new Error(json.error || r.statusText), { status: r.status, body: json });
74
+ return json;
75
+ });
76
+ }
77
+
78
+ const IMAGE_EXTS = new Set(['jpg','jpeg','png','gif','webp','svg','bmp','ico','avif']);
79
+ const VIDEO_EXTS = new Set(['mp4','webm','ogg','mov','avi']);
80
+ const AUDIO_EXTS = new Set(['mp3','wav','flac','aac','m4a','ogg']);
81
+ const DOC_EXTS = new Set(['pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md','csv','rtf']);
82
+ 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']);
83
+
84
+ const TYPE_MAP = {
85
+ pdf: { label:'PDF', color:'#dc2626', bg:'rgba(220, 38, 38, 0.1)' },
86
+ doc: { label:'DOC', color:'#066fd1', bg:'rgba(6, 111, 209, 0.1)' },
87
+ docx: { label:'DOC', color:'#066fd1', bg:'rgba(6, 111, 209, 0.1)' },
88
+ xls: { label:'XLS', color:'#16a34a', bg:'rgba(22, 163, 74, 0.12)' },
89
+ xlsx: { label:'XLS', color:'#16a34a', bg:'rgba(22, 163, 74, 0.12)' },
90
+ csv: { label:'CSV', color:'#16a34a', bg:'rgba(22, 163, 74, 0.12)' },
91
+ ppt: { label:'PPT', color:'#d97706', bg:'rgba(217, 119, 6, 0.12)' },
92
+ pptx: { label:'PPT', color:'#d97706', bg:'rgba(217, 119, 6, 0.12)' },
93
+ txt: { label:'TXT', color:'#667c79', bg:'rgba(102, 124, 121, 0.12)' },
94
+ md: { label:'MD', color:'#7c3aed', bg:'rgba(124, 58, 237, 0.1)' },
95
+ js: { label:'JS', color:'#0f766e', bg:'rgba(15, 118, 110, 0.12)' },
96
+ ts: { label:'TS', color:'#0f766e', bg:'rgba(15, 118, 110, 0.12)' },
97
+ json: { label:'JSON', color:'#0f766e', bg:'rgba(15, 118, 110, 0.12)' },
98
+ html: { label:'HTML', color:'#ea580c', bg:'rgba(234, 88, 12, 0.12)' },
99
+ css: { label:'CSS', color:'#066fd1', bg:'rgba(6, 111, 209, 0.1)' },
100
+ py: { label:'PY', color:'#0f766e', bg:'rgba(15, 118, 110, 0.12)' },
101
+ zip: { label:'ZIP', color:'#667c79', bg:'rgba(102, 124, 121, 0.12)' },
102
+ tar: { label:'TAR', color:'#667c79', bg:'rgba(102, 124, 121, 0.12)' },
103
+ gz: { label:'GZ', color:'#667c79', bg:'rgba(102, 124, 121, 0.12)' },
104
+ };
105
+
106
+ function fileTypeInfo(fileName) {
107
+ const e = ext(fileName);
108
+ if (IMAGE_EXTS.has(e)) return { label: 'IMG', color: '#d97706', bg: 'rgba(217, 119, 6, 0.12)' };
109
+ if (VIDEO_EXTS.has(e)) return { label: 'VID', color: '#1f8f84', bg: 'rgba(31, 143, 132, 0.12)' };
110
+ if (AUDIO_EXTS.has(e)) return { label: 'AUD', color: '#7c3aed', bg: 'rgba(124, 58, 237, 0.1)' };
111
+ return TYPE_MAP[e] || { label: (e.toUpperCase().slice(0,4) || 'FILE'), color: '#667c79', bg: 'rgba(102, 124, 121, 0.12)' };
112
+ }
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────
115
+ // URL Builders
116
+ // ─────────────────────────────────────────────────────────────────────────
117
+
118
+ const url = {
119
+ view(key, bucket) {
120
+ return key ? buildUrl(`${BASE}/view/${encodeURIComponent(key)}`, { bucket: bucket || '' }) : '';
121
+ },
122
+ public(key, bucket) {
123
+ return key ? buildUrl(`${BASE}/p_view/${encodeURIComponent(key)}`, { bucket: bucket || '' }) : '';
124
+ },
125
+ download(key, bucket) {
126
+ return key ? buildUrl(`${BASE}/download/${encodeURIComponent(key)}`, { bucket: bucket || '' }) : '';
127
+ },
128
+ player(key, bucket) {
129
+ return key ? buildUrl(`${BASE}/player/${encodeURIComponent(key)}`, { bucket: bucket || '' }) : '';
130
+ }
131
+ };
132
+
133
+ // ─────────────────────────────────────────────────────────────────────────
134
+ // SVG Icons
135
+ // ─────────────────────────────────────────────────────────────────────────
136
+
137
+ const ICONS = {
138
+ bucket: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3C8 3 4 5 4 8v1c0 3 2 5 8 6 6-1 8-3 8-6V8c0-3-4-5-8-5z"/><path d="M4 9c0 3 2 5 8 6 6-1 8-3 8-6"/><path d="M4 13c0 3 2 5 8 6 6-1 8-3 8-6"/></svg>`,
139
+ folder: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z"/></svg>`,
140
+ file: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>`,
141
+ image: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
142
+ video: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2" y="7" width="15" height="10" rx="2"/><path d="M22 8l-5 4 5 4V8z"/></svg>`,
143
+ audio: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
144
+ close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
145
+ search: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
146
+ grid: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>`,
147
+ list: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
148
+ upload: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0018 9h-1.26A8 8 0 103 16.3"/></svg>`,
149
+ plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
150
+ check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
151
+ warn: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
152
+ info: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
153
+ trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>`,
154
+ browse: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
155
+ };
156
+
157
+ function icon(name, size) {
158
+ const s = size || 16;
159
+ const svg = ICONS[name] || ICONS.file;
160
+ return svg.replace('<svg', `<svg width="${s}" height="${s}"`);
161
+ }
162
+
163
+ // ─────────────────────────────────────────────────────────────────────────
164
+ // Toast Notifications
165
+ // ─────────────────────────────────────────────────────────────────────────
166
+
167
+ let _toastContainer = null;
168
+
169
+ function getToastContainer() {
170
+ if (!_toastContainer) {
171
+ _toastContainer = document.createElement('div');
172
+ _toastContainer.className = 'mbkh-toast-container';
173
+ document.body.appendChild(_toastContainer);
174
+ }
175
+ return _toastContainer;
176
+ }
177
+
178
+ function showToast(message, type, duration) {
179
+ type = type || 'info';
180
+ duration = duration || 3500;
181
+ const iconMap = { success: 'check', error: 'warn', warning: 'warn', info: 'info' };
182
+ const colorMap = { success: '#16a34a', error: '#dc2626', warning: '#d97706', info: '#0f766e' };
183
+
184
+ const el = document.createElement('div');
185
+ el.className = `mbkh-toast mbkh-toast-${type}`;
186
+ el.innerHTML = `
187
+ <span class="mbkh-toast-icon" style="color:${colorMap[type]}">${icon(iconMap[type] || 'info', 16)}</span>
188
+ <span class="mbkh-toast-msg">${esc(message)}</span>`;
189
+
190
+ const container = getToastContainer();
191
+ container.appendChild(el);
192
+
193
+ setTimeout(() => {
194
+ el.classList.add('mbkh-toast-out');
195
+ el.addEventListener('animationend', () => el.remove(), { once: true });
196
+ }, duration);
197
+ }
198
+
199
+ const toast = {
200
+ success: (msg, dur) => showToast(msg, 'success', dur),
201
+ error: (msg, dur) => showToast(msg, 'error', dur),
202
+ warning: (msg, dur) => showToast(msg, 'warning', dur),
203
+ info: (msg, dur) => showToast(msg, 'info', dur),
204
+ };
205
+
206
+ // ─────────────────────────────────────────────────────────────────────────
207
+ // API Helpers
208
+ // ─────────────────────────────────────────────────────────────────────────
209
+
210
+ const api = {
211
+ listFiles(prefix, opts) {
212
+ opts = opts || {};
213
+ return apiFetch(buildUrl(`${BASE}/api/files`, {
214
+ prefix: prefix || '',
215
+ bucket: opts.bucket || '',
216
+ search: opts.search || '',
217
+ token: opts.token || '',
218
+ recursive: opts.recursive ? 'true' : 'false',
219
+ }));
220
+ },
221
+
222
+ uploadSingle(prefix, file, opts) {
223
+ opts = opts || {};
224
+ const fd = new FormData();
225
+ fd.append('file', file);
226
+ fd.append('prefix', prefix || '');
227
+ if (opts.bucket) fd.append('bucket', opts.bucket);
228
+
229
+ return new Promise((resolve, reject) => {
230
+ const xhr = new XMLHttpRequest();
231
+ xhr.open('POST', `${BASE}/upload`);
232
+ xhr.withCredentials = true;
233
+ if (opts.onProgress) {
234
+ xhr.upload.onprogress = e => {
235
+ if (e.lengthComputable) opts.onProgress(e.loaded / e.total);
236
+ };
237
+ }
238
+ xhr.onload = () => {
239
+ try {
240
+ const json = JSON.parse(xhr.responseText);
241
+ if (xhr.status >= 200 && xhr.status < 300 && json.success) resolve(json);
242
+ else reject(new Error(json.error || xhr.statusText));
243
+ } catch(e) { reject(new Error('Upload failed')); }
244
+ };
245
+ xhr.onerror = () => reject(new Error('Network error'));
246
+ xhr.send(fd);
247
+ });
248
+ },
249
+
250
+ async uploadMultipart(prefix, file, opts) {
251
+ opts = opts || {};
252
+ const bucket = opts.bucket || '';
253
+
254
+ const init = await apiFetch(`${BASE}/upload-init`, {
255
+ method: 'POST',
256
+ credentials: 'include',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({ fileName: file.name, prefix, contentType: file.type || 'application/octet-stream', ...(bucket ? { bucket } : {}) }),
259
+ });
260
+ const { uploadId, key } = init;
261
+
262
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
263
+ const parts = [];
264
+ let uploadedBytes = 0;
265
+
266
+ for (let i = 0; i < totalChunks; i++) {
267
+ if (opts.signal && opts.signal.aborted) {
268
+ await apiFetch(`${BASE}/upload-abort`, {
269
+ method: 'POST', credentials: 'include',
270
+ headers: { 'Content-Type': 'application/json' },
271
+ body: JSON.stringify({ uploadId, key, ...(bucket ? { bucket } : {}) }),
272
+ }).catch(() => {});
273
+ throw new Error('Upload aborted');
274
+ }
275
+
276
+ const start = i * CHUNK_SIZE;
277
+ const end = Math.min(start + CHUNK_SIZE, file.size);
278
+ const chunk = file.slice(start, end);
279
+
280
+ const fd = new FormData();
281
+ fd.append('chunk', chunk, file.name);
282
+ fd.append('uploadId', uploadId);
283
+ fd.append('key', key);
284
+ fd.append('partNumber', String(i + 1));
285
+ if (bucket) fd.append('bucket', bucket);
286
+
287
+ const result = await new Promise((resolve, reject) => {
288
+ const xhr = new XMLHttpRequest();
289
+ xhr.open('POST', `${BASE}/upload-chunk`);
290
+ xhr.withCredentials = true;
291
+ xhr.upload.onprogress = e => {
292
+ if (e.lengthComputable && opts.onProgress) {
293
+ opts.onProgress((uploadedBytes + e.loaded) / file.size);
294
+ }
295
+ };
296
+ xhr.onload = () => {
297
+ try {
298
+ const json = JSON.parse(xhr.responseText);
299
+ if (xhr.status >= 200 && xhr.status < 300 && json.success) resolve(json);
300
+ else reject(new Error(json.error || xhr.statusText));
301
+ } catch(e) { reject(new Error('Chunk upload failed')); }
302
+ };
303
+ xhr.onerror = () => reject(new Error('Network error'));
304
+ xhr.send(fd);
305
+ });
306
+
307
+ uploadedBytes += (end - start);
308
+ parts.push({ partNumber: i + 1, ETag: result.ETag });
309
+ if (opts.onProgress) opts.onProgress(uploadedBytes / file.size);
310
+ }
311
+
312
+ return apiFetch(`${BASE}/upload-complete`, {
313
+ method: 'POST', credentials: 'include',
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify({ uploadId, key, parts, ...(bucket ? { bucket } : {}) }),
316
+ });
317
+ },
318
+
319
+ uploadFile(prefix, file, opts) {
320
+ if (file.size > MULTIPART_THRESHOLD) {
321
+ return this.uploadMultipart(prefix, file, opts);
322
+ }
323
+ return this.uploadSingle(prefix, file, opts);
324
+ },
325
+
326
+ getSignedUrl(key, opts) {
327
+ opts = opts || {};
328
+ return apiFetch(buildUrl(`${BASE}/api/signed-url`, {
329
+ key,
330
+ bucket: opts.bucket || '',
331
+ expiresIn: opts.expiresIn || 3600,
332
+ }));
333
+ },
334
+ };
335
+
336
+ // ─────────────────────────────────────────────────────────────────────────
337
+ // Gallery Modal (with In-Modal Upload & Multi-Select)
338
+ // ─────────────────────────────────────────────────────────────────────────
339
+
340
+ let _galleryOverlay = null;
341
+ let _galleryState = null;
342
+ let _galleryResolve = null;
343
+
344
+ function buildGalleryDom() {
345
+ const overlay = document.createElement('div');
346
+ overlay.className = 'mbkh-overlay';
347
+ overlay.setAttribute('role', 'dialog');
348
+ overlay.setAttribute('aria-modal', 'true');
349
+ overlay.setAttribute('aria-label', 'Browse Bucket');
350
+
351
+ overlay.innerHTML = `
352
+ <div class="mbkh-modal" id="mbkh-modal">
353
+ <!-- Header -->
354
+ <div class="mbkh-modal-header">
355
+ <div class="mbkh-modal-logo">${icon('bucket', 14)}</div>
356
+ <div class="mbkh-modal-title" id="mbkh-modal-title">Browse Bucket</div>
357
+ <div class="mbkh-header-actions">
358
+ <button class="mbkh-btn-upload-header" id="mbkh-modal-upload-trigger">
359
+ ${icon('plus', 12)} Upload
360
+ </button>
361
+ <input type="file" id="mbkh-modal-file-input" multiple style="display:none" />
362
+ <button class="mbkh-modal-close" id="mbkh-close-btn" aria-label="Close">${icon('close', 14)}</button>
363
+ </div>
364
+ </div>
365
+
366
+ <!-- Toolbar & Breadcrumb -->
367
+ <div class="mbkh-toolbar">
368
+ <nav class="mbkh-breadcrumb" id="mbkh-breadcrumb" aria-label="Path"></nav>
369
+ <div class="mbkh-toolbar-actions">
370
+ <div class="mbkh-search-wrap">
371
+ <span class="mbkh-search-icon">${icon('search', 13)}</span>
372
+ <input class="mbkh-search" id="mbkh-search" type="search" placeholder="Search in folder…" autocomplete="off" aria-label="Search files"/>
373
+ </div>
374
+ <div class="mbkh-view-toggle">
375
+ <button class="mbkh-view-btn mbkh-active" id="mbkh-view-grid" title="Grid view" aria-pressed="true">${icon('grid', 13)}</button>
376
+ <button class="mbkh-view-btn" id="mbkh-view-list" title="List view" aria-pressed="false">${icon('list', 13)}</button>
377
+ </div>
378
+ </div>
379
+ </div>
380
+
381
+ <!-- Filter bar -->
382
+ <div class="mbkh-filter-bar" id="mbkh-filter-bar">
383
+ <button class="mbkh-filter-btn mbkh-active" data-filter="all">All</button>
384
+ <button class="mbkh-filter-btn" data-filter="images">Images</button>
385
+ <button class="mbkh-filter-btn" data-filter="docs">Documents</button>
386
+ <button class="mbkh-filter-btn" data-filter="media">Media</button>
387
+ <button class="mbkh-filter-btn" data-filter="code">Code</button>
388
+ </div>
389
+
390
+ <!-- In-Modal Upload Progress / Drop Banner -->
391
+ <div class="mbkh-modal-upload-banner" id="mbkh-modal-upload-banner" style="display:none">
392
+ <div class="mbkh-upload-banner-text" id="mbkh-upload-banner-text">Uploading…</div>
393
+ <div class="mbkh-upload-banner-bar"><div class="mbkh-upload-banner-fill" id="mbkh-upload-banner-fill"></div></div>
394
+ </div>
395
+
396
+ <!-- Body -->
397
+ <div class="mbkh-modal-body" id="mbkh-modal-body">
398
+ <div class="mbkh-file-grid" id="mbkh-grid" role="listbox" aria-multiselectable="false" aria-label="Files"></div>
399
+ <div class="mbkh-state" id="mbkh-state" style="display:none"></div>
400
+ <div class="mbkh-drag-overlay" id="mbkh-drag-overlay" style="display:none">
401
+ <div class="mbkh-drag-box">
402
+ ${icon('upload', 36)}
403
+ <div class="mbkh-drag-text">Drop files here to upload to this folder</div>
404
+ </div>
405
+ </div>
406
+ </div>
407
+
408
+ <!-- Footer -->
409
+ <div class="mbkh-modal-footer">
410
+ <div class="mbkh-selected-wrap" id="mbkh-sel-wrap">
411
+ <span class="mbkh-selected-placeholder">Click a file to select</span>
412
+ </div>
413
+ <div class="mbkh-footer-btns">
414
+ <button class="mbkh-btn mbkh-btn-ghost" id="mbkh-cancel-btn">Cancel</button>
415
+ <button class="mbkh-btn mbkh-btn-primary" id="mbkh-select-btn" disabled>
416
+ ${icon('check', 13)} <span id="mbkh-select-btn-text">Select File</span>
417
+ </button>
418
+ </div>
419
+ </div>
420
+ </div>
421
+ `;
422
+
423
+ document.body.appendChild(overlay);
424
+ return overlay;
425
+ }
426
+
427
+ function getGalleryEl(id) { return _galleryOverlay && _galleryOverlay.querySelector(`#${id}`); }
428
+
429
+ function setGalleryState(mode, message) {
430
+ const grid = getGalleryEl('mbkh-grid');
431
+ const state = getGalleryEl('mbkh-state');
432
+ if (!grid || !state) return;
433
+
434
+ if (mode === 'loading') {
435
+ grid.style.display = 'none';
436
+ state.style.display = '';
437
+ state.innerHTML = `
438
+ <div class="mbkh-spinner"></div>
439
+ <div class="mbkh-state-text">${esc(message || 'Loading bucket files…')}</div>
440
+ `;
441
+ } else if (mode === 'empty') {
442
+ grid.style.display = 'none';
443
+ state.style.display = '';
444
+ state.innerHTML = `
445
+ <div class="mbkh-state-icon">
446
+ <svg width="48" height="48" viewBox="0 0 32 32" fill="var(--mbkh-primary-lt)" stroke="var(--mbkh-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>
447
+ </div>
448
+ <div class="mbkh-state-title">No files found</div>
449
+ <div class="mbkh-state-sub">${esc(message || 'This folder is empty or no files match the active filter.')}</div>
450
+ `;
451
+ } else if (mode === 'error') {
452
+ grid.style.display = 'none';
453
+ state.style.display = '';
454
+ state.innerHTML = `
455
+ <div class="mbkh-state-icon" style="color:var(--mbkh-danger);">${icon('warn', 40)}</div>
456
+ <div class="mbkh-state-title" style="color:var(--mbkh-danger);">Failed to load files</div>
457
+ <div class="mbkh-state-sub">${esc(message || 'Please check your connection or permissions.')}</div>
458
+ `;
459
+ } else {
460
+ grid.style.display = '';
461
+ state.style.display = 'none';
462
+ }
463
+ }
464
+
465
+ function renderBreadcrumb(prefix, bucket) {
466
+ const bc = getGalleryEl('mbkh-breadcrumb');
467
+ const title = getGalleryEl('mbkh-modal-title');
468
+ if (!bc) return;
469
+
470
+ const parts = (prefix || '').split('/').filter(Boolean);
471
+ const label = bucket ? `Bucket: ${bucket}` : 'My Drive';
472
+ if (title) title.textContent = parts.length ? baseName(parts[parts.length - 1]) : label;
473
+
474
+ let html = `<span class="mbkh-crumb${parts.length === 0 ? ' mbkh-crumb-active' : ''}" data-prefix="">
475
+ ${icon('bucket', 12)} <span class="mbkh-crumb-text">Drive</span>
476
+ </span>`;
477
+ parts.forEach((part, idx) => {
478
+ const seg = parts.slice(0, idx + 1).join('/') + '/';
479
+ const isLast = idx === parts.length - 1;
480
+ html += `<span class="mbkh-crumb-sep">/</span>
481
+ <span class="mbkh-crumb${isLast ? ' mbkh-crumb-active' : ''}" data-prefix="${esc(seg)}" title="${esc(part)}">
482
+ <span class="mbkh-crumb-text">${esc(part)}</span>
483
+ </span>`;
484
+ });
485
+ bc.innerHTML = html;
486
+
487
+ bc.querySelectorAll('[data-prefix]').forEach(el => {
488
+ if (!el.classList.contains('mbkh-crumb-active')) {
489
+ el.addEventListener('click', () => loadGalleryFolder(el.dataset.prefix));
490
+ }
491
+ });
492
+ }
493
+
494
+ function filterItems(files, filterKey) {
495
+ if (!filterKey || filterKey === 'all') return files;
496
+ return files.filter(file => {
497
+ const e = ext(file.Key || file.key || '');
498
+ if (filterKey === 'images') return IMAGE_EXTS.has(e);
499
+ if (filterKey === 'docs') return DOC_EXTS.has(e);
500
+ if (filterKey === 'media') return VIDEO_EXTS.has(e) || AUDIO_EXTS.has(e);
501
+ if (filterKey === 'code') return CODE_EXTS.has(e);
502
+ return true;
503
+ });
504
+ }
505
+
506
+ function renderGalleryItems(files, folders, viewMode, options) {
507
+ const grid = getGalleryEl('mbkh-grid');
508
+ if (!grid) return;
509
+
510
+ grid.innerHTML = '';
511
+ grid.classList.toggle('mbkh-list-mode', viewMode === 'list');
512
+ grid.setAttribute('aria-multiselectable', _galleryState?.multiple ? 'true' : 'false');
513
+
514
+ // Render folders first
515
+ folders.forEach(folderPrefix => {
516
+ const name = baseName(folderPrefix) || folderPrefix;
517
+ const div = document.createElement('div');
518
+ div.className = 'mbkh-item mbkh-card-folder';
519
+ div.setAttribute('role', 'option');
520
+ div.setAttribute('aria-selected', 'false');
521
+ div.dataset.type = 'folder';
522
+ div.dataset.prefix = folderPrefix;
523
+ div.title = `Open folder: ${name}`;
524
+
525
+ if (viewMode === 'list') {
526
+ div.innerHTML = `
527
+ <div class="mbkh-row-icon mbkh-folder-icon">
528
+ <svg viewBox="0 0 32 32" width="18" height="18" fill="var(--mbkh-primary-lt)" stroke="var(--mbkh-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>
529
+ </div>
530
+ <div class="mbkh-row-name">${esc(name)}</div>
531
+ <div class="mbkh-row-badge">Folder</div>
532
+ <div class="mbkh-row-size">—</div>
533
+ `;
534
+ } else {
535
+ div.innerHTML = `
536
+ <div class="mbkh-card-preview mbkh-folder-preview">
537
+ <svg class="mbkh-folder-svg" viewBox="0 0 32 32" fill="var(--mbkh-primary-lt)" stroke="var(--mbkh-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>
538
+ </div>
539
+ <div class="mbkh-card-details">
540
+ <div class="mbkh-card-name" title="${esc(name)}">${esc(name)}</div>
541
+ <div class="mbkh-card-meta">Folder</div>
542
+ </div>
543
+ `;
544
+ }
545
+
546
+ div.addEventListener('click', () => loadGalleryFolder(folderPrefix));
547
+ div.addEventListener('dblclick', () => loadGalleryFolder(folderPrefix));
548
+ grid.appendChild(div);
549
+ });
550
+
551
+ // Render files
552
+ files.forEach(fileObj => {
553
+ const key = fileObj.Key || fileObj.key || '';
554
+ const name = baseName(key);
555
+ const size = fileObj.Size || fileObj.ContentLength || 0;
556
+ const e = ext(key);
557
+ const tinfo = fileTypeInfo(name);
558
+ const isImg = IMAGE_EXTS.has(e);
559
+ const isSelected = _galleryState?.selectedMap?.has(key);
560
+
561
+ const div = document.createElement('div');
562
+ div.className = `mbkh-item mbkh-card-file${isSelected ? ' mbkh-selected' : ''}`;
563
+ div.setAttribute('role', 'option');
564
+ div.setAttribute('aria-selected', isSelected ? 'true' : 'false');
565
+ div.dataset.type = 'file';
566
+ div.dataset.key = key;
567
+ div.dataset.name = name;
568
+ div.dataset.size = size;
569
+ div.title = `${name} (${formatBytes(size)})`;
570
+
571
+ const thumbSrc = isImg ? url.view(key, _galleryState?.bucket) : null;
572
+ const fileData = { key, name, size, thumbSrc, tinfo, url: thumbSrc || url.download(key, _galleryState?.bucket) };
573
+
574
+ if (viewMode === 'list') {
575
+ let iconHtml;
576
+ if (isImg) {
577
+ iconHtml = `<div class="mbkh-row-icon mbkh-thumb"><img src="${esc(thumbSrc)}" alt="${esc(name)}" loading="lazy"/></div>`;
578
+ } else {
579
+ const fileIco = VIDEO_EXTS.has(e) ? 'video' : AUDIO_EXTS.has(e) ? 'audio' : 'file';
580
+ iconHtml = `<div class="mbkh-row-icon" style="background:${tinfo.bg};color:${tinfo.color};">${icon(fileIco, 16)}</div>`;
581
+ }
582
+
583
+ div.innerHTML = `
584
+ ${iconHtml}
585
+ <div class="mbkh-row-name">${esc(name)}</div>
586
+ <div class="mbkh-row-badge" style="background:${tinfo.bg};color:${tinfo.color};">${esc(tinfo.label)}</div>
587
+ <div class="mbkh-row-size">${formatBytes(size)}</div>
588
+ <div class="mbkh-check-badge">${icon('check', 11)}</div>
589
+ `;
590
+ } else {
591
+ let previewHtml;
592
+ if (isImg) {
593
+ previewHtml = `
594
+ <div class="mbkh-card-preview mbkh-thumb">
595
+ <img src="${esc(thumbSrc)}" alt="${esc(name)}" loading="lazy"/>
596
+ <span class="mbkh-ext-tag">${esc(e.toUpperCase() || 'IMG')}</span>
597
+ </div>
598
+ `;
599
+ } else {
600
+ const fileIco = VIDEO_EXTS.has(e) ? 'video' : AUDIO_EXTS.has(e) ? 'audio' : 'file';
601
+ previewHtml = `
602
+ <div class="mbkh-card-preview" style="background:${tinfo.bg};color:${tinfo.color};">
603
+ ${icon(fileIco, 32)}
604
+ <span class="mbkh-ext-tag" style="background:${tinfo.color};color:#fff;">${esc(tinfo.label)}</span>
605
+ </div>
606
+ `;
607
+ }
608
+
609
+ div.innerHTML = `
610
+ ${previewHtml}
611
+ <div class="mbkh-card-details">
612
+ <div class="mbkh-card-name" title="${esc(name)}">${esc(name)}</div>
613
+ <div class="mbkh-card-meta">${formatBytes(size) || 'File'}</div>
614
+ </div>
615
+ <div class="mbkh-check-badge">${icon('check', 11)}</div>
616
+ `;
617
+ }
618
+
619
+ if (isImg) {
620
+ const imgEl = div.querySelector('img');
621
+ if (imgEl) {
622
+ imgEl.addEventListener('error', () => {
623
+ const parent = imgEl.parentElement;
624
+ if (parent) {
625
+ parent.classList.remove('mbkh-thumb');
626
+ parent.style.background = tinfo.bg;
627
+ parent.style.color = tinfo.color;
628
+ parent.innerHTML = icon('image', 28);
629
+ }
630
+ });
631
+ }
632
+ }
633
+
634
+ div.addEventListener('click', () => toggleGalleryItemSelection(div, fileData));
635
+ div.addEventListener('dblclick', () => {
636
+ if (!_galleryState?.multiple) {
637
+ confirmGallerySelection();
638
+ }
639
+ });
640
+ grid.appendChild(div);
641
+ });
642
+
643
+ // Load-more button if there's a next page
644
+ const state = _galleryState;
645
+ if (state && state.nextToken) {
646
+ const wrap = document.createElement('div');
647
+ wrap.className = 'mbkh-load-more-wrap';
648
+ wrap.innerHTML = `<button class="mbkh-btn mbkh-btn-ghost" id="mbkh-load-more">Load more files…</button>`;
649
+ grid.appendChild(wrap);
650
+ wrap.querySelector('#mbkh-load-more').addEventListener('click', () => loadMoreGallery());
651
+ }
652
+ }
653
+
654
+ function toggleGalleryItemSelection(div, fileData) {
655
+ if (!_galleryState) return;
656
+ const isMulti = !!_galleryState.multiple;
657
+
658
+ if (!isMulti) {
659
+ // Single selection mode
660
+ const grid = getGalleryEl('mbkh-grid');
661
+ if (grid) grid.querySelectorAll('.mbkh-item.mbkh-selected').forEach(el => {
662
+ el.classList.remove('mbkh-selected');
663
+ el.setAttribute('aria-selected', 'false');
664
+ });
665
+
666
+ _galleryState.selectedMap.clear();
667
+ _galleryState.selectedMap.set(fileData.key, fileData);
668
+ div.classList.add('mbkh-selected');
669
+ div.setAttribute('aria-selected', 'true');
670
+ } else {
671
+ // Multi-selection mode
672
+ if (_galleryState.selectedMap.has(fileData.key)) {
673
+ _galleryState.selectedMap.delete(fileData.key);
674
+ div.classList.remove('mbkh-selected');
675
+ div.setAttribute('aria-selected', 'false');
676
+ } else {
677
+ const max = _galleryState.options?.maxFiles;
678
+ if (max && _galleryState.selectedMap.size >= max) {
679
+ toast.warning(`Maximum ${max} files allowed`);
680
+ return;
681
+ }
682
+ _galleryState.selectedMap.set(fileData.key, fileData);
683
+ div.classList.add('mbkh-selected');
684
+ div.setAttribute('aria-selected', 'true');
685
+ }
686
+ }
687
+
688
+ updateGalleryFooterSelection();
689
+ }
690
+
691
+ function updateGalleryFooterSelection() {
692
+ const selWrap = getGalleryEl('mbkh-sel-wrap');
693
+ const selectBtn = getGalleryEl('mbkh-select-btn');
694
+ const btnText = getGalleryEl('mbkh-select-btn-text');
695
+ if (!selWrap || !_galleryState) return;
696
+
697
+ const count = _galleryState.selectedMap.size;
698
+
699
+ if (count === 0) {
700
+ selWrap.innerHTML = `<span class="mbkh-selected-placeholder">Click a file to select</span>`;
701
+ if (selectBtn) selectBtn.disabled = true;
702
+ if (btnText) btnText.textContent = 'Select File';
703
+ return;
704
+ }
705
+
706
+ if (selectBtn) selectBtn.disabled = false;
707
+
708
+ if (!_galleryState.multiple) {
709
+ const item = _galleryState.selectedMap.values().next().value;
710
+ let iconSnippet;
711
+ if (item.thumbSrc) {
712
+ iconSnippet = `<div class="mbkh-sel-thumb"><img src="${esc(item.thumbSrc)}" alt=""/></div>`;
713
+ } else {
714
+ iconSnippet = `<div class="mbkh-sel-badge" style="background:${item.tinfo?.bg || 'var(--mbkh-primary-lt)'};color:${item.tinfo?.color || 'var(--mbkh-primary)'};">${esc(item.tinfo?.label || 'FILE')}</div>`;
715
+ }
716
+ selWrap.innerHTML = `
717
+ ${iconSnippet}
718
+ <div class="mbkh-sel-info">
719
+ <div class="mbkh-sel-name" title="${esc(item.name)}">${esc(item.name)}</div>
720
+ <div class="mbkh-sel-meta">${formatBytes(item.size)}</div>
721
+ </div>
722
+ <button class="mbkh-sel-clear" id="mbkh-sel-clear-btn" title="Deselect">${icon('close', 11)}</button>
723
+ `;
724
+ if (btnText) btnText.textContent = 'Select File';
725
+ } else {
726
+ // Multi mode tray
727
+ const items = Array.from(_galleryState.selectedMap.values());
728
+ const maxDisplay = 3;
729
+ const visible = items.slice(0, maxDisplay);
730
+ const remaining = items.length - maxDisplay;
731
+
732
+ let chipsHtml = visible.map(it => `
733
+ <div class="mbkh-multi-chip" title="${esc(it.name)}">
734
+ <span class="mbkh-multi-chip-name">${esc(it.name)}</span>
735
+ <span class="mbkh-multi-chip-rm" data-rm-key="${esc(it.key)}">${icon('close', 9)}</span>
736
+ </div>
737
+ `).join('');
738
+
739
+ if (remaining > 0) {
740
+ chipsHtml += `<div class="mbkh-multi-chip-more">+${remaining} more</div>`;
741
+ }
742
+
743
+ selWrap.innerHTML = `
744
+ <div class="mbkh-multi-tray">
745
+ <div class="mbkh-multi-count">${count} selected:</div>
746
+ <div class="mbkh-multi-chips">${chipsHtml}</div>
747
+ <button class="mbkh-sel-clear" id="mbkh-sel-clear-btn" title="Clear all">${icon('close', 11)}</button>
748
+ </div>
749
+ `;
750
+
751
+ selWrap.querySelectorAll('[data-rm-key]').forEach(btn => {
752
+ btn.addEventListener('click', (e) => {
753
+ e.stopPropagation();
754
+ const k = btn.dataset.rmKey;
755
+ _galleryState.selectedMap.delete(k);
756
+ const grid = getGalleryEl('mbkh-grid');
757
+ const card = grid?.querySelector(`[data-key="${CSS.escape(k)}"]`);
758
+ if (card) {
759
+ card.classList.remove('mbkh-selected');
760
+ card.setAttribute('aria-selected', 'false');
761
+ }
762
+ updateGalleryFooterSelection();
763
+ });
764
+ });
765
+
766
+ if (btnText) btnText.textContent = `Select ${count} File${count !== 1 ? 's' : ''}`;
767
+ }
768
+
769
+ selWrap.querySelector('#mbkh-sel-clear-btn')?.addEventListener('click', (e) => {
770
+ e.stopPropagation();
771
+ clearGallerySelection();
772
+ });
773
+ }
774
+
775
+ function clearGallerySelection() {
776
+ if (_galleryState) {
777
+ _galleryState.selectedMap.clear();
778
+ }
779
+ const grid = getGalleryEl('mbkh-grid');
780
+ if (grid) grid.querySelectorAll('.mbkh-item.mbkh-selected').forEach(el => {
781
+ el.classList.remove('mbkh-selected');
782
+ el.setAttribute('aria-selected', 'false');
783
+ });
784
+ updateGalleryFooterSelection();
785
+ }
786
+
787
+ function confirmGallerySelection() {
788
+ if (!_galleryState || !_galleryState.selectedMap.size) return;
789
+ if (_galleryState.multiple) {
790
+ const items = Array.from(_galleryState.selectedMap.values());
791
+ closeGallery(items);
792
+ } else {
793
+ const item = _galleryState.selectedMap.values().next().value;
794
+ closeGallery(item);
795
+ }
796
+ }
797
+
798
+ async function loadGalleryFolder(prefix, token, autoSelectKey) {
799
+ if (!_galleryState) return;
800
+ _galleryState.prefix = prefix || '';
801
+ _galleryState.nextToken = null;
802
+
803
+ if (!autoSelectKey) {
804
+ clearGallerySelection();
805
+ }
806
+ renderBreadcrumb(prefix, _galleryState.bucket);
807
+ setGalleryState('loading');
808
+
809
+ const searchEl = getGalleryEl('mbkh-search');
810
+
811
+ try {
812
+ const result = await api.listFiles(prefix, {
813
+ bucket: _galleryState.bucket,
814
+ search: searchEl ? searchEl.value.trim() : '',
815
+ token: token || '',
816
+ recursive: false,
817
+ });
818
+
819
+ _galleryState.nextToken = result.nextContinuationToken || null;
820
+ _galleryState.rawFiles = result.files || [];
821
+ _galleryState.rawFolders = result.folders || [];
822
+
823
+ applyGalleryFilter();
824
+
825
+ if (autoSelectKey) {
826
+ const grid = getGalleryEl('mbkh-grid');
827
+ const card = grid?.querySelector(`[data-key="${CSS.escape(autoSelectKey)}"]`);
828
+ if (card) {
829
+ const name = baseName(autoSelectKey);
830
+ const tinfo = fileTypeInfo(name);
831
+ const thumbSrc = IMAGE_EXTS.has(ext(name)) ? url.view(autoSelectKey, _galleryState.bucket) : null;
832
+ toggleGalleryItemSelection(card, { key: autoSelectKey, name, size: 0, thumbSrc, tinfo, url: thumbSrc || url.download(autoSelectKey, _galleryState.bucket) });
833
+ card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
834
+ }
835
+ }
836
+ } catch (err) {
837
+ setGalleryState('error', err.message || 'Failed to load files');
838
+ }
839
+ }
840
+
841
+ function applyGalleryFilter() {
842
+ if (!_galleryState) return;
843
+ const filterKey = _galleryState.activeFilter || 'all';
844
+ const filteredFiles = filterItems(_galleryState.rawFiles || [], filterKey);
845
+ const folders = (filterKey === 'all') ? (_galleryState.rawFolders || []) : [];
846
+
847
+ if (!filteredFiles.length && !folders.length) {
848
+ setGalleryState('empty');
849
+ return;
850
+ }
851
+
852
+ setGalleryState('content');
853
+ renderGalleryItems(filteredFiles, folders, _galleryState.viewMode, _galleryState.options);
854
+ }
855
+
856
+ async function loadMoreGallery() {
857
+ if (!_galleryState || !_galleryState.nextToken) return;
858
+ const grid = getGalleryEl('mbkh-grid');
859
+ const btn = grid && grid.querySelector('#mbkh-load-more');
860
+ if (btn) { btn.textContent = 'Loading…'; btn.disabled = true; }
861
+
862
+ try {
863
+ const result = await api.listFiles(_galleryState.prefix, {
864
+ bucket: _galleryState.bucket,
865
+ token: _galleryState.nextToken,
866
+ recursive: false,
867
+ });
868
+
869
+ _galleryState.nextToken = result.nextContinuationToken || null;
870
+
871
+ const oldWrap = grid && grid.querySelector('.mbkh-load-more-wrap');
872
+ if (oldWrap) oldWrap.remove();
873
+
874
+ const files = result.files || [];
875
+ const folders = result.folders || [];
876
+
877
+ _galleryState.rawFiles = [...(_galleryState.rawFiles || []), ...files];
878
+ _galleryState.rawFolders = [...(_galleryState.rawFolders || []), ...folders];
879
+
880
+ applyGalleryFilter();
881
+ } catch (err) {
882
+ if (btn) { btn.textContent = 'Retry'; btn.disabled = false; }
883
+ toast.error(err.message || 'Failed to load more');
884
+ }
885
+ }
886
+
887
+ // In-Modal Upload Handler
888
+ async function handleInModalUpload(files) {
889
+ if (!files || !files.length || !_galleryState) return;
890
+ const banner = getGalleryEl('mbkh-modal-upload-banner');
891
+ const textEl = getGalleryEl('mbkh-upload-banner-text');
892
+ const fillEl = getGalleryEl('mbkh-upload-banner-fill');
893
+ if (banner) banner.style.display = 'flex';
894
+
895
+ let lastUploadedKey = null;
896
+ const total = files.length;
897
+
898
+ for (let i = 0; i < total; i++) {
899
+ const file = files[i];
900
+ if (textEl) textEl.textContent = `Uploading ${file.name} (${i + 1}/${total})…`;
901
+ if (fillEl) fillEl.style.width = '0%';
902
+
903
+ try {
904
+ const res = await api.uploadFile(_galleryState.prefix || '', file, {
905
+ bucket: _galleryState.bucket || '',
906
+ onProgress: p => {
907
+ if (fillEl) fillEl.style.width = `${Math.round(p * 100)}%`;
908
+ }
909
+ });
910
+ lastUploadedKey = res.key;
911
+ toast.success(`Uploaded: ${file.name}`);
912
+ } catch (err) {
913
+ toast.error(`Failed ${file.name}: ${err.message}`);
914
+ }
915
+ }
916
+
917
+ if (banner) {
918
+ if (fillEl) fillEl.style.width = '100%';
919
+ setTimeout(() => { banner.style.display = 'none'; }, 400);
920
+ }
921
+
922
+ // Refresh folder and auto-select
923
+ await loadGalleryFolder(_galleryState.prefix, null, lastUploadedKey);
924
+ }
925
+
926
+ function openGallery(options) {
927
+ options = options || {};
928
+ return new Promise(resolve => {
929
+ if (!_galleryOverlay) {
930
+ _galleryOverlay = buildGalleryDom();
931
+ attachGalleryEvents();
932
+ }
933
+
934
+ _galleryState = {
935
+ prefix: options.prefix || '',
936
+ bucket: options.bucket || '',
937
+ multiple: !!options.multiple,
938
+ viewMode: options.defaultView || localStorage.getItem('mbkh_view') || 'grid',
939
+ selectedMap: new Map(),
940
+ nextToken: null,
941
+ options,
942
+ };
943
+ _galleryResolve = resolve;
944
+
945
+ // Restore view mode
946
+ const gridBtn = getGalleryEl('mbkh-view-grid');
947
+ const listBtn = getGalleryEl('mbkh-view-list');
948
+ if (gridBtn && listBtn) {
949
+ const isGrid = _galleryState.viewMode !== 'list';
950
+ gridBtn.classList.toggle('mbkh-active', isGrid);
951
+ listBtn.classList.toggle('mbkh-active', !isGrid);
952
+ gridBtn.setAttribute('aria-pressed', isGrid ? 'true' : 'false');
953
+ listBtn.setAttribute('aria-pressed', isGrid ? 'false' : 'true');
954
+ }
955
+
956
+ const filterBar = getGalleryEl('mbkh-filter-bar');
957
+ if (filterBar) {
958
+ _galleryState.activeFilter = 'all';
959
+ filterBar.querySelectorAll('[data-filter]').forEach(b => b.classList.toggle('mbkh-active', b.dataset.filter === 'all'));
960
+ }
961
+
962
+ const searchEl = getGalleryEl('mbkh-search');
963
+ if (searchEl) searchEl.value = '';
964
+
965
+ _galleryOverlay.style.display = '';
966
+ _galleryOverlay.classList.remove('mbkh-closing');
967
+ document.body.style.overflow = 'hidden';
968
+
969
+ loadGalleryFolder(_galleryState.prefix);
970
+ });
971
+ }
972
+
973
+ function closeGallery(result) {
974
+ if (!_galleryOverlay) return;
975
+ _galleryOverlay.classList.add('mbkh-closing');
976
+ document.body.style.overflow = '';
977
+
978
+ setTimeout(() => {
979
+ if (_galleryOverlay) _galleryOverlay.style.display = 'none';
980
+ if (_galleryResolve) {
981
+ const r = _galleryResolve;
982
+ _galleryResolve = null;
983
+ r(result || null);
984
+ }
985
+ }, 180);
986
+ }
987
+
988
+ function attachGalleryEvents() {
989
+ if (!_galleryOverlay) return;
990
+
991
+ // Close on overlay click
992
+ _galleryOverlay.addEventListener('click', e => {
993
+ if (e.target === _galleryOverlay) closeGallery(null);
994
+ });
995
+
996
+ // Close button
997
+ getGalleryEl('mbkh-close-btn')?.addEventListener('click', () => closeGallery(null));
998
+ getGalleryEl('mbkh-cancel-btn')?.addEventListener('click', () => closeGallery(null));
999
+ getGalleryEl('mbkh-select-btn')?.addEventListener('click', () => confirmGallerySelection());
1000
+
1001
+ // In-modal Upload Trigger
1002
+ const fileInput = getGalleryEl('mbkh-modal-file-input');
1003
+ const uploadTrigger = getGalleryEl('mbkh-modal-upload-trigger');
1004
+ uploadTrigger?.addEventListener('click', () => fileInput?.click());
1005
+ fileInput?.addEventListener('change', () => {
1006
+ if (fileInput.files && fileInput.files.length) {
1007
+ handleInModalUpload(Array.from(fileInput.files));
1008
+ fileInput.value = '';
1009
+ }
1010
+ });
1011
+
1012
+ // In-modal Drag and Drop
1013
+ const modalBody = getGalleryEl('mbkh-modal-body');
1014
+ const dragOverlay = getGalleryEl('mbkh-drag-overlay');
1015
+ let dragCounter = 0;
1016
+
1017
+ modalBody?.addEventListener('dragenter', e => {
1018
+ e.preventDefault();
1019
+ dragCounter++;
1020
+ if (dragOverlay) dragOverlay.style.display = 'flex';
1021
+ });
1022
+ modalBody?.addEventListener('dragleave', e => {
1023
+ e.preventDefault();
1024
+ dragCounter--;
1025
+ if (dragCounter <= 0 && dragOverlay) {
1026
+ dragOverlay.style.display = 'none';
1027
+ dragCounter = 0;
1028
+ }
1029
+ });
1030
+ modalBody?.addEventListener('dragover', e => { e.preventDefault(); });
1031
+ modalBody?.addEventListener('drop', e => {
1032
+ e.preventDefault();
1033
+ dragCounter = 0;
1034
+ if (dragOverlay) dragOverlay.style.display = 'none';
1035
+ const files = e.dataTransfer && e.dataTransfer.files;
1036
+ if (files && files.length) {
1037
+ handleInModalUpload(Array.from(files));
1038
+ }
1039
+ });
1040
+
1041
+ // Filter buttons
1042
+ const filterBar = getGalleryEl('mbkh-filter-bar');
1043
+ filterBar?.addEventListener('click', e => {
1044
+ const btn = e.target.closest('[data-filter]');
1045
+ if (!btn || !_galleryState) return;
1046
+ _galleryState.activeFilter = btn.dataset.filter;
1047
+ filterBar.querySelectorAll('[data-filter]').forEach(b => b.classList.toggle('mbkh-active', b === btn));
1048
+ applyGalleryFilter();
1049
+ });
1050
+
1051
+ // Search (debounced)
1052
+ const searchEl = getGalleryEl('mbkh-search');
1053
+ let searchTimer = null;
1054
+ searchEl?.addEventListener('input', () => {
1055
+ clearTimeout(searchTimer);
1056
+ searchTimer = setTimeout(() => {
1057
+ loadGalleryFolder(_galleryState ? _galleryState.prefix : '');
1058
+ }, 350);
1059
+ });
1060
+
1061
+ // View toggle
1062
+ getGalleryEl('mbkh-view-grid')?.addEventListener('click', () => {
1063
+ _galleryState.viewMode = 'grid';
1064
+ localStorage.setItem('mbkh_view', 'grid');
1065
+ getGalleryEl('mbkh-view-grid')?.classList.add('mbkh-active');
1066
+ getGalleryEl('mbkh-view-list')?.classList.remove('mbkh-active');
1067
+ loadGalleryFolder(_galleryState.prefix);
1068
+ });
1069
+ getGalleryEl('mbkh-view-list')?.addEventListener('click', () => {
1070
+ _galleryState.viewMode = 'list';
1071
+ localStorage.setItem('mbkh_view', 'list');
1072
+ getGalleryEl('mbkh-view-list')?.classList.add('mbkh-active');
1073
+ getGalleryEl('mbkh-view-grid')?.classList.remove('mbkh-active');
1074
+ loadGalleryFolder(_galleryState.prefix);
1075
+ });
1076
+
1077
+ // Keyboard navigation
1078
+ _galleryOverlay.addEventListener('keydown', e => {
1079
+ if (e.key === 'Escape') closeGallery(null);
1080
+ if (e.key === 'Enter' && _galleryState && _galleryState.selectedMap.size) confirmGallerySelection();
1081
+ });
1082
+ }
1083
+
1084
+ // ─────────────────────────────────────────────────────────────────────────
1085
+ // File Picker (Input wrapper with Live Preview & Multi-Select support)
1086
+ // ─────────────────────────────────────────────────────────────────────────
1087
+
1088
+ /**
1089
+ * Wraps an existing <input> element with a styled picker widget.
1090
+ *
1091
+ * @param {HTMLInputElement|string} inputEl
1092
+ * @param {{ bucket?: string, prefix?: string, placeholder?: string,
1093
+ * preview?: string, multiple?: boolean, onSelect?: Function }} options
1094
+ */
1095
+ function createPicker(inputEl, options) {
1096
+ if (typeof inputEl === 'string') inputEl = document.querySelector(inputEl);
1097
+ if (!inputEl) throw new Error('[MBKBucket] createPicker: input element not found');
1098
+ options = options || {};
1099
+
1100
+ const isMulti = options.multiple !== undefined ? options.multiple : (inputEl.dataset.mbkMultiple === 'true');
1101
+ const previewSel = options.preview || inputEl.dataset.mbkPreview || '';
1102
+
1103
+ // Build wrapper
1104
+ const wrap = document.createElement('div');
1105
+ wrap.className = 'mbkh-picker';
1106
+ wrap.id = inputEl.id ? `mbkh-picker-${inputEl.id}` : `mbkh-picker-${Date.now()}`;
1107
+
1108
+ // Hidden real input
1109
+ const hidden = document.createElement('input');
1110
+ hidden.type = 'hidden';
1111
+ hidden.name = inputEl.name || '';
1112
+ hidden.id = inputEl.id || '';
1113
+ hidden.value = inputEl.value || '';
1114
+ inputEl.removeAttribute('id');
1115
+
1116
+ // Display input
1117
+ const display = document.createElement('input');
1118
+ display.type = 'text';
1119
+ display.className = 'mbkh-picker-value';
1120
+ display.readOnly = true;
1121
+ display.placeholder = options.placeholder || (isMulti ? 'Select files from bucket…' : 'Select a file from bucket…');
1122
+ display.value = hidden.value;
1123
+ display.setAttribute('aria-label', 'Selected file key(s)');
1124
+
1125
+ const actions = document.createElement('div');
1126
+ actions.className = 'mbkh-picker-actions';
1127
+
1128
+ const browseBtn = document.createElement('button');
1129
+ browseBtn.type = 'button';
1130
+ browseBtn.className = 'mbkh-picker-btn';
1131
+ browseBtn.innerHTML = `${icon('browse', 13)} Browse`;
1132
+
1133
+ const clearBtn = document.createElement('button');
1134
+ clearBtn.type = 'button';
1135
+ clearBtn.className = 'mbkh-picker-clear';
1136
+ clearBtn.title = 'Clear selection';
1137
+ clearBtn.innerHTML = icon('close', 11);
1138
+ clearBtn.hidden = !hidden.value;
1139
+ clearBtn.setAttribute('aria-label', 'Clear selection');
1140
+
1141
+ actions.appendChild(browseBtn);
1142
+ actions.appendChild(clearBtn);
1143
+
1144
+ wrap.appendChild(display);
1145
+ wrap.appendChild(actions);
1146
+
1147
+ inputEl.parentNode.insertBefore(wrap, inputEl);
1148
+ inputEl.parentNode.insertBefore(hidden, wrap.nextSibling);
1149
+ inputEl.remove();
1150
+
1151
+ // Cache initial preview element state if present
1152
+ let previewEl = null;
1153
+ let originalPreviewSrc = null;
1154
+ if (previewSel) {
1155
+ previewEl = document.querySelector(previewSel);
1156
+ if (previewEl) {
1157
+ if (previewEl.tagName.toLowerCase() === 'img') {
1158
+ originalPreviewSrc = previewEl.getAttribute('data-mbk-original-src') || previewEl.getAttribute('src') || '';
1159
+ if (!previewEl.hasAttribute('data-mbk-original-src')) previewEl.setAttribute('data-mbk-original-src', originalPreviewSrc);
1160
+ }
1161
+ }
1162
+ }
1163
+
1164
+ function updateLivePreview(keyOrKeys) {
1165
+ if (!previewEl) return;
1166
+ const key = Array.isArray(keyOrKeys) ? keyOrKeys[0] : keyOrKeys;
1167
+ if (key) {
1168
+ const viewUrl = url.view(key, options.bucket);
1169
+ if (previewEl.tagName.toLowerCase() === 'img') {
1170
+ previewEl.src = viewUrl;
1171
+ } else {
1172
+ previewEl.style.backgroundImage = `url("${viewUrl}")`;
1173
+ previewEl.style.backgroundSize = 'cover';
1174
+ previewEl.style.backgroundPosition = 'center';
1175
+ }
1176
+ } else {
1177
+ if (previewEl.tagName.toLowerCase() === 'img') {
1178
+ previewEl.src = originalPreviewSrc || '';
1179
+ } else {
1180
+ previewEl.style.backgroundImage = '';
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ // Set initial preview if input had pre-filled value
1186
+ if (hidden.value) {
1187
+ updateLivePreview(hidden.value);
1188
+ }
1189
+
1190
+ function setValue(val) {
1191
+ let stringVal = '';
1192
+ if (Array.isArray(val)) {
1193
+ stringVal = val.map(v => typeof v === 'object' ? v.key : v).filter(Boolean).join(',');
1194
+ } else if (typeof val === 'object' && val !== null) {
1195
+ stringVal = val.key || '';
1196
+ } else {
1197
+ stringVal = String(val || '');
1198
+ }
1199
+
1200
+ hidden.value = stringVal;
1201
+ display.value = stringVal;
1202
+ clearBtn.hidden = !stringVal;
1203
+ display.placeholder = stringVal ? '' : (options.placeholder || 'Select a file from bucket…');
1204
+
1205
+ updateLivePreview(stringVal);
1206
+ }
1207
+
1208
+ browseBtn.addEventListener('click', async () => {
1209
+ const result = await openGallery({
1210
+ prefix: options.prefix || '',
1211
+ bucket: options.bucket || '',
1212
+ multiple: isMulti,
1213
+ });
1214
+
1215
+ if (result) {
1216
+ setValue(result);
1217
+ hidden.dispatchEvent(new Event('change', { bubbles: true }));
1218
+ if (options.onSelect) options.onSelect(result);
1219
+ }
1220
+ });
1221
+
1222
+ clearBtn.addEventListener('click', () => {
1223
+ setValue('');
1224
+ hidden.dispatchEvent(new Event('change', { bubbles: true }));
1225
+ if (options.onSelect) options.onSelect(null);
1226
+ });
1227
+
1228
+ return {
1229
+ el: wrap,
1230
+ getValue: () => hidden.value,
1231
+ setValue,
1232
+ };
1233
+ }
1234
+
1235
+ // ─────────────────────────────────────────────────────────────────────────
1236
+ // Upload Widget
1237
+ // ─────────────────────────────────────────────────────────────────────────
1238
+
1239
+ function createUploader(containerEl, options) {
1240
+ if (typeof containerEl === 'string') containerEl = document.querySelector(containerEl);
1241
+ if (!containerEl) throw new Error('[MBKBucket] createUploader: container element not found');
1242
+ options = options || {};
1243
+
1244
+ const multiple = options.multiple !== false;
1245
+ const accept = options.accept || '';
1246
+
1247
+ const root = document.createElement('div');
1248
+ root.className = 'mbkh-uploader';
1249
+
1250
+ const dropzone = document.createElement('div');
1251
+ dropzone.className = 'mbkh-dropzone';
1252
+ dropzone.setAttribute('role', 'button');
1253
+ dropzone.setAttribute('tabindex', '0');
1254
+ dropzone.setAttribute('aria-label', 'Upload files — drag and drop or click to browse');
1255
+ dropzone.innerHTML = `
1256
+ <div class="mbkh-dropzone-icon">${icon('upload', 24)}</div>
1257
+ <div class="mbkh-dropzone-title">Drag &amp; drop files here</div>
1258
+ <div class="mbkh-dropzone-sub">or <strong>browse your device</strong></div>
1259
+ ${accept ? `<div style="font-size:11px;color:var(--mbkh-text-dim);margin-top:2px;">Accepted: ${esc(accept)}</div>` : ''}`;
1260
+
1261
+ const fileInput = document.createElement('input');
1262
+ fileInput.type = 'file';
1263
+ fileInput.multiple = multiple;
1264
+ fileInput.accept = accept;
1265
+ fileInput.style.display = 'none';
1266
+
1267
+ const listEl = document.createElement('div');
1268
+ listEl.className = 'mbkh-upload-list';
1269
+
1270
+ const actionsEl = document.createElement('div');
1271
+ actionsEl.className = 'mbkh-upload-actions';
1272
+ actionsEl.style.display = 'none';
1273
+ actionsEl.innerHTML = `
1274
+ <span class="mbkh-upload-summary" id="mbkh-upl-summary"></span>
1275
+ <button type="button" class="mbkh-btn mbkh-btn-ghost" id="mbkh-upl-clear">Clear done</button>
1276
+ <button type="button" class="mbkh-btn mbkh-btn-primary" id="mbkh-upl-start">Upload All</button>`;
1277
+
1278
+ root.appendChild(dropzone);
1279
+ root.appendChild(fileInput);
1280
+ root.appendChild(listEl);
1281
+ root.appendChild(actionsEl);
1282
+ containerEl.appendChild(root);
1283
+
1284
+ let queue = [];
1285
+
1286
+ function updateSummary() {
1287
+ const total = queue.length;
1288
+ const done = queue.filter(f => f.state === 'done').length;
1289
+ const errors = queue.filter(f => f.state === 'error').length;
1290
+ const pending = queue.filter(f => f.state === 'pending').length;
1291
+
1292
+ const summaryEl = root.querySelector('#mbkh-upl-summary');
1293
+ if (summaryEl) {
1294
+ summaryEl.textContent = `${done}/${total} uploaded${errors ? ` • ${errors} failed` : ''}${pending ? ` • ${pending} pending` : ''}`;
1295
+ }
1296
+ actionsEl.style.display = total ? '' : 'none';
1297
+ }
1298
+
1299
+ function renderItem(entry) {
1300
+ const existing = root.querySelector(`[data-upload-id="${CSS.escape(entry.id)}"]`);
1301
+ if (existing) { updateUploadItemEl(existing, entry); return; }
1302
+
1303
+ const e = ext(entry.file.name);
1304
+ const ti = fileTypeInfo(entry.file.name);
1305
+
1306
+ const div = document.createElement('div');
1307
+ div.className = 'mbkh-upload-item';
1308
+ div.dataset.uploadId = entry.id;
1309
+ div.innerHTML = `
1310
+ <div class="mbkh-upload-file-icon" style="background:${ti.bg};color:${ti.color}">${ti.label.slice(0,4)}</div>
1311
+ <div class="mbkh-upload-info">
1312
+ <div class="mbkh-upload-name" title="${esc(entry.file.name)}">${esc(entry.file.name)}</div>
1313
+ <div class="mbkh-upload-progress-row">
1314
+ <div class="mbkh-progress-bar-wrap"><div class="mbkh-progress-bar" style="width:0%"></div></div>
1315
+ <span class="mbkh-upload-pct">0%</span>
1316
+ </div>
1317
+ <div class="mbkh-upload-status">${formatBytes(entry.file.size)}</div>
1318
+ </div>
1319
+ <button class="mbkh-upload-remove" title="Remove" aria-label="Remove file">${icon('trash', 13)}</button>`;
1320
+
1321
+ div.querySelector('.mbkh-upload-remove').addEventListener('click', () => {
1322
+ queue = queue.filter(q => q.id !== entry.id);
1323
+ div.remove();
1324
+ updateSummary();
1325
+ });
1326
+
1327
+ listEl.appendChild(div);
1328
+ }
1329
+
1330
+ function updateUploadItemEl(el, entry) {
1331
+ const bar = el.querySelector('.mbkh-progress-bar');
1332
+ const pct = el.querySelector('.mbkh-upload-pct');
1333
+ const status = el.querySelector('.mbkh-upload-status');
1334
+ const p = Math.round((entry.progress || 0) * 100);
1335
+
1336
+ if (bar) bar.style.width = p + '%';
1337
+ if (pct) pct.textContent = p + '%';
1338
+
1339
+ el.classList.remove('mbkh-done', 'mbkh-error');
1340
+ if (entry.state === 'done') {
1341
+ el.classList.add('mbkh-done');
1342
+ if (status) status.textContent = '✓ Uploaded';
1343
+ if (pct) pct.textContent = '100%';
1344
+ if (bar) bar.style.width = '100%';
1345
+ const rmBtn = el.querySelector('.mbkh-upload-remove');
1346
+ if (rmBtn) rmBtn.disabled = false;
1347
+ } else if (entry.state === 'error') {
1348
+ el.classList.add('mbkh-error');
1349
+ if (status) status.textContent = '✕ ' + (entry.error || 'Failed');
1350
+ } else if (entry.state === 'uploading') {
1351
+ if (status) status.textContent = 'Uploading…';
1352
+ const rmBtn = el.querySelector('.mbkh-upload-remove');
1353
+ if (rmBtn) rmBtn.disabled = true;
1354
+ }
1355
+ }
1356
+
1357
+ function addFiles(files) {
1358
+ const arr = Array.from(files);
1359
+ if (!multiple && arr.length > 0) {
1360
+ queue = [];
1361
+ listEl.innerHTML = '';
1362
+ }
1363
+ arr.forEach(file => {
1364
+ const id = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1365
+ const entry = { file, id, state: 'pending', progress: 0 };
1366
+ queue.push(entry);
1367
+ renderItem(entry);
1368
+ });
1369
+ updateSummary();
1370
+ }
1371
+
1372
+ async function startUploadAll() {
1373
+ const pending = queue.filter(e => e.state === 'pending' || e.state === 'error');
1374
+ if (!pending.length) {
1375
+ toast.info('No pending files to upload');
1376
+ return;
1377
+ }
1378
+
1379
+ const startBtn = root.querySelector('#mbkh-upl-start');
1380
+ if (startBtn) { startBtn.disabled = true; startBtn.textContent = 'Uploading…'; }
1381
+
1382
+ for (const entry of pending) {
1383
+ entry.state = 'uploading';
1384
+ entry.progress = 0;
1385
+ const el = root.querySelector(`[data-upload-id="${CSS.escape(entry.id)}"]`);
1386
+ if (el) updateUploadItemEl(el, entry);
1387
+
1388
+ try {
1389
+ const result = await api.uploadFile(options.prefix || '', entry.file, {
1390
+ bucket: options.bucket || '',
1391
+ onProgress: p => {
1392
+ entry.progress = p;
1393
+ const el2 = root.querySelector(`[data-upload-id="${CSS.escape(entry.id)}"]`);
1394
+ if (el2) updateUploadItemEl(el2, entry);
1395
+ },
1396
+ });
1397
+
1398
+ entry.state = 'done';
1399
+ entry.key = result.key || '';
1400
+ const el2 = root.querySelector(`[data-upload-id="${CSS.escape(entry.id)}"]`);
1401
+ if (el2) updateUploadItemEl(el2, entry);
1402
+
1403
+ if (options.onSuccess) options.onSuccess({ file: entry.file, key: entry.key, result });
1404
+ toast.success(`Uploaded: ${entry.file.name}`);
1405
+ } catch (err) {
1406
+ entry.state = 'error';
1407
+ entry.error = err.message || 'Upload failed';
1408
+ const el2 = root.querySelector(`[data-upload-id="${CSS.escape(entry.id)}"]`);
1409
+ if (el2) updateUploadItemEl(el2, entry);
1410
+
1411
+ if (options.onError) options.onError({ file: entry.file, error: err });
1412
+ toast.error(`Failed: ${entry.file.name} — ${err.message}`);
1413
+ }
1414
+ updateSummary();
1415
+ }
1416
+
1417
+ if (startBtn) { startBtn.disabled = false; startBtn.textContent = 'Upload All'; }
1418
+ }
1419
+
1420
+ dropzone.addEventListener('click', () => fileInput.click());
1421
+ dropzone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
1422
+ dropzone.querySelector('strong')?.addEventListener('click', e => {
1423
+ e.stopPropagation();
1424
+ fileInput.click();
1425
+ });
1426
+
1427
+ fileInput.addEventListener('change', () => {
1428
+ if (fileInput.files && fileInput.files.length) {
1429
+ addFiles(fileInput.files);
1430
+ fileInput.value = '';
1431
+ }
1432
+ });
1433
+
1434
+ dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('mbkh-drag-over'); });
1435
+ dropzone.addEventListener('dragleave', e => { if (!dropzone.contains(e.relatedTarget)) dropzone.classList.remove('mbkh-drag-over'); });
1436
+ dropzone.addEventListener('drop', e => {
1437
+ e.preventDefault();
1438
+ dropzone.classList.remove('mbkh-drag-over');
1439
+ const files = e.dataTransfer && e.dataTransfer.files;
1440
+ if (files && files.length) addFiles(files);
1441
+ });
1442
+
1443
+ root.querySelector('#mbkh-upl-start')?.addEventListener('click', startUploadAll);
1444
+ root.querySelector('#mbkh-upl-clear')?.addEventListener('click', () => {
1445
+ queue = queue.filter(e => e.state !== 'done');
1446
+ listEl.querySelectorAll('.mbkh-upload-item.mbkh-done').forEach(el => el.remove());
1447
+ updateSummary();
1448
+ });
1449
+
1450
+ return {
1451
+ el: root,
1452
+ addFiles: f => addFiles(f instanceof FileList ? f : [f]),
1453
+ clear: () => { queue = []; listEl.innerHTML = ''; updateSummary(); },
1454
+ };
1455
+ }
1456
+
1457
+ // ─────────────────────────────────────────────────────────────────────────
1458
+ // Auto-init via data attributes
1459
+ // ─────────────────────────────────────────────────────────────────────────
1460
+
1461
+ function autoInit() {
1462
+ document.querySelectorAll('input[data-mbk-picker]').forEach(el => {
1463
+ try {
1464
+ createPicker(el, {
1465
+ bucket: el.dataset.mbkBucket || '',
1466
+ prefix: el.dataset.mbkPrefix || '',
1467
+ placeholder: el.dataset.mbkPlaceholder || undefined,
1468
+ preview: el.dataset.mbkPreview || undefined,
1469
+ multiple: el.dataset.mbkMultiple === 'true',
1470
+ });
1471
+ } catch(e) { console.warn('[MBKBucket] Picker init error:', e); }
1472
+ });
1473
+
1474
+ document.querySelectorAll('[data-mbk-uploader]').forEach(el => {
1475
+ try {
1476
+ createUploader(el, {
1477
+ bucket: el.dataset.mbkBucket || '',
1478
+ prefix: el.dataset.mbkPrefix || '',
1479
+ accept: el.dataset.mbkAccept || '',
1480
+ multiple: el.dataset.mbkMultiple !== 'false',
1481
+ });
1482
+ } catch(e) { console.warn('[MBKBucket] Uploader init error:', e); }
1483
+ });
1484
+
1485
+ document.querySelectorAll('[data-mbk-gallery-trigger]').forEach(btn => {
1486
+ const targetSel = btn.dataset.mbkTarget;
1487
+ const previewSel = btn.dataset.mbkPreview;
1488
+ const isMulti = btn.dataset.mbkMultiple === 'true';
1489
+
1490
+ btn.addEventListener('click', async () => {
1491
+ const result = await openGallery({
1492
+ bucket: btn.dataset.mbkBucket || '',
1493
+ prefix: btn.dataset.mbkPrefix || '',
1494
+ multiple: isMulti,
1495
+ });
1496
+ if (result) {
1497
+ if (targetSel) {
1498
+ const target = document.querySelector(targetSel);
1499
+ if (target) {
1500
+ if (Array.isArray(result)) {
1501
+ target.value = result.map(r => r.key).join(',');
1502
+ } else {
1503
+ target.value = result.key;
1504
+ }
1505
+ target.dispatchEvent(new Event('change', { bubbles: true }));
1506
+ }
1507
+ }
1508
+ if (previewSel) {
1509
+ const previewEl = document.querySelector(previewSel);
1510
+ if (previewEl) {
1511
+ const firstKey = Array.isArray(result) ? result[0]?.key : result.key;
1512
+ if (firstKey) {
1513
+ const viewUrl = url.view(firstKey, btn.dataset.mbkBucket);
1514
+ if (previewEl.tagName.toLowerCase() === 'img') {
1515
+ previewEl.src = viewUrl;
1516
+ } else {
1517
+ previewEl.style.backgroundImage = `url("${viewUrl}")`;
1518
+ }
1519
+ }
1520
+ }
1521
+ }
1522
+ }
1523
+ });
1524
+ });
1525
+ }
1526
+
1527
+ // ─────────────────────────────────────────────────────────────────────────
1528
+ // Public API
1529
+ // ─────────────────────────────────────────────────────────────────────────
1530
+
1531
+ const MBKBucket = {
1532
+ version: '1.2.0',
1533
+ openGallery,
1534
+ createPicker,
1535
+ createUploader,
1536
+ url,
1537
+ api,
1538
+ toast,
1539
+ init: autoInit,
1540
+ };
1541
+
1542
+ global.MBKBucket = MBKBucket;
1543
+
1544
+ if (document.readyState === 'loading') {
1545
+ document.addEventListener('DOMContentLoaded', autoInit);
1546
+ } else {
1547
+ autoInit();
1548
+ }
1549
+
1550
+ })(window);