Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 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 | |
| 6 | var 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">×' + 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);">×</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">×' + 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 |