Seregon/zftpd

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

C/11.0 KB/No license
web/js/app.js
zftpd / web / js / app.js
1/* ══ APP — Router & State Management ══════════════════════════════════════
2 * Central application bootstrap. Manages view switching, global state,
3 * and wires up navigation tabs.
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 
15 /* ── Global state ── */
16 Z.state = {
17 view: 'dashboard', /* active view: dashboard | explorer | filemanager | downloads */
18 path: '/', /* current directory path (explorer) */
19 entries: [], /* current directory entries */
20 transferActive: false, /* true while upload/copy is running */
21 statsTimer: null, /* interval ID for stats polling */
22 fmLeftPath: '/', /* file manager left panel path */
23 fmRightPath: '/' /* file manager right panel path */
24 };
25 
26 /* ── Themes ── */
27 Z.THEMES = [
28 { id: 'ps5', name: 'PS5', desc: 'Default blue', sw: '#2b8cff' },
29 { id: 'cloud', name: 'Cloud', desc: 'Clean white', sw: '#3b82f6' },
30 { id: 'matrix', name: 'Matrix', desc: 'Green terminal', sw: '#00e639' },
31 { id: 'sunset', name: 'Sunset', desc: 'Warm orange', sw: '#ff6535' },
32 { id: 'arctic', name: 'Arctic', desc: 'Cool cyan', sw: '#00d4ff' },
33 { id: 'neon', name: 'Neon', desc: 'Purple glow', sw: '#c800ff' },
34 { id: 'amber', name: 'Amber', desc: 'Retro gold', sw: '#ffb700' }
35 ];
36 
37 /* ── View switching ── */
38 Z.switchView = function (viewId) {
39 var valid = { dashboard: 1, explorer: 1, filemanager: 1, downloads: 1, games: 1, settings: 1 };
40 if (!valid[viewId]) return;
41 
42 Z.state.view = viewId;
43 
44 /* Update nav tabs */
45 var tabs = D.querySelectorAll('.nav-tab');
46 for (var i = 0; i < tabs.length; i++) {
47 var t = tabs[i];
48 if (t.getAttribute('data-view') === viewId) {
49 t.classList.add('active');
50 } else {
51 t.classList.remove('active');
52 }
53 }
54 
55 /* Update view panels */
56 var views = D.querySelectorAll('.view');
57 for (var j = 0; j < views.length; j++) {
58 var v = views[j];
59 if (v.id === 'view-' + viewId) {
60 v.classList.add('view-active');
61 } else {
62 v.classList.remove('view-active');
63 }
64 }
65 
66 /* Trigger view-specific init */
67 if (viewId === 'explorer' && Z.explorer && Z.explorer.init) {
68 Z.explorer.init();
69 Z.explorer.nav(Z.state.path);
70 }
71 if (viewId === 'dashboard' && Z.dashboard && Z.dashboard.refresh) {
72 Z.dashboard.refresh();
73 }
74 if (viewId === 'filemanager' && Z.fileManager && Z.fileManager.init) {
75 Z.fileManager.init();
76 }
77 if (viewId === 'downloads' && Z.downloadMgr && Z.downloadMgr.refresh) {
78 Z.downloadMgr.refresh();
79 }
80 if (viewId === 'games' && Z.gamesView && Z.gamesView.refresh) {
81 Z.gamesView.refresh();
82 }
83 if (viewId === 'settings' && Z.settingsView && Z.settingsView.refresh) {
84 Z.settingsView.refresh();
85 }
86 
87 try { localStorage.setItem('zftpd_view', viewId); } catch (e) { }
88 };
89 
90 /* ═══════════════════════════════════════════════════════════════════════
91 * TRANSFER LOCK MODAL
92 * Shows a blocking modal during uploads/copies preventing navigation.
93 *
94 * ┌──────────────────────────────────┐
95 * │ ⬆ UPLOADING… 18 MB/s 17s │
96 * │ filename.pkg 44% │
97 * │ → /data │
98 * │ ████████░░░░░░░░░░░░░░░ │
99 * │ [‖ Pause] [✕ Cancel] │
100 * └──────────────────────────────────┘
101 * ═══════════════════════════════════════════════════════════════════════ */
102 
103 var _xferOverlay = null;
104 var _xferEls = {};
105 
106 function _ensureXferOverlay() {
107 if (_xferOverlay) return;
108 var ov = D.createElement('div');
109 ov.className = 'xfer-lock-overlay';
110 ov.innerHTML =
111 '<div class="xfer-lock-card">' +
112 '<div class="xfer-lock-header">' +
113 '<span id="xfer-label">UPLOADING\u2026</span>' +
114 '<span><span id="xfer-speed" class="xfer-lock-speed"></span> ' +
115 '<span id="xfer-time" class="xfer-lock-time"></span> ' +
116 '<span id="xfer-pct" class="xfer-lock-pct"></span></span>' +
117 '</div>' +
118 '<div class="xfer-lock-body">' +
119 '<div id="xfer-filename" class="xfer-lock-filename"></div>' +
120 '<div id="xfer-dest" class="xfer-lock-dest"></div>' +
121 '<div class="xfer-lock-bar"><div id="xfer-bar" class="xfer-lock-bar-fill"></div></div>' +
122 '</div>' +
123 '<div class="xfer-lock-footer">' +
124 '<button id="xfer-pause" class="btn">&#x2016; Pause</button>' +
125 '<button id="xfer-cancel" class="btn danger">&times; Cancel</button>' +
126 '</div>' +
127 '</div>';
128 D.body.appendChild(ov);
129 _xferOverlay = ov;
130 _xferEls = {
131 label: ov.querySelector('#xfer-label'),
132 speed: ov.querySelector('#xfer-speed'),
133 time: ov.querySelector('#xfer-time'),
134 pct: ov.querySelector('#xfer-pct'),
135 filename: ov.querySelector('#xfer-filename'),
136 dest: ov.querySelector('#xfer-dest'),
137 bar: ov.querySelector('#xfer-bar'),
138 pauseBtn: ov.querySelector('#xfer-pause'),
139 cancelBtn: ov.querySelector('#xfer-cancel')
140 };
141 }
142 
143 /**
144 * Show the transfer lock modal
145 * @param {object} opts - { label, filename, dest, onPause, onCancel }
146 */
147 Z.showTransferLock = function (opts) {
148 _ensureXferOverlay();
149 opts = opts || {};
150 Z.state.transferActive = true;
151 _xferEls.label.textContent = (opts.label || 'UPLOADING') + '\u2026';
152 _xferEls.filename.textContent = opts.filename || '';
153 _xferEls.dest.textContent = opts.dest || '/';
154 _xferEls.speed.textContent = '';
155 _xferEls.time.textContent = '';
156 _xferEls.pct.textContent = '';
157 _xferEls.bar.style.width = '0%';
158 _xferEls.pauseBtn.onclick = opts.onPause || function () {};
159 _xferEls.cancelBtn.onclick = opts.onCancel || function () {};
160 /* Show/hide pause button */
161 _xferEls.pauseBtn.style.display = opts.onPause ? '' : 'none';
162 _xferOverlay.classList.add('on');
163 };
164 
165 /**
166 * Update the transfer lock modal progress
167 * @param {object} info - { pct, speed, elapsed }
168 */
169 Z.updateTransferLock = function (info) {
170 if (!_xferOverlay) return;
171 info = info || {};
172 if (typeof info.pct === 'number') {
173 _xferEls.pct.textContent = info.pct + '%';
174 _xferEls.bar.style.width = info.pct + '%';
175 }
176 if (info.speed) _xferEls.speed.textContent = info.speed;
177 if (info.elapsed) _xferEls.time.textContent = info.elapsed;
178 };
179 
180 /** Hide the transfer lock modal */
181 Z.hideTransferLock = function () {
182 Z.state.transferActive = false;
183 if (_xferOverlay) _xferOverlay.classList.remove('on');
184 };
185 
186 /* Legacy compat */
187 Z.setTransferActive = function (active) {
188 Z.state.transferActive = !!active;
189 if (!active) Z.hideTransferLock();
190 };
191 
192 Z.ensureTransferIdle = function () {
193 if (Z.state.transferActive) {
194 Z.toast('Transfer in progress\u2026', 'wn');
195 return false;
196 }
197 return true;
198 };
199 
200 /* ═══════════════════════════════════════════════════════════════════════
201 * NOTIFICATION CENTER
202 *
203 * Z.notify(title, desc, type) — type: 'ok' | 'er' | 'wn' | ''
204 * Notifications appear in the bell dropdown in the topbar.
205 * ═══════════════════════════════════════════════════════════════════════ */
206 
207 var _notifications = [];
208 var _notifMaxItems = 50;
209 
210 Z.notify = function (title, desc, type) {
211 _notifications.unshift({
212 title: title || '',
213 desc: desc || '',
214 type: type || '',
215 time: new Date()
216 });
217 if (_notifications.length > _notifMaxItems) {
218 _notifications.length = _notifMaxItems;
219 }
220 _renderNotifications();
221 /* Also show toast for immediate feedback */
222 Z.toast(title, type);
223 };
224 
225 function _renderNotifications() {
226 var badge = $('tb-notif-badge');
227 var list = $('tb-notif-list');
228 if (!list) return;
229 
230 /* Update badge count */
231 if (badge) {
232 if (_notifications.length > 0) {
233 badge.textContent = _notifications.length > 99 ? '99+' : _notifications.length;
234 badge.classList.remove('hidden');
235 } else {
236 badge.classList.add('hidden');
237 }
238 }
239 
240 list.innerHTML = '';
241
242 if (!_notifications.length) {
243 list.innerHTML = '<div class="tb-notif-empty">No notifications</div>';
244 return;
245 }
246 
247 var clr = D.createElement('div');
248 clr.style.cssText = 'padding:8px 16px;text-align:right;font-size:12px;color:var(--ac);cursor:pointer;border-bottom:1px solid var(--bd);line-height:1;margin-bottom:4px;font-weight:600;';
249 clr.innerHTML = 'Clear All';
250 clr.onclick = function(e) {
251 e.stopPropagation();
252 _notifications = [];
253 _renderNotifications();
254 };
255 list.appendChild(clr);
256 
257 var icoMap = {
258 ok: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
259 er: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
260 wn: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
261 };
262 var defaultIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>';
263 
264 for (var i = 0; i < _notifications.length && i < 30; i++) {
265 var n = _notifications[i];
266 var ago = _timeAgo(n.time);
267 var item = D.createElement('div');
268 item.className = 'tb-notif-item';
269 item.innerHTML =
270 '<div class="ni-ico ' + (n.type || '') + '">' + (icoMap[n.type] || defaultIco) + '</div>' +
271 '<div class="ni-body"><div class="ni-title">' + n.title + '</div>' +
272 (n.desc ? '<div class="ni-desc">' + n.desc + '</div>' : '') + '</div>' +
273 '<div class="ni-time">' + ago + '</div>';
274
275 var cb = D.createElement('span');
276 cb.innerHTML = '&times;';
277 cb.style.cssText = 'position:absolute;top:10px;right:10px;cursor:pointer;color:var(--tx3);font-size:16px;line-height:1;width:24px;height:24px;display:flex;align-items:center;justify-content:center;border-radius:12px;';
278 cb.onmouseover = function(){this.style.background='var(--tg)';this.style.color='var(--tx)';};
279 cb.onmouseout = function(){this.style.background='none';this.style.color='var(--tx3)';};
280 (function(idx) {
281 cb.onclick = function(e){
282 e.stopPropagation();
283 _notifications.splice(idx, 1);
284 _renderNotifications();
285 };
286 })(i);
287 item.appendChild(cb);
288 list.appendChild(item);
289 }
290 }
291 
292 function _timeAgo(date) {
293 var sec = Math.floor((Date.now() - date.getTime()) / 1000);
294 if (sec < 60) return 'now';
295 if (sec < 3600) return Math.floor(sec / 60) + 'm';
296 if (sec < 86400) return Math.floor(sec / 3600) + 'h';
297 return Math.floor(sec / 86400) + 'd';
298 }
299 
300 /* ═══════════════════════════════════════════════════════════════════════
301 * DOWNLOAD PROGRESS PILL (topbar)
302 *
303 * Polls /api/download/status every 2s when downloads are active.
304 * Updates the pill count + aggregate progress bar.
305 * ═══════════════════════════════════════════════════════════════════════ */
306 
307 var _dlPollTimer = null;
308 
309 function _startDlPolling() {
310 if (_dlPollTimer) return;
311 _dlPollTimer = setInterval(_pollDownloads, 2000);
312 _pollDownloads();
313 }
314 
315 function _stopDlPolling() {
316 if (_dlPollTimer) { clearInterval(_dlPollTimer); _dlPollTimer = null; }
317 }
318 
319 function _pollDownloads() {
320 Z.api.downloadStatus().then(function (data) {
321 var items = (data && Array.isArray(data.downloads)) ? data.downloads : [];
322 var active = items.filter(function (d) { return !d.done && !d.error; });
323 var pill = $('tb-dl-pill');
324 var countEl = $('tb-dl-count');
325 var barFill = $('tb-dl-bar-fill');
326 
327 if (active.length > 0) {
328 if (pill) pill.classList.remove('hidden');
329 if (countEl) countEl.textContent = active.length;
330 
331 /* Calculate aggregate progress */
332 var totalDown = 0, totalSize = 0;
333 for (var i = 0; i < active.length; i++) {
334 totalDown += (active[i].downloaded || 0);
335 totalSize += (active[i].total_size || 0);
336 }
337 var pct = totalSize > 0 ? Math.floor(totalDown / totalSize * 100) : 0;
338 if (barFill) barFill.style.width = pct + '%';
339 } else {
340 if (pill) pill.classList.add('hidden');
341 _stopDlPolling();
342 }
343 
344 /* Notify on completed/errored downloads */
345 for (var j = 0; j < items.length; j++) {
346 var d = items[j];
347 var nKey = 'dl_notified_' + (d.id || j);
348 if (d.done && !d.error && !Z.state[nKey]) {
349 Z.state[nKey] = true;
350 Z.notify('Download complete', d.filename || d.url, 'ok');
351 }
352 if (d.error && !Z.state[nKey]) {
353 Z.state[nKey] = true;
354 Z.notify('Download failed', (d.filename || d.url) + ': ' + d.error_msg, 'er');
355 }
356 }
357 }).catch(function () { });
358 }
359 
360 /* Hook: call this from download-mgr.js when a download starts */
361 Z.onDownloadStarted = function () { _startDlPolling(); };
362 /* Click pill → go to downloads view */
363 D.addEventListener('DOMContentLoaded', function () {
364 var pill = $('tb-dl-pill');
365 if (pill) pill.onclick = function () { Z.switchView('downloads'); };
366 });
367 
368 /* ── Theme management ── */
369 Z.setTheme = function (id) {
370 var ids = Z.THEMES.map(function (t) { return t.id; });
371 var theme = ids.indexOf(id) >= 0 ? id : 'ps5';
372 D.documentElement.setAttribute('data-theme', theme);
373 
374 /* Update mobile select */
375 var sel = $('theme-select');
376 if (sel) sel.value = theme;
377 
378 /* Update desktop button swatch */
379 var t = Z.THEMES.filter(function (x) { return x.id === theme; })[0] || Z.THEMES[0];
380 var sw = $('t-sw');
381 if (sw) sw.style.background = t.sw;
382 var tn = $('t-nm');
383 if (tn) tn.textContent = t.name;
384 
385 try { localStorage.setItem('zftpd_theme', theme); } catch (e) { }
386 };
387 
388 /* ── Stats polling ── */
389 function refreshStats() {
390 Z.api.stats(Z.state.path).then(function (d) {
391 if (Z.dashboard && Z.dashboard.updateStats) Z.dashboard.updateStats(d);
392 }).catch(function () { });
393 }
394 
395 /* ── Bootstrap ── */
396 D.addEventListener('DOMContentLoaded', function () {
397 
398 /* Restore saved preferences */
399 try {
400 var sv = localStorage.getItem('zftpd_view');
401 if (sv) Z.state.view = sv;
402 var st = localStorage.getItem('zftpd_theme');
403 if (st) Z.setTheme(st);
404 } catch (e) { }
405 
406 /* Brand logo */
407 var bl = D.querySelector('.brand-logo');
408 if (bl) bl.src = 'assets/zftpd-logo.png';
409 
410 /* Nav tab clicks */
411 var tabs = D.querySelectorAll('.nav-tab');
412 for (var i = 0; i < tabs.length; i++) {
413 (function (tab) {
414 tab.onclick = function () {
415 var view = tab.getAttribute('data-view');
416 if (view) Z.switchView(view);
417 };
418 })(tabs[i]);
419 }
420 
421 /* Theme selector (mobile) */
422 var themeSel = $('theme-select');
423 if (themeSel) {
424 themeSel.innerHTML = '';
425 Z.THEMES.forEach(function (t) {
426 var opt = D.createElement('option');
427 opt.value = t.id;
428 opt.textContent = t.name;
429 themeSel.appendChild(opt);
430 });
431 var cur = D.documentElement.getAttribute('data-theme') || 'ps5';
432 themeSel.value = cur;
433 themeSel.onchange = function () { Z.setTheme(this.value); };
434 }
435 
436 /* Theme button (desktop) */
437 var themeBtn = $('theme-btn');
438 var themeDd = $('theme-dd');
439 if (themeBtn && themeDd) {
440 themeBtn.onclick = function (e) {
441 e.stopPropagation();
442 themeDd.classList.toggle('show');
443 themeBtn.classList.toggle('open', themeDd.classList.contains('show'));
444 /* Build dropdown items */
445 themeDd.innerHTML = '<div class="td-hd">Select Theme</div>';
446 var curTheme = D.documentElement.getAttribute('data-theme') || 'ps5';
447 Z.THEMES.forEach(function (t) {
448 var el = D.createElement('div');
449 el.className = 'td-item' + (t.id === curTheme ? ' active' : '');
450 el.innerHTML = '<div class="td-sw" style="background:' + t.sw + '"></div>' +
451 '<div class="td-info"><div class="td-nm">' + t.name + '</div><div class="td-ds">' + t.desc + '</div></div>' +
452 '<span class="td-ck">' + Z.ICO.check + '</span>';
453 el.onclick = function () {
454 Z.setTheme(t.id);
455 themeDd.classList.remove('show');
456 themeBtn.classList.remove('open');
457 };
458 themeDd.appendChild(el);
459 });
460 };
461 }
462 
463 /* Notification bell toggle */
464 var notifBtn = $('tb-notif-btn');
465 var notifDd = $('tb-notif-dd');
466 if (notifBtn && notifDd) {
467 notifBtn.onclick = function (e) {
468 e.stopPropagation();
469 notifDd.classList.toggle('show');
470 /* Close theme dropdown */
471 if (themeDd) themeDd.classList.remove('show');
472 if (themeBtn) themeBtn.classList.remove('open');
473 /* Re-render to update relative times */
474 if (notifDd.classList.contains('show')) _renderNotifications();
475 };
476 }
477 
478 /* Close all dropdowns on outside click */
479 D.addEventListener('click', function () {
480 if (themeDd) themeDd.classList.remove('show');
481 if (themeBtn) themeBtn.classList.remove('open');
482 if (notifDd) notifDd.classList.remove('show');
483 });
484 
485 /* Escape key */
486 D.addEventListener('keydown', function (e) {
487 if (e.key === 'Escape') {
488 if (themeDd) themeDd.classList.remove('show');
489 if (themeBtn) themeBtn.classList.remove('open');
490 if (notifDd) notifDd.classList.remove('show');
491 }
492 });
493 
494 /* Drag-and-drop upload */
495 var _dd = 0;
496 D.addEventListener('dragenter', function (e) {
497 e.preventDefault();
498 _dd++;
499 var drop = $('drop-overlay');
500 if (drop) drop.classList.add('on');
501 });
502 D.addEventListener('dragover', function (e) { e.preventDefault(); });
503 D.addEventListener('dragleave', function (e) {
504 e.preventDefault();
505 _dd = Math.max(0, _dd - 1);
506 if (!_dd) {
507 var drop = $('drop-overlay');
508 if (drop) drop.classList.remove('on');
509 }
510 });
511 D.addEventListener('drop', function (e) {
512 e.preventDefault();
513 _dd = 0;
514 var drop = $('drop-overlay');
515 if (drop) drop.classList.remove('on');
516 if (Z.explorer && Z.explorer.upload) {
517 Z.explorer.upload(e.dataTransfer.files);
518 }
519 });
520 
521 var dc = $('drop-close');
522 if (dc) dc.onclick = function () {
523 var drop = $('drop-overlay');
524 if (drop) drop.classList.remove('on');
525 };
526 
527 /* Stats polling */
528 Z.state.statsTimer = setInterval(refreshStats, 15000);
529 
530 /* Initialize the active view */
531 Z.switchView(Z.state.view);
532 
533 /* Preload root directory */
534 Z.api.list('/').catch(function () { });
535 });
536 
537})(ZFTPD);
538