Seregon/zftpd

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

C/11.0 KB/No license
web/js/utils.js
zftpd / web / js / utils.js
1/* ══ UTILITIES ════════════════════════════════════════════════════════════
2 * Path helpers, byte formatting, CSRF token, DOM helpers.
3 * ES5 compatible for PS5 browser.
4 * ═════════════════════════════════════════════════════════════════════════ */
5 
6var ZFTPD = ZFTPD || {};
7 
8(function (Z) {
9 'use strict';
10 
11 var D = document;
12 
13 /* ── DOM helpers ── */
14 Z.$ = function (id) { return D.getElementById(id); };
15 Z.E = encodeURIComponent;
16 
17 /* ── CSRF ── */
18 Z.csrf = function () {
19 var m = D.querySelector('meta[name="csrf-token"]');
20 return m ? m.content : '';
21 };
22 
23 /* ── Path helpers ── */
24 Z.norm = function (p) {
25 if (!p || p[0] !== '/') return '/';
26 if (p.length > 1 && p[p.length - 1] === '/') return p.slice(0, -1);
27 return p;
28 };
29 
30 Z.parent = function (p) {
31 p = Z.norm(p);
32 if (p === '/') return null;
33 var i = p.lastIndexOf('/');
34 return i <= 0 ? '/' : p.slice(0, i);
35 };
36 
37 Z.join = function (base, name) {
38 return base === '/' ? '/' + name : base + '/' + name;
39 };
40 
41 Z.basename = function (p) {
42 if (!p) return '';
43 var parts = p.split('/');
44 return parts[parts.length - 1] || '';
45 };
46 
47 Z.extname = function (name) {
48 if (!name) return '';
49 var i = name.lastIndexOf('.');
50 return i > 0 ? name.slice(i + 1).toLowerCase() : '';
51 };
52 
53 /* ── Byte formatting ── */
54 Z.bytes = function (b) {
55 if (typeof b !== 'number' || b < 0) return '\u2014';
56 if (b === 0) return '0 B';
57 var u = ['B', 'KB', 'MB', 'GB', 'TB'];
58 var i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), 4);
59 return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + u[i];
60 };
61 
62 Z.bps = function (b) { return Z.bytes(b) + '/s'; };
63 
64 /* ── Time formatting ── */
65 Z.duration = function (sec) {
66 sec = Math.max(0, Math.floor(sec));
67 if (sec >= 3600) return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
68 if (sec >= 60) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
69 return sec + 's';
70 };
71 
72 Z.relativeTime = function (timestamp) {
73 if (!timestamp) return '\u2014';
74 var now = Math.floor(Date.now() / 1000);
75 var diff = now - timestamp;
76 if (diff < 60) return 'just now';
77 if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
78 if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
79 return Math.floor(diff / 86400) + 'd ago';
80 };
81 
82 /* ── File type detection ── */
83 Z.fileCategory = function (name, isDir) {
84 if (isDir) return 'dir';
85 var ext = Z.extname(name);
86 var map = {
87 dir: [],
88 code: ['c', 'cpp', 'h', 'hpp', 'py', 'rs', 'go', 'java', 'rb', 'php', 'swift', 'kt'],
89 web: ['html', 'htm', 'jsx', 'tsx', 'vue', 'svelte'],
90 style: ['css', 'scss', 'sass', 'less'],
91 script: ['js', 'ts', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'lua'],
92 data: ['json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env'],
93 doc: ['md', 'txt', 'pdf', 'doc', 'docx', 'rtf', 'odt', 'tex'],
94 img: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'tiff', 'heic'],
95 video: ['mp4', 'mkv', 'avi', 'mov', 'webm', 'flv', 'wmv', 'm4v'],
96 audio: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a', 'opus'],
97 arch: ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar', 'zst', 'lz4'],
98 bin: ['exe', 'dll', 'so', 'dylib', 'elf', 'bin', 'o', 'a'],
99 db: ['db', 'sqlite', 'sqlite3', 'sql', 'mdb'],
100 lock: ['pem', 'key', 'crt', 'cer', 'p12', 'pfx'],
101 log: ['log', 'out', 'err'],
102 sheet: ['csv', 'xls', 'xlsx', 'ods', 'tsv'],
103 slide: ['ppt', 'pptx', 'odp'],
104 game: ['exfat', 'pkg', 'fpkg', 'ffpkg']
105 };
106 for (var cat in map) {
107 if (map.hasOwnProperty(cat)) {
108 for (var i = 0; i < map[cat].length; i++) {
109 if (map[cat][i] === ext) return cat;
110 }
111 }
112 }
113 return 'generic';
114 };
115 
116 /* ── Toast notifications (closable) ── */
117 Z.toast = function (msg, type) {
118 var wrap = Z.$('toast-wrap');
119 if (!wrap) return;
120 var el = D.createElement('div');
121 el.className = 'toast ' + (type || '');
122 
123 var txt = D.createElement('span');
124 txt.textContent = msg;
125 el.appendChild(txt);
126 
127 var closeBtn = D.createElement('span');
128 closeBtn.className = 'toast-close';
129 closeBtn.innerHTML = '&times;';
130 closeBtn.onclick = function () { _removeToast(el); };
131 el.appendChild(closeBtn);
132 
133 wrap.appendChild(el);
134 var timer = setTimeout(function () { _removeToast(el); }, 5000);
135 el._timer = timer;
136 };
137 
138 function _removeToast(el) {
139 if (!el || !el.parentNode) return;
140 clearTimeout(el._timer);
141 el.style.opacity = '0';
142 el.style.transform = 'translateY(8px)';
143 el.style.transition = 'all .2s ease';
144 setTimeout(function () {
145 if (el.parentNode) el.parentNode.removeChild(el);
146 }, 200);
147 }
148 
149 /* ── Debounce ── */
150 Z.debounce = function (fn, ms) {
151 var timer;
152 return function () {
153 var args = arguments;
154 var ctx = this;
155 clearTimeout(timer);
156 timer = setTimeout(function () { fn.apply(ctx, args); }, ms);
157 };
158 };
159 
160 /*=========================================================================*
161 * MODAL SYSTEM — replaces native prompt / confirm / alert
162 *
163 * Z.modal.prompt(title, defaultVal) → Promise<string|null>
164 * Z.modal.confirm(title, message) → Promise<boolean>
165 * Z.modal.alert(title, message) → Promise<void>
166 *
167 * ┌──────────────────────────────────┐
168 * │ Title [✕] │
169 * │ ────────────────────────────── │
170 * │ (optional message) │
171 * │ [ input field ] │
172 * │ [ Cancel ] [ OK ] │
173 * └──────────────────────────────────┘
174 *=========================================================================*/
175 Z.modal = {};
176 var _activeModal = null;
177 
178 /**
179 * Show a generic HTML modal with a title and raw HTML content.
180 * Returns the content container for further manipulation.
181 */
182 Z.modal.showHTML = function (title, htmlContent) {
183 var m = _modalCreate(title);
184 _activeModal = m;
185 var body = D.createElement('div');
186 body.id = 'zftpd-modal-content';
187 body.style.cssText = 'padding:24px;overflow-y:auto;max-height:60vh;';
188 body.innerHTML = htmlContent;
189 m.card.appendChild(body);
190 D.body.appendChild(m.overlay);
191 return body;
192 };
193 
194 Z.modal.close = function () {
195 if (_activeModal && _activeModal.close) {
196 _activeModal.close();
197 _activeModal = null;
198 }
199 };
200 
201 /* Shared overlay + card builder */
202 function _modalCreate(title) {
203 var overlay = D.createElement('div');
204 overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.6);' +
205 'display:flex;align-items:center;justify-content:center;animation:fadeIn .12s ease;';
206 
207 var card = D.createElement('div');
208 card.style.cssText = 'background:var(--sf);border:1px solid var(--bd2);border-radius:14px;' +
209 'width:min(380px,88vw);box-shadow:0 24px 64px rgba(0,0,0,.6);overflow:hidden;';
210 
211 /* Header */
212 var hdr = D.createElement('div');
213 hdr.style.cssText = 'display:flex;align-items:center;padding:14px 18px;' +
214 'border-bottom:1px solid var(--bd);gap:8px;';
215 hdr.innerHTML = '<div style="flex:1;font-weight:700;font-size:13px;color:var(--tx);">' +
216 title + '</div>' +
217 '<button class="modal-close" style="background:none;border:none;color:var(--tx3);' +
218 'font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>';
219 
220 card.appendChild(hdr);
221 overlay.appendChild(card);
222 
223 function close() { if (overlay.parentNode) D.body.removeChild(overlay); }
224 hdr.querySelector('.modal-close').onclick = close;
225 
226 return { overlay: overlay, card: card, close: close };
227 }
228 
229 /* Footer with action buttons */
230 function _modalFooter(cancelLabel, okLabel, okDanger) {
231 var ftr = D.createElement('div');
232 ftr.style.cssText = 'padding:12px 18px;border-top:1px solid var(--bd);' +
233 'display:flex;gap:8px;justify-content:flex-end;';
234 
235 if (cancelLabel) {
236 var cancelBtn = D.createElement('button');
237 cancelBtn.className = 'btn';
238 cancelBtn.style.cssText = 'padding:7px 16px;font-size:12px;';
239 cancelBtn.textContent = cancelLabel;
240 ftr.appendChild(cancelBtn);
241 ftr._cancelBtn = cancelBtn;
242 }
243 
244 var okBtn = D.createElement('button');
245 okBtn.className = 'btn';
246 okBtn.style.cssText = 'padding:7px 18px;font-size:12px;font-weight:700;border-radius:8px;' +
247 'cursor:pointer;border:none;color:#fff;background:' + (okDanger ? 'var(--er)' : 'var(--ac)') + ';';
248 okBtn.textContent = okLabel || 'OK';
249 ftr.appendChild(okBtn);
250 ftr._okBtn = okBtn;
251 
252 return ftr;
253 }
254 
255 /**
256 * Prompt modal — text input with OK/Cancel
257 * @param {string} title - Header text
258 * @param {string} defVal - Default input value
259 * @returns {Promise<string|null>} Resolved with value or null if cancelled
260 */
261 Z.modal.prompt = function (title, defVal) {
262 return new Promise(function (resolve) {
263 var m = _modalCreate(title);
264 
265 var body = D.createElement('div');
266 body.style.cssText = 'padding:14px 18px;';
267 var input = D.createElement('input');
268 input.type = 'text';
269 input.value = defVal || '';
270 input.style.cssText = 'width:100%;box-sizing:border-box;padding:9px 12px;font-size:13px;' +
271 'border:1px solid var(--bd2);border-radius:8px;background:var(--sf2);' +
272 'color:var(--tx);font-family:inherit;outline:none;';
273 input.onfocus = function () { input.style.borderColor = 'var(--ac)'; };
274 input.onblur = function () { input.style.borderColor = 'var(--bd2)'; };
275 body.appendChild(input);
276 m.card.appendChild(body);
277 
278 var ftr = _modalFooter('Cancel', 'OK', false);
279 m.card.appendChild(ftr);
280 
281 function done(val) { m.close(); resolve(val); }
282 ftr._cancelBtn.onclick = function () { done(null); };
283 ftr._okBtn.onclick = function () { done(input.value); };
284 m.overlay.addEventListener('click', function (e) { if (e.target === m.overlay) done(null); });
285 input.addEventListener('keydown', function (e) {
286 if (e.key === 'Enter') done(input.value);
287 if (e.key === 'Escape') done(null);
288 });
289 
290 D.body.appendChild(m.overlay);
291 setTimeout(function () { input.focus(); input.select(); }, 50);
292 });
293 };
294 
295 /**
296 * Confirm modal — message with OK/Cancel (OK can be danger-styled)
297 * @param {string} title - Header text
298 * @param {string} message - Body message
299 * @param {boolean} danger - If true, OK button is red
300 * @returns {Promise<boolean>}
301 */
302 Z.modal.confirm = function (title, message, danger) {
303 return new Promise(function (resolve) {
304 var m = _modalCreate(title);
305 
306 if (message) {
307 var body = D.createElement('div');
308 body.style.cssText = 'padding:14px 18px;font-size:12px;color:var(--tx2);line-height:1.5;' +
309 'white-space:pre-wrap;word-break:break-word;max-height:40vh;overflow-y:auto;';
310 body.textContent = message;
311 m.card.appendChild(body);
312 }
313 
314 var ftr = _modalFooter('Cancel', 'Confirm', !!danger);
315 m.card.appendChild(ftr);
316 
317 function done(val) { m.close(); resolve(val); }
318 ftr._cancelBtn.onclick = function () { done(false); };
319 ftr._okBtn.onclick = function () { done(true); };
320 m.overlay.addEventListener('click', function (e) { if (e.target === m.overlay) done(false); });
321 
322 D.body.appendChild(m.overlay);
323 setTimeout(function () { ftr._okBtn.focus(); }, 50);
324 });
325 };
326 
327 /**
328 * Alert modal — informational message with single OK button
329 * @param {string} title - Header text
330 * @param {string} message - Body message
331 * @returns {Promise<void>}
332 */
333 Z.modal.alert = function (title, message) {
334 return new Promise(function (resolve) {
335 var m = _modalCreate(title);
336 
337 if (message) {
338 var body = D.createElement('div');
339 body.style.cssText = 'padding:14px 18px;font-size:12px;color:var(--tx2);line-height:1.5;' +
340 'white-space:pre-wrap;word-break:break-word;max-height:40vh;overflow-y:auto;';
341 body.textContent = message;
342 m.card.appendChild(body);
343 }
344 
345 var ftr = _modalFooter(null, 'OK', false);
346 m.card.appendChild(ftr);
347 
348 function done() { m.close(); resolve(); }
349 ftr._okBtn.onclick = done;
350 m.overlay.addEventListener('click', function (e) { if (e.target === m.overlay) done(); });
351 
352 D.body.appendChild(m.overlay);
353 setTimeout(function () { ftr._okBtn.focus(); }, 50);
354 });
355 };
356 
357 /* ═══════════════════════════════════════════════════════════════════════
358 * FOLDER PICKER MODAL — browseable filesystem picker
359 *
360 * Z.modal.folderPicker(title, startPath) → Promise<string|null>
361 *
362 * ┌──────────────────────────────────────────────┐
363 * │ Choose destination [✕] │
364 * │ / > data > user │
365 * │ ┌──────────────────────────────┐ │
366 * │ │ 📁 folder_a › │ │
367 * │ │ 📁 folder_b › │ │
368 * │ └──────────────────────────────┘ │
369 * │ [↑ Back] [+ New Folder] [ Select folder ] │
370 * └──────────────────────────────────────────────┘
371 * ═══════════════════════════════════════════════════════════════════════ */
372 Z.modal.folderPicker = function (title, startPath) {
373 return new Promise(function (resolve) {
374 var browsePath = startPath || '/';
375 
376 /* Build overlay */
377 var overlay = D.createElement('div');
378 overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.65);' +
379 'display:flex;align-items:center;justify-content:center;animation:fadeIn .15s ease;';
380 
381 var card = D.createElement('div');
382 card.style.cssText = 'background:var(--sf);border:1px solid var(--bd2);border-radius:14px;' +
383 'width:min(460px,90vw);max-height:70vh;display:flex;flex-direction:column;' +
384 'box-shadow:0 32px 80px rgba(0,0,0,.6);overflow:hidden;';
385 
386 /* ── Header ── */
387 var hdr = D.createElement('div');
388 hdr.style.cssText = 'display:flex;align-items:center;padding:14px 18px;' +
389 'border-bottom:1px solid var(--bd);gap:8px;';
390 hdr.innerHTML = '<div style="flex:1;font-weight:700;font-size:13px;color:var(--tx);">' +
391 (title || 'Choose destination') + '</div>' +
392 '<button class="fp-close" style="background:none;border:none;color:var(--tx3);' +
393 'font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>';
394 
395 /* ── Breadcrumb ── */
396 var bcBar = D.createElement('div');
397 bcBar.style.cssText = 'padding:10px 18px 4px;font-size:11px;color:var(--tx3);' +
398 'font-family:monospace;white-space:nowrap;overflow-x:auto;';
399 
400 /* ── Body = folder list ── */
401 var body = D.createElement('div');
402 body.style.cssText = 'flex:1;overflow-y:auto;padding:8px 12px;min-height:120px;max-height:50vh;';
403 
404 /* ── Footer ── */
405 var ftr = D.createElement('div');
406 ftr.style.cssText = 'padding:12px 18px;border-top:1px solid var(--bd);display:flex;gap:8px;align-items:center;';
407 ftr.innerHTML = '<button class="fp-up btn" style="padding:6px 10px;font-size:11px;">' +
408 '&uarr; Back</button>' +
409 '<button class="fp-mkdir btn" style="padding:6px 10px;font-size:11px;">' +
410 '+ New Folder</button>' +
411 '<div style="flex:1;"></div>' +
412 '<button class="fp-ok btn" style="padding:8px 18px;font-size:12px;font-weight:700;' +
413 'background:var(--ac);color:#fff;border:none;border-radius:8px;cursor:pointer;">' +
414 'Select this folder</button>';
415 
416 card.appendChild(hdr);
417 card.appendChild(bcBar);
418 card.appendChild(body);
419 card.appendChild(ftr);
420 overlay.appendChild(card);
421 D.body.appendChild(overlay);
422 
423 /* ── Close ── */
424 function close(val) {
425 if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
426 resolve(val);
427 }
428 overlay.addEventListener('click', function (e) { if (e.target === overlay) close(null); });
429 hdr.querySelector('.fp-close').onclick = function () { close(null); };
430 
431 /* ── Back (parent dir) ── */
432 ftr.querySelector('.fp-up').onclick = function () {
433 var parent = Z.parent(browsePath);
434 if (parent !== null) { browsePath = parent; loadDir(browsePath); }
435 };
436 
437 /* ── New Folder ── */
438 ftr.querySelector('.fp-mkdir').onclick = function () {
439 Z.modal.prompt('New Folder', '').then(function (name) {
440 if (!name) return;
441 Z.api.mkdir(browsePath, name).then(function () {
442 Z.toast('Folder created', 'ok');
443 loadDir(browsePath);
444 }).catch(function (e) {
445 Z.toast('Failed: ' + e.message, 'er');
446 });
447 });
448 };
449 
450 /* ── Select this folder ── */
451 ftr.querySelector('.fp-ok').onclick = function () { close(browsePath); };
452 
453 /* ── Render breadcrumb ── */
454 function renderBreadcrumb(path) {
455 var parts = path.split('/').filter(function (s) { return s.length > 0; });
456 var html = '<span style="color:var(--ac);cursor:pointer;" data-fp="/">/</span>';
457 var cum = '';
458 for (var i = 0; i < parts.length; i++) {
459 cum += '/' + parts[i];
460 html += ' <span style="color:var(--tx3);">›</span> ' +
461 '<span style="color:var(--ac);cursor:pointer;" data-fp="' + cum + '">' +
462 parts[i] + '</span>';
463 }
464 bcBar.innerHTML = html;
465 var spans = bcBar.querySelectorAll('span[data-fp]');
466 for (var s = 0; s < spans.length; s++) {
467 (function (sp) {
468 sp.onclick = function () {
469 browsePath = sp.getAttribute('data-fp');
470 loadDir(browsePath);
471 };
472 })(spans[s]);
473 }
474 }
475 
476 /* ── Load directory ── */
477 function loadDir(path) {
478 renderBreadcrumb(path);
479 body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--tx3);font-size:12px;">Loading\u2026</div>';
480 Z.api.list(path).then(function (data) {
481 body.innerHTML = '';
482 if (!data || !data.entries || !data.entries.length) {
483 body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--tx3);font-size:12px;">Empty directory</div>';
484 return;
485 }
486 var dirs = data.entries.filter(function (e) { return e.type === 'directory'; });
487 dirs.sort(function (a, b) { return a.name.localeCompare(b.name); });
488 if (!dirs.length) {
489 body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--tx3);font-size:12px;">No subdirectories</div>';
490 return;
491 }
492 for (var i = 0; i < dirs.length; i++) {
493 (function (entry) {
494 var row = D.createElement('div');
495 row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:8px 12px;' +
496 'border-radius:8px;cursor:pointer;color:var(--tx2);transition:all .1s;font-size:12px;';
497 row.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
498 'stroke-width="2" style="flex-shrink:0;color:var(--ac);"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 ' +
499 '1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>' +
500 '<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' +
501 entry.name + '</span>' +
502 '<span style="color:var(--tx3);font-size:10px;">›</span>';
503 row.onmouseenter = function () { row.style.background = 'var(--sf2)'; row.style.color = 'var(--tx)'; };
504 row.onmouseleave = function () { row.style.background = 'none'; row.style.color = 'var(--tx2)'; };
505 row.onclick = function () {
506 browsePath = Z.join(path, entry.name);
507 loadDir(browsePath);
508 };
509 body.appendChild(row);
510 })(dirs[i]);
511 }
512 }).catch(function (err) {
513 body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--er);font-size:12px;">' +
514 'Error: ' + err.message + '</div>';
515 });
516 }
517 
518 loadDir(browsePath);
519 });
520 };
521 
522})(ZFTPD);
523