Seregon/zftpd

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

C/11.0 KB/No license
web/js/views/dashboard.js
zftpd / web / js / views / dashboard.js
1/* ══ DASHBOARD VIEW — PS5 Hub Style ═══════════════════════════════════════
2 * Homepage with game cards, quick actions, stats widgets, recent files.
3 * ES5 compatible for PS5 browser.
4 * ═════════════════════════════════════════════════════════════════════════ */
5 
6var ZFTPD = ZFTPD || {};
7 
8(function (Z) {
9 'use strict';
10 
11 var D = document;
12 var $ = Z.$;
13 var ICO = Z.ICO;
14 
15 var dashboard = {};
16 var _games = [];
17 var _uniqueGames = [];
18 var _recentFiles = [];
19 dashboard.heroSet = false;
20 
21 /* ── Refresh dashboard data ── */
22 dashboard.refresh = function () {
23 dashboard.heroSet = false;
24 var hc = $('dash-hero-container');
25 if (hc) { hc.innerHTML = ''; hc.style.display = 'none'; }
26 loadGames();
27 loadRecentFiles();
28 loadStats();
29 };
30 
31 /* ── Load games — recursive scan ────────────── */
32 var _gameExts = { pkg: 1, fpkg: 1, ffpkg: 1, exfat: 1 };
33 var _searchRoots = ['/mnt/usb0', '/mnt/usb1', '/', '/data', '/user'];
34 
35 function loadGames() {
36 _games = [];
37 var pending = 0;
38 var seen = {};
39 var metaPromises = [];
40 dashboard.heroSet = false;
41 
42 var row = $('dash-games');
43 if (row) {
44 row.innerHTML = '';
45 for (var s = 0; s < 6; s++) {
46 row.innerHTML += '<div class="dash-game-card shimmer" style="border-color:transparent"><div class="dash-game-cover"></div><div class="dash-game-info"><div class="dash-game-title" style="height:14px;background:var(--bd2);border-radius:4px;width:80%"></div><div class="dash-game-id" style="height:10px;background:var(--bd);border-radius:4px;width:40%;margin-top:6px"></div></div></div>';
47 }
48 }
49 
50 function scanDir(dirPath, depth) {
51 if (depth > 2) return;
52 pending++;
53 Z.api.list(dirPath).then(function (d) {
54 var entries = (d && Array.isArray(d.entries)) ? d.entries : [];
55 for (var i = 0; i < entries.length; i++) {
56 var e = entries[i];
57 var fullPath = Z.join(dirPath, e.name);
58 if (e.type === 'directory' && depth < 2) {
59 scanDir(fullPath, depth + 1);
60 } else {
61 var ext = Z.extname(e.name);
62 if (e.name.indexOf('._') === 0) continue;
63 
64 if (_gameExts[ext] && !seen[fullPath]) {
65 seen[fullPath] = 1;
66 var g = { name: e.name, size: e.size, path: fullPath, meta: null };
67 _games.push(g);
68
69 /* Fetch meta to get content_id for reliable deduplication */
70 var metaPromise = Z.api.gameMeta(fullPath).then((function(gameObj) {
71 return function(meta) { if(meta) gameObj.meta = meta; };
72 })(g)).catch(function(){});
73
74 metaPromises.push(metaPromise);
75 }
76 }
77 }
78 pending--;
79 if (pending === 0) finishLoad();
80 }).catch(function () {
81 pending--;
82 if (pending === 0) finishLoad();
83 });
84 }
85
86 function finishLoad() {
87 if (metaPromises.length > 0) {
88 Promise.all(metaPromises).then(computeUniqueGames).catch(computeUniqueGames);
89 } else {
90 computeUniqueGames();
91 }
92 }
93 
94 for (var r = 0; r < _searchRoots.length; r++) {
95 scanDir(_searchRoots[r], 0);
96 }
97 }
98 
99 function getFingerprintFromName(name) {
100 var match = name.match(/(CUSA\d{5}|PPSA\d{5})/i);
101 if (match) return match[1].toUpperCase();
102
103 var n = name.toLowerCase();
104 n = n.replace(/\.(exfat|pkg|fpkg|ffpkg|ufs)$/i, '');
105 n = n.replace(/_v\d+\.\d+/g, '');
106 n = n.replace(/_\[.*?\]/g, '');
107 n = n.replace(/_backport/gi, '');
108 n = n.replace(/_opoisso\d+/gi, '');
109 n = n.replace(/_cyb1k/gi, '');
110 n = n.replace(/-\[dlpsgame.*?\]/gi, '');
111 return n;
112 }
113 
114 function computeUniqueGames() {
115 var groups = {};
116 _uniqueGames = [];
117 for (var i = 0; i < _games.length; i++) {
118 var g = _games[i];
119 /* Deduplicate strictly by content_id if available, fallback to filename logic */
120 var fp = (g.meta && g.meta.content_id && g.meta.content_id.length > 0) ? g.meta.content_id : getFingerprintFromName(g.name);
121
122 if (!groups[fp]) {
123 var ref = { fingerprint: fp, name: g.name, locations: [g], meta: g.meta, coverUrl: null };
124 groups[fp] = ref;
125 _uniqueGames.push(ref);
126 } else {
127 groups[fp].locations.push(g);
128 }
129 }
130 renderGames();
131 }
132 
133 /* ── Render game cards row ── */
134 function renderGames() {
135 var row = $('dash-games');
136 if (!row) return;
137 row.innerHTML = '';
138 
139 if (!_uniqueGames.length) {
140 row.innerHTML = '<div class="dash-game-card" style="width:280px;display:flex;align-items:center;justify-content:center;padding:20px;color:var(--tx3);font-size:12px;">' +
141 ICO.gamepad + ' <span style="margin-left:8px">No game files found</span></div>';
142 return;
143 }
144 
145 /* We only show a limited number on the hero row */
146 for (var i = 0; i < _uniqueGames.length && i < 20; i++) {
147 var ug = _uniqueGames[i];
148 var card = D.createElement('div');
149 card.className = 'dash-game-card';
150 
151 var badgeHtml = '';
152 if (ug.locations.length > 1) {
153 badgeHtml = '<div class="dash-loc-badge">&times;' + ug.locations.length + ' Locs</div>';
154 }
155 
156 var iconUrl = null;
157 if (ug.meta && (ug.meta.has_icon || ug.meta.icon_base64)) {
158 iconUrl = ug.meta.icon_base64 ? ('data:image/png;base64,' + ug.meta.icon_base64) : Z.api.gameIconUrl(ug.locations[0].path);
159 ug.coverUrl = iconUrl;
160 setHeroBanner(ug, iconUrl);
161 }
162 
163 var coverHtml = iconUrl ? '<img class="dash-game-cover" src="' + iconUrl + '">' : '<div class="dash-game-cover placeholder">' + ICO.gamepad + '</div>';
164 var title = ug.meta && ug.meta.title_name ? ug.meta.title_name : ug.name.replace(/\.(exfat|pkg|fpkg|ffpkg)$/i, '');
165 var cusa = ug.meta && ug.meta.title_id ? ug.meta.title_id : ug.fingerprint;
166 
167 card.innerHTML = coverHtml + badgeHtml +
168 '<div class="dash-game-info">' +
169 '<div class="dash-game-title" title="' + title + '">' + title + '</div>' +
170 '<div class="dash-game-id">' + cusa + '</div>' +
171 '</div>';
172 
173 (function (fp) {
174 card.onclick = function () {
175 dashboard.playGame(fp);
176 };
177 })(ug.fingerprint);
178 
179 row.appendChild(card);
180 }
181 }
182 
183 function setHeroBanner(ug, iconUrl) {
184 if (dashboard.heroSet) return;
185 dashboard.heroSet = true;
186 var hc = $('dash-hero-container');
187 if (!hc) return;
188 
189 var title = ug.meta && ug.meta.title_name ? ug.meta.title_name : ug.name.replace(/\.(pkg|fpkg|exfat)$/i, '');
190 var cusa = ug.meta && ug.meta.title_id ? ug.meta.title_id : ug.fingerprint;
191 
192 var html =
193 '<div class="dash-hero-banner">' +
194 '<div class="dash-hero-bg" style="background-image:url(\''+iconUrl+'\')"></div>' +
195 '<div class="dash-hero-content">' +
196 '<img class="dash-hero-cover" src="'+iconUrl+'">' +
197 '<div class="dash-hero-info">' +
198 '<div class="dash-hero-meta">Featured Game</div>' +
199 '<div class="dash-hero-title" title="'+title+'">'+title+'</div>' +
200 '<div class="dash-hero-id">'+cusa+'</div>' +
201 '</div>' +
202 '<div class="dash-hero-actions">' +
203 '<button class="btn" style="background:var(--ac);color:#fff;border:none;padding:12px 24px;font-weight:700;font-size:14px;border-radius:24px;cursor:pointer;box-shadow:0 8px 16px rgba(0,0,0,0.5);" onclick="ZFTPD.dashboard.playGame(\''+ug.fingerprint+'\')">Play / Browse</button>' +
204 '</div>' +
205 '</div>' +
206 '</div>';
207 hc.innerHTML = html;
208 hc.style.display = 'flex';
209 }
210 
211 dashboard.playGame = function(fp) {
212 var ug = null;
213 for (var i = 0; i < _uniqueGames.length; i++) {
214 if (_uniqueGames[i].fingerprint === fp) { ug = _uniqueGames[i]; break; }
215 }
216 if (!ug) return;
217 
218 var titleId = ug.meta && ug.meta.title_id ? ug.meta.title_id : null;
219 var title = ug.meta && ug.meta.title_name ? ug.meta.title_name : ug.name.replace(/\.(pkg|fpkg|exfat)$/i, '');
220 
221 var d = document.getElementById('dash-action-modal');
222 if (d) d.parentNode.removeChild(d);
223 
224 var html =
225 '<div id="dash-action-modal" class="dash-loc-dropdown">' +
226 '<div class="dash-loc-box">' +
227 '<div class="dash-loc-box-title">' + title + ' <span class="close-btn" onclick="var d=document.getElementById(\'dash-action-modal\');d.parentNode.removeChild(d);">&times;</span></div>' +
228 '<div class="dash-loc-box-sub">Choose an action for this game</div>' +
229 '<div class="dash-loc-list" style="display:flex;flex-direction:column;gap:10px;padding:10px;">';
230 
231 var launchPath = (ug.locations && ug.locations[0] && ug.locations[0].path) ? ug.locations[0].path : null;
232 if (titleId || launchPath) {
233 var launchUrl = titleId
234 ? ("/api/admin/launch?id=" + encodeURIComponent(titleId))
235 : ("/api/admin/launch?path=" + encodeURIComponent(launchPath));
236 var launchJs = "fetch('" + launchUrl + "').then(function(r){return r.json();}).then(function(j){ ZFTPD.toast((j&&j.message) || 'Launch signal sent', (j&&j.status)==='ok'?'success':'error'); var m=document.getElementById('dash-action-modal'); if(m)m.parentNode.removeChild(m); }).catch(function(){ZFTPD.toast('Launch failed','error');})";
237 html +=
238 '<button class="btn" style="background:var(--ac);color:#fff;border:none;padding:12px;border-radius:8px;cursor:pointer;font-weight:bold;display:flex;align-items:center;justify-content:center;gap:8px;" onclick="' + launchJs + '">' +
239 ICO.gamepad + ' Launch Game' + (titleId ? (' (' + titleId + ')') : '') +
240 '</button>';
241 }
242 
243 if (ug.locations.length === 1) {
244 html +=
245 '<button class="btn" style="background:var(--bg2);color:var(--tx1);border:1px solid var(--bd);padding:12px;border-radius:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;" onclick="ZFTPD.dashboard.navTo(\'' + Z.E(ug.locations[0].path) + '\')">' +
246 ICO.folder + ' Browse Files' +
247 '</button>';
248 } else {
249 html += '<div style="font-size:12px;color:var(--tx3);margin-top:10px;">Browse Duplicate Files:</div>';
250 for (var i = 0; i < ug.locations.length; i++) {
251 var loc = ug.locations[i];
252 var drive = loc.path.indexOf('usb0') > -1 ? 'USB0' : (loc.path.indexOf('usb1') > -1 ? 'USB1' : (loc.path.indexOf('data') > -1 ? 'DATA' : 'SYS'));
253 html +=
254 '<div class="dash-loc-list-item" onclick="ZFTPD.dashboard.navTo(\''+Z.E(loc.path)+'\')">' +
255 '<div class="dash-loc-drive">'+drive+'</div>' +
256 '<div class="dash-loc-path" title="'+loc.path+'">'+loc.path+'</div>' +
257 '<div class="dash-loc-size">'+Z.bytes(loc.size||0)+'</div>' +
258 '</div>';
259 }
260 }
261
262 html += '</div></div></div>';
263 var div = document.createElement('div');
264 div.innerHTML = html;
265 document.body.appendChild(div.firstChild);
266 };
267 
268 dashboard.navTo = function(pathUrl) {
269 var path = decodeURIComponent(pathUrl);
270 var d = document.getElementById('dash-action-modal');
271 if (d) d.parentNode.removeChild(d);
272
273 Z.switchView('explorer');
274 setTimeout(function() { if(Z.explorer) Z.explorer.nav(Z.parent(path) || '/'); }, 100);
275 };
276 
277 /* ── Load recent files ── */
278 function loadRecentFiles() {
279 Z.api.list(Z.state.path || '/').then(function (d) {
280 var entries = (d && Array.isArray(d.entries)) ? d.entries : [];
281 _recentFiles = entries.filter(function (e) {
282 return e.type !== 'directory' && e.name.indexOf('._') !== 0;
283 })
284 .sort(function (a, b) { return (b.mtime || 0) - (a.mtime || 0); })
285 .slice(0, 8);
286 renderRecent();
287 }).catch(function () { });
288 }
289 
290 function renderRecent() {
291 var list = $('dash-recent-list');
292 if (!list) return;
293 list.innerHTML = '';
294 
295 if (!_recentFiles.length) {
296 list.innerHTML = '<div style="padding:16px;color:var(--tx3);font-size:12px;text-align:center;">No files found</div>';
297 return;
298 }
299 
300 for (var i = 0; i < _recentFiles.length; i++) {
301 var f = _recentFiles[i];
302 var cat = Z.fileCategory(f.name, false);
303 var path = Z.join(Z.state.path || '/', f.name);
304 
305 var item = D.createElement('div');
306 item.className = 'dash-recent-item';
307 item.innerHTML =
308 '<div class="dash-recent-ico fi-' + cat + '">' + ICO.file + '</div>' +
309 '<div class="dash-recent-name" title="' + f.name + '">' + f.name + '</div>' +
310 '<div class="dash-recent-size">' + Z.bytes(f.size || 0) + '</div>';
311 
312 (function (p) {
313 item.onclick = function () {
314 window.location.href = Z.api.downloadUrl(p);
315 };
316 })(path);
317 
318 list.appendChild(item);
319 }
320 }
321 
322 /* ── Load & render stats ── */
323 function loadStats() {
324 Z.api.stats('/').then(function (d) {
325 dashboard.updateStats(d);
326 }).catch(function () { });
327 }
328 
329 dashboard.updateStats = function (d) {
330 if (!d || typeof d !== 'object') return;
331 
332 /* Disk Ring */
333 var hasDisk = (typeof d.disk_used === 'number') && (typeof d.disk_total === 'number') && d.disk_total > 0;
334 if (hasDisk) {
335 var pct = Math.min(100, Math.floor(d.disk_used / d.disk_total * 100));
336 var txt = $('dash-disk-txt');
337 if (txt) txt.textContent = pct + '%';
338 var sub = $('dash-disk-sub');
339 if (sub) sub.textContent = Z.bytes(d.disk_used) + ' / ' + Z.bytes(d.disk_total);
340
341 var ring = $('dash-disk-ring');
342 if (ring) {
343 var offset = 220 - (pct / 100) * 220;
344 ring.style.strokeDashoffset = offset;
345 ring.className.baseVal = 'dash-stat-ring-fg' + (pct > 85 ? ' cr' : pct > 70 ? ' wn' : '');
346 }
347 }
348 
349 /* Temp Ring */
350 if (typeof d.cpu_temp === 'number') {
351 var tTxt = $('dash-temp-txt');
352 if (tTxt) tTxt.textContent = d.cpu_temp + '\u00b0C';
353 var tSub = $('dash-temp-sub');
354 if (tSub) tSub.textContent = d.cpu_temp > 65 ? 'Running hot' : 'Normal';
355
356 var tRing = $('dash-temp-ring');
357 if (tRing) {
358 var tpct = Math.min(100, d.cpu_temp);
359 var toffset = 220 - (tpct / 100) * 220;
360 tRing.style.strokeDashoffset = Math.max(0, toffset);
361 tRing.className.baseVal = 'dash-stat-ring-fg' + (d.cpu_temp > 65 ? ' wn' : '');
362 }
363 }
364 };
365 
366 /* ── Quick action handlers ── */
367 dashboard.goExplorer = function () { Z.switchView('explorer'); };
368 dashboard.goFileManager = function () { Z.switchView('filemanager'); };
369 dashboard.goDownloads = function () { Z.switchView('downloads'); };
370 dashboard.doUpload = function () {
371 Z.switchView('explorer');
372 setTimeout(function () {
373 var fi = $('file-input');
374 if (fi) fi.click();
375 }, 200);
376 };
377 
378 /* ── See All Games List Modal ── */
379 dashboard.showGamesList = function () {
380 if (Z.modal) {
381 var html = '<div style="display:flex;flex-wrap:wrap;gap:16px;align-content:start;">';
382 for (var i = 0; i < _uniqueGames.length; i++) {
383 var ug = _uniqueGames[i];
384
385 var badgeHtml = '';
386 if (ug.locations.length > 1) {
387 badgeHtml = '<div class="dash-loc-badge">&times;' + ug.locations.length + ' Locs</div>';
388 }
389 var coverUrl = ug.coverUrl ? ug.coverUrl : '';
390 var coverHtml = coverUrl ? '<img class="dash-game-cover" src="'+coverUrl+'">' : '<div class="dash-game-cover placeholder">' + ICO.gamepad + '</div>';
391 var title = ug.meta && ug.meta.title_name ? ug.meta.title_name : ug.name.replace(/\.(exfat|pkg|fpkg|ffpkg)$/i, '');
392 var id = ug.meta && ug.meta.title_id ? ug.meta.title_id : getFingerprintFromName(ug.name);
393 
394 html +=
395 '<div class="dash-game-card" onclick="ZFTPD.dashboard.playGame(\''+ug.fingerprint+'\'); ZFTPD.modal.close()">' +
396 coverHtml + badgeHtml +
397 '<div class="dash-game-info">' +
398 '<div class="dash-game-title" title="'+ug.name+'">' + title + '</div>' +
399 '<div class="dash-game-id">' + id + '</div>' +
400 '</div>' +
401 '</div>';
402 }
403 html += '</div>';
404 
405 Z.modal.showHTML('Full Game Library', html);
406
407 var d = document.getElementById('zftpd-modal-content');
408 if (d) {
409 d.style.overflowY = 'auto';
410 d.style.maxHeight = '60vh';
411 d.style.padding = '24px 32px';
412 }
413 }
414 };
415 
416 Z.dashboard = dashboard;
417 
418})(ZFTPD);
419