Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
web/js/views/explorer.js
zftpd / web / js / views / explorer.js
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 
7var 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