Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 1 | /* ══ FILE EXPLORER VIEW ═══════════════════════════════════════════════════ |
| 2 | * Classic file browser with grid/list/details views, breadcrumb navigation, |
| 3 | * context menu, upload, and file operations. |
| 4 | * ES5 compatible for PS5 browser. |
| 5 | * ═════════════════════════════════════════════════════════════════════════ */ |
| 6 | |
| 7 | var ZFTPD = ZFTPD || {}; |
| 8 | |
| 9 | (function (Z) { |
| 10 | 'use strict'; |
| 11 | |
| 12 | var D = document; |
| 13 | var $ = Z.$; |
| 14 | var E = Z.E; |
| 15 | var ICO = Z.ICO; |
| 16 | |
| 17 | var explorer = {}; |
| 18 | var _view = 'grid'; /* grid | list | details */ |
| 19 | var _sortKey = 'name'; |
| 20 | var _sortAsc = true; |
| 21 | |
| 22 | /* ── Navigate to directory ── */ |
| 23 | var _viewInitialized = false; |
| 24 | explorer.nav = function (path) { |
| 25 | if (!_viewInitialized) { |
| 26 | /* Apply the defaultView user setting initially */ |
| 27 | _view = (Z.settings && Z.settings.defaultView) ? Z.settings.defaultView : 'grid'; |
| 28 | _viewInitialized = true; |
| 29 | } |
| 30 | if (Z.state.transferActive) { |
| 31 | Z.toast('Transfer in progress\u2026', 'wn'); |
| 32 | return; |
| 33 | } |
| 34 | Z.state.path = Z.norm(path); |
| 35 | updatePath(); |
| 36 | renderBreadcrumb(); |
| 37 | |
| 38 | var fl = $('file-list'); |
| 39 | if (fl) fl.innerHTML = '<div class="s-card"><div class="loader"></div><div>Loading\u2026</div></div>'; |
| 40 | |
| 41 | Z.api.list(Z.state.path).then(function (d) { |
| 42 | Z.state.entries = (d && Array.isArray(d.entries)) ? d.entries : []; |
| 43 | sortEntries(); |
| 44 | render($('search') ? $('search').value : ''); |
| 45 | updateStatus(true); |
| 46 | updateCount(); |
| 47 | }).catch(function () { |
| 48 | if (fl) fl.innerHTML = '<div class="s-card s-err"><div class="s-ico">' + ICO.alert + '</div><div>Failed to load directory</div></div>'; |
| 49 | updateStatus(false); |
| 50 | }); |
| 51 | }; |
| 52 | |
| 53 | /* ── Render file list ── */ |
| 54 | function render(query) { |
| 55 | var fl = $('file-list'); |
| 56 | if (!fl) return; |
| 57 | fl.innerHTML = ''; |
| 58 | fl.className = 'fl vg-' + _view; |
| 59 | |
| 60 | query = (query || '').trim().toLowerCase(); |
| 61 | var entries = Z.state.entries || []; |
| 62 | var filtered = entries.filter(function (x) { |
| 63 | /* ── Settings-based filters ── |
| 64 | * Hide dangerous system folders and dotfiles unless the user |
| 65 | * explicitly enabled them in Settings. */ |
| 66 | var isDir = x.type === 'directory'; |
| 67 | if (isDir && !Z.settings.showSystemFolders && Z.isDangerousFolder && Z.isDangerousFolder(x.name)) return false; |
| 68 | if (!Z.settings.showHiddenFiles && Z.isHiddenFile && Z.isHiddenFile(x.name)) return false; |
| 69 | |
| 70 | return !query || x.name.toLowerCase().indexOf(query) >= 0; |
| 71 | }); |
| 72 | |
| 73 | if (!filtered.length) { |
| 74 | fl.innerHTML = '<div class="s-card"><div class="s-ico">' + ICO.folder + '</div><div>Empty directory</div></div>'; |
| 75 | return; |
| 76 | } |
| 77 | |
| 78 | if (_view === 'details') { |
| 79 | renderDetails(fl, filtered); |
| 80 | } else { |
| 81 | for (var i = 0; i < filtered.length; i++) { |
| 82 | var entry = filtered[i]; |
| 83 | var isDir = entry.type === 'directory'; |
| 84 | var path = Z.join(Z.state.path, entry.name); |
| 85 | var cat = Z.fileCategory(entry.name, isDir); |
| 86 | var card = createCard(entry, path, isDir, cat); |
| 87 | fl.appendChild(card); |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /* ── Create a card element ── */ |
| 93 | function createCard(entry, path, isDir, cat) { |
| 94 | var card = D.createElement('div'); |
| 95 | card.className = 'card'; |
| 96 | card.setAttribute('data-path', path); |
| 97 | card.setAttribute('data-dir', isDir ? '1' : '0'); |
| 98 | card.setAttribute('data-name', entry.name); |
| 99 | |
| 100 | var iconHtml = isDir ? ICO.folder : ICO.file; |
| 101 | var iconClass = 'fi-' + cat; |
| 102 | var ext = Z.extname(entry.name); |
| 103 | var sizeStr = isDir ? '' : Z.bytes(entry.size || 0); |
| 104 | |
| 105 | if (_view === 'grid') { |
| 106 | card.innerHTML = |
| 107 | '<div class="c-ico"><div class="fi-wrap ' + iconClass + '">' + iconHtml + '</div></div>' + |
| 108 | '<div class="c-name" title="' + entry.name + '">' + entry.name + '</div>' + |
| 109 | '<div class="c-meta">' + |
| 110 | (ext && !isDir ? '<span class="xb">' + ext + '</span>' : '') + |
| 111 | (sizeStr ? '<span class="sb">' + sizeStr + '</span>' : '') + |
| 112 | '</div>'; |
| 113 | } else { /* list */ |
| 114 | card.innerHTML = |
| 115 | '<div class="c-ico"><div class="fi-wrap ' + iconClass + '">' + iconHtml + '</div></div>' + |
| 116 | '<div class="c-name" title="' + entry.name + '">' + entry.name + '</div>' + |
| 117 | '<div class="c-right">' + |
| 118 | (ext && !isDir ? '<span class="xb">' + ext + '</span>' : '') + |
| 119 | (sizeStr ? '<span class="sb">' + sizeStr + '</span>' : '') + |
| 120 | '</div>'; |
| 121 | } |
| 122 | |
| 123 | /* Click: navigate or download */ |
| 124 | card.onclick = function () { |
| 125 | if (isDir) explorer.nav(path); |
| 126 | else window.location.href = Z.api.downloadUrl(path); |
| 127 | }; |
| 128 | |
| 129 | /* Context menu */ |
| 130 | card.addEventListener('contextmenu', function (ev) { |
| 131 | ev.preventDefault(); |
| 132 | ev.stopPropagation(); |
| 133 | showCtx(ev, entry, path, isDir); |
| 134 | }); |
| 135 | |
| 136 | return card; |
| 137 | } |
| 138 | |
| 139 | /* ── Details/table view ── */ |
| 140 | function renderDetails(fl, entries) { |
| 141 | var wrap = D.createElement('div'); |
| 142 | wrap.className = 'dtbl-wrap'; |
| 143 | var tbl = D.createElement('table'); |
| 144 | tbl.className = 'dtbl'; |
| 145 | |
| 146 | var thead = D.createElement('thead'); |
| 147 | thead.innerHTML = |
| 148 | '<tr><th class="t-ic"></th>' + |
| 149 | '<th data-sort="name">Name</th>' + |
| 150 | '<th class="t-ex" data-sort="ext">Type</th>' + |
| 151 | '<th class="t-sz" data-sort="size">Size</th>' + |
| 152 | '<th class="t-dt" data-sort="mtime">Modified</th></tr>'; |
| 153 | tbl.appendChild(thead); |
| 154 | |
| 155 | /* Sort headers */ |
| 156 | var ths = thead.querySelectorAll('th[data-sort]'); |
| 157 | for (var h = 0; h < ths.length; h++) { |
| 158 | (function (th) { |
| 159 | th.onclick = function () { |
| 160 | var key = th.getAttribute('data-sort'); |
| 161 | if (_sortKey === key) _sortAsc = !_sortAsc; |
| 162 | else { _sortKey = key; _sortAsc = true; } |
| 163 | sortEntries(); |
| 164 | render($('search') ? $('search').value : ''); |
| 165 | }; |
| 166 | })(ths[h]); |
| 167 | } |
| 168 | |
| 169 | var tbody = D.createElement('tbody'); |
| 170 | for (var i = 0; i < entries.length; i++) { |
| 171 | var e = entries[i]; |
| 172 | var isDir = e.type === 'directory'; |
| 173 | var path = Z.join(Z.state.path, e.name); |
| 174 | var cat = Z.fileCategory(e.name, isDir); |
| 175 | var ext = Z.extname(e.name); |
| 176 | |
| 177 | var tr = D.createElement('tr'); |
| 178 | tr.setAttribute('data-path', path); |
| 179 | tr.innerHTML = |
| 180 | '<td class="t-ic"><span class="fi-' + cat + '">' + (isDir ? ICO.folder : ICO.file) + '</span></td>' + |
| 181 | '<td class="t-nm" title="' + e.name + '">' + e.name + '</td>' + |
| 182 | '<td class="t-ex">' + (ext ? '<span class="xb">' + ext + '</span>' : (isDir ? 'Folder' : '\u2014')) + '</td>' + |
| 183 | '<td class="t-sz">' + (isDir ? '\u2014' : Z.bytes(e.size || 0)) + '</td>' + |
| 184 | '<td class="t-dt">' + (e.mtime ? Z.relativeTime(e.mtime) : '\u2014') + '</td>'; |
| 185 | |
| 186 | tr.onclick = function () { |
| 187 | var p = this.getAttribute('data-path'); |
| 188 | var d = this.querySelector('.t-ex'); |
| 189 | var isDirRow = d && d.textContent === 'Folder'; |
| 190 | if (isDirRow) explorer.nav(p); |
| 191 | else window.location.href = Z.api.downloadUrl(p); |
| 192 | }; |
| 193 | |
| 194 | tr.addEventListener('contextmenu', function (ev) { |
| 195 | ev.preventDefault(); |
| 196 | ev.stopPropagation(); |
| 197 | var p = this.getAttribute('data-path'); |
| 198 | var n = this.querySelector('.t-nm').textContent; |
| 199 | var d = this.querySelector('.t-ex').textContent === 'Folder'; |
| 200 | showCtx(ev, { name: n, type: d ? 'directory' : 'file', size: 0 }, p, d); |
| 201 | }); |
| 202 | |
| 203 | tbody.appendChild(tr); |
| 204 | } |
| 205 | tbl.appendChild(tbody); |
| 206 | wrap.appendChild(tbl); |
| 207 | fl.appendChild(wrap); |
| 208 | } |
| 209 | |
| 210 | /* ── Sort entries ── */ |
| 211 | function sortEntries() { |
| 212 | var entries = Z.state.entries; |
| 213 | if (!entries) return; |
| 214 | entries.sort(function (a, b) { |
| 215 | /* Directories first */ |
| 216 | var da = a.type === 'directory' ? 0 : 1; |
| 217 | var db = b.type === 'directory' ? 0 : 1; |
| 218 | if (da !== db) return da - db; |
| 219 | |
| 220 | var va, vb; |
| 221 | if (_sortKey === 'name') { |
| 222 | va = (a.name || '').toLowerCase(); |
| 223 | vb = (b.name || '').toLowerCase(); |
| 224 | return _sortAsc ? (va < vb ? -1 : va > vb ? 1 : 0) : (vb < va ? -1 : vb > va ? 1 : 0); |
| 225 | } |
| 226 | if (_sortKey === 'size') { |
| 227 | va = a.size || 0; |
| 228 | vb = b.size || 0; |
| 229 | return _sortAsc ? va - vb : vb - va; |
| 230 | } |
| 231 | if (_sortKey === 'ext') { |
| 232 | va = Z.extname(a.name || ''); |
| 233 | vb = Z.extname(b.name || ''); |
| 234 | return _sortAsc ? (va < vb ? -1 : va > vb ? 1 : 0) : (vb < va ? -1 : vb > va ? 1 : 0); |
| 235 | } |
| 236 | if (_sortKey === 'mtime') { |
| 237 | va = a.mtime || 0; |
| 238 | vb = b.mtime || 0; |
| 239 | return _sortAsc ? va - vb : vb - va; |
| 240 | } |
| 241 | return 0; |
| 242 | }); |
| 243 | } |
| 244 | |
| 245 | /* ── Breadcrumb ── */ |
| 246 | function renderBreadcrumb() { |
| 247 | var bc = $('breadcrumb'); |
| 248 | if (!bc) return; |
| 249 | bc.innerHTML = ''; |
| 250 | |
| 251 | var root = D.createElement('span'); |
| 252 | root.className = 'crumb' + (Z.state.path === '/' ? ' act' : ''); |
| 253 | root.innerHTML = ICO.home + ' Root'; |
| 254 | root.onclick = function () { explorer.nav('/'); }; |
| 255 | bc.appendChild(root); |
| 256 | |
| 257 | var parts = Z.norm(Z.state.path).split('/'); |
| 258 | var acc = ''; |
| 259 | for (var i = 0; i < parts.length; i++) { |
| 260 | var p = parts[i]; |
| 261 | if (!p) continue; |
| 262 | acc += '/' + p; |
| 263 | var sep = D.createElement('span'); |
| 264 | sep.className = 'cr-sep'; |
| 265 | sep.textContent = '/'; |
| 266 | bc.appendChild(sep); |
| 267 | |
| 268 | var seg = D.createElement('span'); |
| 269 | seg.className = 'crumb' + (acc === Z.state.path ? ' act' : ''); |
| 270 | seg.textContent = p; |
| 271 | (function (cp) { seg.onclick = function () { explorer.nav(cp); }; })(acc); |
| 272 | bc.appendChild(seg); |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | /* ── Context menu ── */ |
| 277 | function showCtx(ev, entry, path, isDir) { |
| 278 | var ctx = $('ctx-menu'); |
| 279 | if (!ctx) return; |
| 280 | ctx.innerHTML = ''; |
| 281 | ctx.style.left = ev.clientX + 'px'; |
| 282 | ctx.style.top = ev.clientY + 'px'; |
| 283 | ctx.classList.add('on'); |
| 284 | |
| 285 | function item(ico, label, red, fn) { |
| 286 | var el = D.createElement('div'); |
| 287 | el.className = 'ci' + (red ? ' red' : ''); |
| 288 | el.innerHTML = '<span class="ci-i">' + ico + '</span><span class="ci-l">' + label + '</span>'; |
| 289 | el.onclick = function () { ctx.classList.remove('on'); fn(); }; |
| 290 | ctx.appendChild(el); |
| 291 | } |
| 292 | |
| 293 | if (entry) { |
| 294 | var sec = D.createElement('div'); |
| 295 | sec.className = 'c-sec'; |
| 296 | sec.textContent = entry.name; |
| 297 | ctx.appendChild(sec); |
| 298 | var sepEl = D.createElement('div'); |
| 299 | sepEl.className = 'c-sep'; |
| 300 | ctx.appendChild(sepEl); |
| 301 | } |
| 302 | |
| 303 | if (!isDir && path) { |
| 304 | item(ICO.download, 'Download', false, function () { |
| 305 | window.location.href = Z.api.downloadUrl(path); |
| 306 | }); |
| 307 | } |
| 308 | if (path) { |
| 309 | item(ICO.edit, 'Rename', false, function () { doRename(path); }); |
| 310 | item(ICO.sendTo, 'Send To\u2026', false, function () { doSendTo(entry, path); }); |
| 311 | |
| 312 | /* Extract option for archives */ |
| 313 | if (entry && !isDir) { |
| 314 | var ext = Z.extname(entry.name); |
| 315 | if (['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].indexOf(ext) >= 0) { |
| 316 | item(ICO.extractBox, 'Extract Here', false, function () { doExtract(path, Z.state.path); }); |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | item(ICO.trash, 'Delete', true, function () { doDelete(path, false); }); |
| 321 | if (isDir) { |
| 322 | item(ICO.trash, 'Delete (recursive)', true, function () { doDelete(path, true); }); |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | if (!entry) { |
| 327 | /* Background context menu */ |
| 328 | item(ICO.newFile, 'New File', false, function () { doCreateFile(); }); |
| 329 | item(ICO.newFolder, 'New Folder', false, function () { doCreateDir(); }); |
| 330 | item(ICO.refresh, 'Refresh', false, function () { explorer.nav(Z.state.path); }); |
| 331 | } |
| 332 | |
| 333 | /* Dismiss on click outside */ |
| 334 | setTimeout(function () { |
| 335 | D.addEventListener('click', function dismiss() { |
| 336 | ctx.classList.remove('on'); |
| 337 | D.removeEventListener('click', dismiss); |
| 338 | }, { once: true }); |
| 339 | }, 0); |
| 340 | } |
| 341 | |
| 342 | /* ── File operations ── */ |
| 343 | function doRename(path) { |
| 344 | Z.modal.prompt('Rename', Z.basename(path)).then(function (name) { |
| 345 | if (!name) return; |
| 346 | Z.api.rename(path, name).then(function () { |
| 347 | Z.toast('Renamed', 'ok'); |
| 348 | explorer.nav(Z.state.path); |
| 349 | }).catch(function (e) { Z.toast('Rename failed: ' + e.message, 'er'); }); |
| 350 | }); |
| 351 | } |
| 352 | |
| 353 | function doDelete(path, recursive) { |
| 354 | var msg = path + (recursive ? ' (recursive)' : ''); |
| 355 | Z.modal.confirm('Delete', msg, true).then(function (ok) { |
| 356 | if (!ok) return; |
| 357 | Z.api.del(path, recursive).then(function () { |
| 358 | Z.toast('Deleted', 'ok'); |
| 359 | explorer.nav(Z.state.path); |
| 360 | }).catch(function (e) { Z.toast('Delete failed: ' + e.message, 'er'); }); |
| 361 | }); |
| 362 | } |
| 363 | |
| 364 | function doCreateFile() { |
| 365 | Z.modal.prompt('New File', '').then(function (name) { |
| 366 | if (!name) return; |
| 367 | Z.api.createFile(Z.state.path, name).then(function () { |
| 368 | Z.toast('Created', 'ok'); |
| 369 | explorer.nav(Z.state.path); |
| 370 | }).catch(function (e) { Z.toast('Failed: ' + e.message, 'er'); }); |
| 371 | }); |
| 372 | } |
| 373 | |
| 374 | function doCreateDir() { |
| 375 | Z.modal.prompt('New Folder', '').then(function (name) { |
| 376 | if (!name) return; |
| 377 | Z.api.mkdir(Z.state.path, name).then(function () { |
| 378 | Z.toast('Created', 'ok'); |
| 379 | explorer.nav(Z.state.path); |
| 380 | }).catch(function (e) { Z.toast('Failed: ' + e.message, 'er'); }); |
| 381 | }); |
| 382 | } |
| 383 | |
| 384 | function doSendTo(entry, srcPath) { |
| 385 | if (!Z.ensureTransferIdle()) return; |
| 386 | Z.modal.folderPicker('Send To…', Z.state.path).then(function (dst) { |
| 387 | if (dst === null) return; |
| 388 | if (!dst) dst = '/'; |
| 389 | |
| 390 | var cancelled = false; |
| 391 | Z.showTransferLock({ |
| 392 | label: 'COPYING', |
| 393 | filename: entry ? entry.name : Z.basename(srcPath), |
| 394 | dest: dst, |
| 395 | onCancel: function () { |
| 396 | cancelled = true; |
| 397 | Z.api.copyCancel().catch(function(){}); |
| 398 | Z.hideTransferLock(); |
| 399 | Z.notify('Copy cancelled', 'Copy to ' + dst + ' aborted.', 'wn'); |
| 400 | } |
| 401 | }); |
| 402 | |
| 403 | var startTime = Date.now(); |
| 404 | var progressInterval = setInterval(function() { |
| 405 | var elapsed = Math.floor((Date.now() - startTime) / 1000); |
| 406 | Z.updateTransferLock({ elapsed: elapsed + 's' }); |
| 407 | }, 1000); |
| 408 | |
| 409 | Z.api.copy(srcPath, dst, entry ? entry.size : 0).then(function () { |
| 410 | clearInterval(progressInterval); |
| 411 | if (!cancelled) { |
| 412 | Z.hideTransferLock(); |
| 413 | Z.notify('Copied', 'Successfully copied to ' + dst, 'ok'); |
| 414 | explorer.nav(Z.state.path); |
| 415 | } |
| 416 | }).catch(function (e) { |
| 417 | clearInterval(progressInterval); |
| 418 | Z.hideTransferLock(); |
| 419 | Z.notify('Copy failed', e.message, 'er'); |
| 420 | }); |
| 421 | }); |
| 422 | } |
| 423 | |
| 424 | function doExtract(archivePath, dstDir) { |
| 425 | if (!Z.ensureTransferIdle()) return; |
| 426 | Z.modal.folderPicker('Extract to…', dstDir).then(function (dst) { |
| 427 | if (dst === null) return; |
| 428 | if (!dst) dst = dstDir; |
| 429 | |
| 430 | var cancelled = false; |
| 431 | Z.showTransferLock({ |
| 432 | label: 'EXTRACTING', |
| 433 | filename: Z.basename(archivePath), |
| 434 | dest: dst, |
| 435 | onCancel: function () { |
| 436 | cancelled = true; |
| 437 | Z.api.extractCancel().catch(function(){}); |
| 438 | Z.hideTransferLock(); |
| 439 | Z.notify('Extraction cancelled', 'Extract to ' + dst + ' aborted.', 'wn'); |
| 440 | } |
| 441 | }); |
| 442 | |
| 443 | var startTime = Date.now(); |
| 444 | var progressInterval = setInterval(function() { |
| 445 | var elapsed = Math.floor((Date.now() - startTime) / 1000); |
| 446 | Z.updateTransferLock({ elapsed: elapsed + 's' }); |
| 447 | /* If your API supports extract progress, poll it here */ |
| 448 | Z.api.extractProgress().then(function(d) { |
| 449 | if (d && typeof d.progress === 'number') { |
| 450 | Z.updateTransferLock({ pct: d.progress }); |
| 451 | } |
| 452 | }).catch(function(){}); |
| 453 | }, 1000); |
| 454 | |
| 455 | Z.api.extract(archivePath, dst).then(function () { |
| 456 | clearInterval(progressInterval); |
| 457 | if (!cancelled) { |
| 458 | Z.hideTransferLock(); |
| 459 | Z.notify('Extracted', 'Successfully extracted to ' + dst, 'ok'); |
| 460 | explorer.nav(Z.state.path); |
| 461 | } |
| 462 | }).catch(function (e) { |
| 463 | clearInterval(progressInterval); |
| 464 | Z.hideTransferLock(); |
| 465 | Z.notify('Extract failed', e.message, 'er'); |
| 466 | }); |
| 467 | }); |
| 468 | } |
| 469 | |
| 470 | /* ── Upload — with transfer lock modal ── */ |
| 471 | explorer.upload = function (files) { |
| 472 | if (!files || !files.length) return; |
| 473 | if (!Z.ensureTransferIdle()) return; |
| 474 | |
| 475 | var fileIdx = 0; |
| 476 | var cancelled = false; |
| 477 | var currentXhr = null; |
| 478 | |
| 479 | function uploadNext() { |
| 480 | if (cancelled || fileIdx >= files.length) { |
| 481 | Z.hideTransferLock(); |
| 482 | if (!cancelled) { |
| 483 | explorer.nav(Z.state.path); |
| 484 | Z.notify('Upload complete', files.length + ' file(s) uploaded', 'ok'); |
| 485 | } |
| 486 | return; |
| 487 | } |
| 488 | var f = files[fileIdx++]; |
| 489 | var startTime = Date.now(); |
| 490 | |
| 491 | Z.showTransferLock({ |
| 492 | label: 'UPLOADING', |
| 493 | filename: f.name, |
| 494 | dest: Z.state.path, |
| 495 | onCancel: function () { |
| 496 | cancelled = true; |
| 497 | if (currentXhr) currentXhr.abort(); |
| 498 | Z.hideTransferLock(); |
| 499 | Z.notify('Upload cancelled', f.name, 'wn'); |
| 500 | } |
| 501 | }); |
| 502 | |
| 503 | var prom = Z.api.upload(Z.state.path, f, function (pct, loaded, total) { |
| 504 | var elapsed = Math.floor((Date.now() - startTime) / 1000); |
| 505 | var speedBps = elapsed > 0 ? loaded / elapsed : 0; |
| 506 | Z.updateTransferLock({ |
| 507 | pct: pct, |
| 508 | speed: Z.bytes(speedBps) + '/s', |
| 509 | elapsed: elapsed + 's' |
| 510 | }); |
| 511 | }); |
| 512 | /* Capture the XHR handle for abort */ |
| 513 | if (prom._xhr) currentXhr = prom._xhr; |
| 514 | |
| 515 | prom.then(function () { |
| 516 | uploadNext(); |
| 517 | }).catch(function (e) { |
| 518 | Z.hideTransferLock(); |
| 519 | Z.notify('Upload failed', f.name + ': ' + e.message, 'er'); |
| 520 | }); |
| 521 | } |
| 522 | |
| 523 | uploadNext(); |
| 524 | }; |
| 525 | |
| 526 | /* ── View switching ── */ |
| 527 | explorer.setView = function (v) { |
| 528 | if (!{ grid: 1, list: 1, details: 1 }[v]) return; |
| 529 | _view = v; |
| 530 | ['grid', 'list', 'details'].forEach(function (x) { |
| 531 | var b = $('vb-' + x); |
| 532 | if (b) b.classList.toggle('active', x === v); |
| 533 | }); |
| 534 | render($('search') ? $('search').value : ''); |
| 535 | try { localStorage.setItem('zftpd_explorer_view', v); } catch (e) { } |
| 536 | }; |
| 537 | |
| 538 | /* ── Helper updates ── */ |
| 539 | function updatePath() { |
| 540 | var el = $('current-path'); |
| 541 | if (el) el.textContent = Z.state.path; |
| 542 | } |
| 543 | |
| 544 | function updateStatus(ok) { |
| 545 | var pill = $('status'); |
| 546 | if (!pill) return; |
| 547 | pill.className = 'status-pill ' + (ok ? 'status-ok' : 'status-bad'); |
| 548 | var dot = pill.querySelector('.sdot'); |
| 549 | var txt = pill.querySelector('.stxt'); |
| 550 | if (txt) txt.textContent = ok ? 'Connected' : 'Error'; |
| 551 | } |
| 552 | |
| 553 | function updateCount() { |
| 554 | var el = $('fl-count'); |
| 555 | if (el) el.innerHTML = '<b>' + (Z.state.entries ? Z.state.entries.length : 0) + '</b> items'; |
| 556 | } |
| 557 | |
| 558 | /* ── Init (called when view becomes active) ── */ |
| 559 | explorer.init = function () { |
| 560 | try { |
| 561 | var sv = localStorage.getItem('zftpd_explorer_view'); |
| 562 | if (sv) _view = sv; |
| 563 | } catch (e) { } |
| 564 | |
| 565 | /* Wire toolbar buttons */ |
| 566 | var bu = $('btn-up'); |
| 567 | if (bu) bu.onclick = function () { var p = Z.parent(Z.state.path); if (p !== null) explorer.nav(p); }; |
| 568 | var br = $('btn-ref'); |
| 569 | if (br) br.onclick = function () { explorer.nav(Z.state.path); }; |
| 570 | var sr = $('search'); |
| 571 | if (sr) sr.oninput = Z.debounce(function () { render(sr.value); }, 150); |
| 572 | var fi = $('file-input'); |
| 573 | if (fi) fi.onchange = function (e) { explorer.upload(e.target.files); e.target.value = ''; }; |
| 574 | |
| 575 | ['grid', 'list', 'details'].forEach(function (v) { |
| 576 | var b = $('vb-' + v); |
| 577 | if (b) b.onclick = function () { explorer.setView(v); }; |
| 578 | }); |
| 579 | |
| 580 | explorer.setView(_view); |
| 581 | }; |
| 582 | |
| 583 | Z.explorer = explorer; |
| 584 | |
| 585 | })(ZFTPD); |
| 586 |