Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 1 | /* ══ UTILITIES ════════════════════════════════════════════════════════════ |
| 2 | * Path helpers, byte formatting, CSRF token, DOM helpers. |
| 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 | |
| 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 = '×'; |
| 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;">×</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;">×</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 | '↑ 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 |