mirror of
https://github.com/seregonwar/zftpd.git
synced 2026-10-22 03:32:24 -05:00
Replace the embedded UI with a file manager: sidebar, details table, keyboard shortcuts, uploads, copy/move and extraction. Games, System, Transfers and Settings use the same components. The previous UI is archived in web/legacy/ and left out of the embedded resources. The restyle is flat: donation banner, credits with the maintainer photo, and no gradients or card shadows. JavaScript stays ES5.
358 lines
13 KiB
JavaScript
358 lines
13 KiB
JavaScript
/* zftpd web — core helpers.
|
|
* ES5 only: the PS4/PS5 browser does not support ES2015 syntax.
|
|
*/
|
|
var ZF = window.ZF || {};
|
|
window.ZF = ZF;
|
|
|
|
(function (ZF) {
|
|
'use strict';
|
|
|
|
var D = document;
|
|
var SVGNS = 'http://www.w3.org/2000/svg';
|
|
var XLINK = 'http://www.w3.org/1999/xlink';
|
|
|
|
ZF.views = ZF.views || {};
|
|
|
|
/* ── Events ─────────────────────────────────────────────────────────── */
|
|
|
|
var handlers = {};
|
|
|
|
ZF.on = function (name, fn) {
|
|
(handlers[name] = handlers[name] || []).push(fn);
|
|
};
|
|
|
|
ZF.emit = function (name, data) {
|
|
var list = handlers[name];
|
|
if (!list) return;
|
|
for (var i = 0; i < list.length; i++) {
|
|
try { list[i](data); } catch (e) { if (window.console) console.error(e); }
|
|
}
|
|
};
|
|
|
|
/* ── DOM ────────────────────────────────────────────────────────────── */
|
|
|
|
ZF.$ = function (sel, root) { return (root || D).querySelector(sel); };
|
|
ZF.$$ = function (sel, root) { return Array.prototype.slice.call((root || D).querySelectorAll(sel)); };
|
|
ZF.byId = function (id) { return D.getElementById(id); };
|
|
|
|
/**
|
|
* el('button', { class: 'btn', onclick: fn, text: 'Save' }, [children])
|
|
* Keys: class, text, html, style, on<event>, plus plain attributes.
|
|
* Boolean false / null values are skipped.
|
|
*/
|
|
ZF.el = function (tag, props, children) {
|
|
var node = D.createElement(tag);
|
|
var k, v;
|
|
if (props) {
|
|
for (k in props) {
|
|
if (!props.hasOwnProperty(k)) continue;
|
|
v = props[k];
|
|
if (v === null || v === undefined || v === false) continue;
|
|
if (k === 'class') node.className = v;
|
|
else if (k === 'text') node.textContent = v;
|
|
else if (k === 'html') node.innerHTML = v;
|
|
else if (k === 'style') node.style.cssText = v;
|
|
else if (k.indexOf('on') === 0 && typeof v === 'function') node.addEventListener(k.slice(2), v);
|
|
else if (k === 'value' || k === 'checked' || k === 'disabled' || k === 'selected') node[k] = v;
|
|
else node.setAttribute(k, v === true ? '' : String(v));
|
|
}
|
|
}
|
|
ZF.append(node, children);
|
|
return node;
|
|
};
|
|
|
|
ZF.append = function (node, children) {
|
|
if (children === null || children === undefined || children === false) return node;
|
|
if (!(children instanceof Array)) children = [children];
|
|
for (var i = 0; i < children.length; i++) {
|
|
var c = children[i];
|
|
if (c === null || c === undefined || c === false) continue;
|
|
node.appendChild(typeof c === 'string' || typeof c === 'number' ? D.createTextNode(String(c)) : c);
|
|
}
|
|
return node;
|
|
};
|
|
|
|
ZF.clear = function (node) {
|
|
while (node && node.firstChild) node.removeChild(node.firstChild);
|
|
return node;
|
|
};
|
|
|
|
ZF.icon = function (name, cls) {
|
|
var svg = D.createElementNS(SVGNS, 'svg');
|
|
svg.setAttribute('class', 'ic ic-' + name + (cls ? ' ' + cls : ''));
|
|
svg.setAttribute('aria-hidden', 'true');
|
|
svg.setAttribute('focusable', 'false');
|
|
var use = D.createElementNS(SVGNS, 'use');
|
|
use.setAttributeNS(XLINK, 'xlink:href', '#i-' + name);
|
|
svg.appendChild(use);
|
|
return svg;
|
|
};
|
|
|
|
ZF.iconHtml = function (name, cls) {
|
|
return '<svg class="ic ic-' + name + (cls ? ' ' + cls : '') + '" aria-hidden="true" focusable="false">' +
|
|
'<use xlink:href="#i-' + name + '"></use></svg>';
|
|
};
|
|
|
|
/** Button with optional icon and label. */
|
|
ZF.button = function (o) {
|
|
var cls = 'btn' + (o.kind ? ' btn-' + o.kind : '') + (o.size ? ' btn-' + o.size : '') +
|
|
(o.label ? '' : ' btn-icon') + (o.cls ? ' ' + o.cls : '');
|
|
var b = ZF.el('button', {
|
|
type: o.type || 'button',
|
|
class: cls,
|
|
title: o.title || (o.label ? null : o.aria),
|
|
'aria-label': o.label ? null : (o.aria || o.title),
|
|
disabled: !!o.disabled,
|
|
onclick: o.onclick
|
|
});
|
|
if (o.icon) b.appendChild(ZF.icon(o.icon));
|
|
if (o.label) b.appendChild(ZF.el('span', { text: o.label }));
|
|
return b;
|
|
};
|
|
|
|
ZF.esc = function (value) {
|
|
return String(value === null || value === undefined ? '' : value)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
};
|
|
|
|
ZF.isTyping = function (target) {
|
|
if (!target) return false;
|
|
var tag = target.tagName;
|
|
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable;
|
|
};
|
|
|
|
/* ── Paths ──────────────────────────────────────────────────────────── */
|
|
|
|
ZF.path = {
|
|
norm: function (p) {
|
|
p = String(p || '/').replace(/\\/g, '/').replace(/\/{2,}/g, '/');
|
|
if (p.charAt(0) !== '/') p = '/' + p;
|
|
if (p.length > 1 && p.charAt(p.length - 1) === '/') p = p.slice(0, -1);
|
|
return p;
|
|
},
|
|
parent: function (p) {
|
|
p = ZF.path.norm(p);
|
|
if (p === '/') return null;
|
|
var i = p.lastIndexOf('/');
|
|
return i <= 0 ? '/' : p.slice(0, i);
|
|
},
|
|
join: function (dir, name) {
|
|
dir = ZF.path.norm(dir);
|
|
return dir === '/' ? '/' + name : dir + '/' + name;
|
|
},
|
|
base: function (p) {
|
|
p = ZF.path.norm(p);
|
|
return p === '/' ? '/' : p.slice(p.lastIndexOf('/') + 1);
|
|
},
|
|
ext: function (name) {
|
|
var i = String(name || '').lastIndexOf('.');
|
|
return i > 0 ? name.slice(i + 1).toLowerCase() : '';
|
|
},
|
|
segments: function (p) {
|
|
p = ZF.path.norm(p);
|
|
return p === '/' ? [] : p.slice(1).split('/');
|
|
},
|
|
/** True when `child` is `parent` or lives inside it. */
|
|
within: function (child, parent) {
|
|
child = ZF.path.norm(child);
|
|
parent = ZF.path.norm(parent);
|
|
if (parent === '/') return true;
|
|
return child === parent || child.indexOf(parent + '/') === 0;
|
|
}
|
|
};
|
|
|
|
/* ── Formatting ─────────────────────────────────────────────────────── */
|
|
|
|
ZF.bytes = function (n) {
|
|
if (typeof n !== 'number' || !isFinite(n) || n < 0) return '\u2014';
|
|
if (n < 1024) return n + ' B';
|
|
var units = ['KB', 'MB', 'GB', 'TB', 'PB'];
|
|
var i = -1;
|
|
do { n = n / 1024; i++; } while (n >= 1024 && i < units.length - 1);
|
|
return (n >= 100 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i];
|
|
};
|
|
|
|
ZF.rate = function (bps) {
|
|
return bps > 0 ? ZF.bytes(bps) + '/s' : '\u2014';
|
|
};
|
|
|
|
ZF.duration = function (sec) {
|
|
if (typeof sec !== 'number' || !isFinite(sec) || sec < 0) return '\u2014';
|
|
sec = Math.round(sec);
|
|
var d = Math.floor(sec / 86400);
|
|
var h = Math.floor((sec % 86400) / 3600);
|
|
var m = Math.floor((sec % 3600) / 60);
|
|
var s = sec % 60;
|
|
if (d > 0) return d + 'd ' + h + 'h';
|
|
if (h > 0) return h + 'h ' + m + 'm';
|
|
if (m > 0) return m + 'm ' + s + 's';
|
|
return s + 's';
|
|
};
|
|
|
|
ZF.plural = function (n, one, many) {
|
|
return n + ' ' + (n === 1 ? one : (many || one + 's'));
|
|
};
|
|
|
|
ZF.number = function (n) {
|
|
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
};
|
|
|
|
ZF.percent = function (part, total) {
|
|
if (!total || total <= 0) return 0;
|
|
return Math.max(0, Math.min(100, (part / total) * 100));
|
|
};
|
|
|
|
/** Case-insensitive natural comparison ("file2" < "file10"). */
|
|
ZF.naturalCompare = function (a, b) {
|
|
a = String(a).toLowerCase();
|
|
b = String(b).toLowerCase();
|
|
var re = /(\d+|\D+)/g;
|
|
var ax = a.match(re) || [];
|
|
var bx = b.match(re) || [];
|
|
var n = Math.min(ax.length, bx.length);
|
|
for (var i = 0; i < n; i++) {
|
|
var x = ax[i], y = bx[i];
|
|
if (x === y) continue;
|
|
var xd = /^\d/.test(x), yd = /^\d/.test(y);
|
|
if (xd && yd) {
|
|
var diff = parseInt(x, 10) - parseInt(y, 10);
|
|
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
if (x.length !== y.length) return x.length < y.length ? -1 : 1;
|
|
}
|
|
return x < y ? -1 : 1;
|
|
}
|
|
return ax.length === bx.length ? 0 : (ax.length < bx.length ? -1 : 1);
|
|
};
|
|
|
|
/* ── File types ─────────────────────────────────────────────────────── */
|
|
|
|
var TYPES = [
|
|
{ kind: 'image', label: 'Image', icon: 'image', ext: 'png jpg jpeg gif bmp webp svg ico tif tiff heic' },
|
|
{ kind: 'video', label: 'Video', icon: 'video', ext: 'mp4 m4v mkv mov avi webm wmv flv' },
|
|
{ kind: 'audio', label: 'Audio', icon: 'audio', ext: 'mp3 wav flac aac ogg m4a opus wma at9' },
|
|
{ kind: 'archive', label: 'Archive', icon: 'archive', ext: 'zip 7z rar tar gz tgz bz2 xz zst lz4' },
|
|
{ kind: 'package', label: 'Game package', icon: 'package', ext: 'pkg fpkg ffpkg' },
|
|
{ kind: 'image-disk', label: 'Disk image', icon: 'disc', ext: 'exfat ffpfs iso img pup' },
|
|
{ kind: 'text', label: 'Text', icon: 'file-text', ext: 'txt md log ini cfg conf csv tsv nfo lst' },
|
|
{ kind: 'code', label: 'Source', icon: 'code', ext: 'c h cc cpp hpp py js ts json xml yml yaml toml html htm css sh lua rs go java php rb mk' },
|
|
{ kind: 'binary', label: 'Executable', icon: 'file', ext: 'elf self sprx prx so dll exe' },
|
|
{ kind: 'document', label: 'Document', icon: 'file-text', ext: 'pdf doc docx odt rtf' }
|
|
];
|
|
var EXT_MAP = {};
|
|
(function () {
|
|
for (var i = 0; i < TYPES.length; i++) {
|
|
var list = TYPES[i].ext.split(' ');
|
|
for (var j = 0; j < list.length; j++) EXT_MAP[list[j]] = TYPES[i];
|
|
}
|
|
})();
|
|
|
|
ZF.fileType = function (name, isDir) {
|
|
if (isDir) return { kind: 'folder', label: 'Folder', icon: 'folder' };
|
|
var ext = ZF.path.ext(name);
|
|
var t = EXT_MAP[ext];
|
|
if (t) return { kind: t.kind, label: t.label, icon: t.icon, ext: ext };
|
|
return { kind: 'file', label: ext ? ext.toUpperCase() + ' file' : 'File', icon: 'file', ext: ext };
|
|
};
|
|
|
|
ZF.isArchive = function (name) { return ZF.fileType(name, false).kind === 'archive'; };
|
|
ZF.isPackage = function (name) { return ZF.fileType(name, false).kind === 'package'; };
|
|
ZF.isGameImage = function (name) {
|
|
var ext = ZF.path.ext(name);
|
|
return ext === 'pkg' || ext === 'fpkg' || ext === 'ffpkg' || ext === 'exfat';
|
|
};
|
|
|
|
/* ── Settings ───────────────────────────────────────────────────────── */
|
|
|
|
var SETTINGS_KEY = 'zftpd.settings';
|
|
var DEFAULTS = {
|
|
theme: 'system',
|
|
density: 'comfortable',
|
|
showHidden: false,
|
|
showProtected: false,
|
|
startPath: '/',
|
|
downloadDest: '/',
|
|
bookmarks: [],
|
|
fanThreshold: 60,
|
|
sortKey: 'name',
|
|
sortDir: 1,
|
|
sidebarCollapsed: false
|
|
};
|
|
|
|
function loadSettings() {
|
|
var out = {};
|
|
var stored = {};
|
|
try { stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') || {}; } catch (e) { stored = {}; }
|
|
for (var k in DEFAULTS) {
|
|
if (DEFAULTS.hasOwnProperty(k)) out[k] = stored.hasOwnProperty(k) ? stored[k] : DEFAULTS[k];
|
|
}
|
|
if (!(out.bookmarks instanceof Array)) out.bookmarks = [];
|
|
return out;
|
|
}
|
|
|
|
ZF.settings = loadSettings();
|
|
|
|
ZF.setSetting = function (key, value) {
|
|
ZF.settings[key] = value;
|
|
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(ZF.settings)); } catch (e) { /* private mode */ }
|
|
ZF.emit('settings', key);
|
|
};
|
|
|
|
ZF.resetSettings = function () {
|
|
for (var k in DEFAULTS) {
|
|
if (DEFAULTS.hasOwnProperty(k)) ZF.settings[k] = k === 'bookmarks' ? [] : DEFAULTS[k];
|
|
}
|
|
try { localStorage.removeItem(SETTINGS_KEY); } catch (e) { /* ignore */ }
|
|
ZF.emit('settings', '*');
|
|
};
|
|
|
|
/* Root-level system directories that are hidden unless explicitly enabled. */
|
|
var PROTECTED = ['system', 'system_ex', 'system_data', 'preinst', 'preinst2', 'mini-syscore',
|
|
'sandcastle', 'update', 'dev', 'proc', 'sys', 'kern'];
|
|
|
|
ZF.isProtected = function (dir, name) {
|
|
return ZF.path.norm(dir) === '/' && PROTECTED.indexOf(name) >= 0;
|
|
};
|
|
|
|
ZF.features = { pkgInstall: false };
|
|
|
|
/* ── Misc ───────────────────────────────────────────────────────────── */
|
|
|
|
ZF.debounce = function (fn, ms) {
|
|
var t = null;
|
|
return function () {
|
|
var args = arguments, ctx = this;
|
|
clearTimeout(t);
|
|
t = setTimeout(function () { fn.apply(ctx, args); }, ms);
|
|
};
|
|
};
|
|
|
|
ZF.copyText = function (text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) {
|
|
return navigator.clipboard.writeText(text);
|
|
}
|
|
return new Promise(function (resolve, reject) {
|
|
var ta = D.createElement('textarea');
|
|
ta.value = text;
|
|
ta.setAttribute('readonly', '');
|
|
ta.style.cssText = 'position:fixed;left:-9999px;top:0;';
|
|
D.body.appendChild(ta);
|
|
ta.select();
|
|
var ok = false;
|
|
try { ok = D.execCommand('copy'); } catch (e) { ok = false; }
|
|
D.body.removeChild(ta);
|
|
if (ok) resolve(); else reject(new Error('Copy failed'));
|
|
});
|
|
};
|
|
|
|
ZF.triggerDownload = function (url, name) {
|
|
var a = D.createElement('a');
|
|
a.href = url;
|
|
if (name) a.setAttribute('download', name);
|
|
a.style.display = 'none';
|
|
D.body.appendChild(a);
|
|
a.click();
|
|
D.body.removeChild(a);
|
|
};
|
|
})(ZF);
|