Seregon/zftpd

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

C/11.0 KB/No license
web/js/views/download-mgr.js
zftpd / web / js / views / download-mgr.js
1/* ══ DOWNLOAD MANAGER VIEW ════════════════════════════════════════════════
2 * URL input, active downloads, history.
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 dlm = {};
16 var _downloads = [];
17 var _history = [];
18 var _pollTimer = null;
19 var _destPath = '/';
20 
21 dlm.refresh = function () {
22 renderActive();
23 renderHistory();
24 startPolling();
25 };
26 
27 /* ── Start a download ── */
28 dlm.start = function () {
29 var input = $('dl-url');
30 if (!input) return;
31 var url = (input.value || '').trim();
32 if (!url) {
33 Z.toast('Enter a URL first', 'wn');
34 input.focus();
35 return;
36 }
37 
38 /* Detect URL type */
39 var type = detectUrlType(url);
40 var displayName = extractFilename(url) || 'download';
41 
42 Z.toast('Starting download: ' + displayName, 'ok');
43 input.value = '';
44 
45 var entry = {
46 id: Date.now(),
47 url: url,
48 name: displayName,
49 type: type,
50 dst: _destPath,
51 status: 'starting',
52 progress: 0,
53 speed: 0,
54 size: 0,
55 downloaded: 0,
56 startTime: Date.now()
57 };
58 _downloads.push(entry);
59 renderActive();
60 
61 Z.api.downloadStart(url, _destPath).then(function (d) {
62 entry.status = 'downloading';
63 if (d && d.id) entry.serverId = d.id;
64 if (d && d.name) entry.name = d.name;
65 if (d && d.size) entry.size = d.size;
66 renderActive();
67 /* Start topbar download progress polling */
68 if (Z.onDownloadStarted) Z.onDownloadStarted();
69 }).catch(function (e) {
70 entry.status = 'error';
71 entry.error = e.message;
72 renderActive();
73 Z.notify('Download failed', displayName + ': ' + e.message, 'er');
74 });
75 };
76 
77 /* ── URL type detection ── */
78 function detectUrlType(url) {
79 if (/magnet:/i.test(url)) return 'magnet';
80 if (/drive\.google\.com/i.test(url)) return 'gdrive';
81 if (/mega\.(nz|co\.nz)/i.test(url)) return 'mega';
82 if (/mediafire\.com/i.test(url)) return 'mediafire';
83 if (/1fichier\.com/i.test(url)) return '1fichier';
84 return 'http';
85 }
86 
87 /* ── Extract filename from URL ── */
88 function extractFilename(url) {
89 try {
90 if (/magnet:/i.test(url)) {
91 var dnMatch = url.match(/dn=([^&]+)/);
92 return dnMatch ? decodeURIComponent(dnMatch[1]) : 'magnet-download';
93 }
94 var path = url.split('?')[0].split('#')[0];
95 var parts = path.split('/');
96 var last = parts[parts.length - 1];
97 return last ? decodeURIComponent(last) : 'download';
98 } catch (e) {
99 return 'download';
100 }
101 }
102 
103 /* ── Type icon ── */
104 function typeIcon(type) {
105 switch (type) {
106 case 'magnet': return ICO.link;
107 case 'gdrive': return ICO.cloud;
108 case 'mega': return ICO.cloud;
109 case 'mediafire': return ICO.cloud;
110 default: return ICO.cloudDown;
111 }
112 }
113 
114 /* ── Render active downloads ── */
115 function renderActive() {
116 var wrap = $('dl-active');
117 if (!wrap) return;
118 wrap.innerHTML = '';
119 
120 var countEl = $('dl-active-count');
121 var active = _downloads.filter(function (d) { return d.status !== 'done' && d.status !== 'error'; });
122 if (countEl) countEl.textContent = active.length;
123 
124 if (!_downloads.length) {
125 wrap.innerHTML = '<div class="dl-empty"><div class="dl-empty-icon">' + ICO.cloudDown + '</div><div>No active downloads</div><div style="font-size:11px;color:var(--tx3);">Paste a URL above to start downloading</div></div>';
126 return;
127 }
128 
129 for (var i = 0; i < _downloads.length; i++) {
130 var dl = _downloads[i];
131 var card = D.createElement('div');
132 card.className = 'dl-card';
133 
134 var elapsed = (Date.now() - dl.startTime) / 1000;
135 var speedStr = dl.speed > 0 ? Z.bps(dl.speed) : '\u2014';
136 var etaStr = dl.speed > 0 && dl.size > 0 ? Z.duration((dl.size - dl.downloaded) / dl.speed) : '\u2014';
137 var progressClass = dl.status === 'error' ? ' error' : dl.status === 'done' ? ' done' : '';
138 var statusLabel = dl.status === 'downloading' ? 'Downloading' : dl.status === 'starting' ? 'Starting\u2026' : dl.status === 'paused' ? 'Paused' : dl.status === 'done' ? 'Complete' : dl.status === 'error' ? 'Failed' : dl.status;
139 
140 card.innerHTML =
141 '<div class="dl-card-top">' +
142 '<div class="dl-card-icon">' + typeIcon(dl.type) + '</div>' +
143 '<div class="dl-card-info">' +
144 '<div class="dl-card-name">' + dl.name + '</div>' +
145 '<div class="dl-card-url">' + dl.url.substring(0, 80) + (dl.url.length > 80 ? '\u2026' : '') + '</div>' +
146 '</div>' +
147 '<div class="dl-card-stats">' +
148 '<div class="dl-stat"><div class="dl-stat-value">' + speedStr + '</div><div class="dl-stat-label">Speed</div></div>' +
149 '<div class="dl-stat"><div class="dl-stat-value">' + etaStr + '</div><div class="dl-stat-label">ETA</div></div>' +
150 '<div class="dl-stat"><div class="dl-stat-value">' + dl.progress + '%</div><div class="dl-stat-label">' + statusLabel + '</div></div>' +
151 '</div>' +
152 '</div>' +
153 '<div class="dl-progress"><div class="dl-progress-fill' + progressClass + '" style="width:' + dl.progress + '%"></div></div>' +
154 '<div class="dl-card-actions">' +
155 (dl.status === 'downloading' ? '<button class="btn" data-action="pause" data-id="' + dl.id + '">' + ICO.pause + ' Pause</button>' : '') +
156 (dl.status === 'paused' ? '<button class="btn" data-action="resume" data-id="' + dl.id + '">' + ICO.play + ' Resume</button>' : '') +
157 '<button class="btn danger" data-action="cancel" data-id="' + dl.id + '">' + ICO.xcancel + ' Cancel</button>' +
158 '</div>';
159 
160 wrap.appendChild(card);
161 }
162 
163 /* Wire action buttons */
164 var btns = wrap.querySelectorAll('button[data-action]');
165 for (var b = 0; b < btns.length; b++) {
166 (function (btn) {
167 btn.onclick = function () {
168 var action = btn.getAttribute('data-action');
169 var id = parseInt(btn.getAttribute('data-id'), 10);
170 handleAction(action, id);
171 };
172 })(btns[b]);
173 }
174 }
175 
176 /* ── Handle button actions ── */
177 function handleAction(action, id) {
178 var dl = _downloads.filter(function (d) { return d.id === id; })[0];
179 if (!dl) return;
180 
181 if (action === 'cancel') {
182 if (dl.serverId) Z.api.downloadCancel(dl.serverId).catch(function () { });
183 dl.status = 'error';
184 dl.error = 'Cancelled';
185 /* Move to history */
186 _history.unshift({ name: dl.name, size: dl.downloaded, date: Date.now(), status: 'cancelled' });
187 _downloads = _downloads.filter(function (d) { return d.id !== id; });
188 renderActive();
189 renderHistory();
190 Z.toast('Download cancelled', 'wn');
191 } else if (action === 'pause') {
192 if (dl.serverId) Z.api.downloadPause(dl.serverId).catch(function () { });
193 dl.status = 'paused';
194 renderActive();
195 } else if (action === 'resume') {
196 if (dl.serverId) Z.api.downloadPause(dl.serverId).catch(function () { });
197 dl.status = 'downloading';
198 renderActive();
199 }
200 }
201 
202 /* ── Render history ── */
203 function renderHistory() {
204 var wrap = $('dl-history');
205 if (!wrap) return;
206 wrap.innerHTML = '';
207 
208 if (!_history.length) {
209 wrap.innerHTML = '<div style="padding:16px;text-align:center;color:var(--tx3);font-size:11px;">No download history</div>';
210 return;
211 }
212 
213 for (var i = 0; i < _history.length && i < 20; i++) {
214 var h = _history[i];
215 var item = D.createElement('div');
216 item.className = 'dl-history-item';
217 item.innerHTML =
218 '<div class="dl-history-name">' + h.name + '</div>' +
219 '<div class="dl-history-size">' + Z.bytes(h.size || 0) + '</div>' +
220 '<div class="dl-history-date">' + Z.relativeTime(Math.floor(h.date / 1000)) + '</div>' +
221 '<div class="dl-history-status ' + (h.status === 'complete' ? 'ok' : 'er') + '">' + h.status + '</div>';
222 wrap.appendChild(item);
223 }
224 }
225 
226 /* ── Poll for progress ── */
227 function startPolling() {
228 if (_pollTimer) clearInterval(_pollTimer);
229 _pollTimer = setInterval(function () {
230 var active = _downloads.filter(function (d) { return d.status === 'downloading'; });
231 if (!active.length) return;
232 
233 Z.api.downloadStatus().then(function (data) {
234 if (!data || !Array.isArray(data.downloads)) return;
235 for (var i = 0; i < data.downloads.length; i++) {
236 var sd = data.downloads[i];
237 var local = _downloads.filter(function (d) { return d.serverId === sd.id; })[0];
238 if (!local) continue;
239 local.progress = sd.progress || 0;
240 local.speed = sd.speed || 0;
241 local.downloaded = sd.downloaded || 0;
242 local.size = sd.total_size || local.size;
243 if (sd.done) {
244 local.status = sd.error ? 'error' : 'done';
245 if (sd.error) local.error = sd.error;
246 /* Move to history */
247 _history.unshift({ name: local.name, size: local.size, date: Date.now(), status: local.status === 'done' ? 'complete' : 'failed' });
248 _downloads = _downloads.filter(function (d) { return d.id !== local.id; });
249 if (local.status === 'done') Z.toast('Downloaded: ' + local.name, 'ok');
250 }
251 }
252 renderActive();
253 renderHistory();
254 }).catch(function () { });
255 }, 1500);
256 }
257 
258 /* ── Destination selector — modal folder browser ──────────────────────
259 *
260 * Opens an overlay that browses the console filesystem via /api/list.
261 * Only directories are shown; clicking one navigates into it.
262 * "Select this folder" confirms the choice.
263 *
264 * ┌──────────────────────────────────────────────┐
265 * │ Choose download destination [✕] │
266 * │ ─────────────────────────────────────────── │
267 * │ / > data > user │
268 * │ [↑ Back] │
269 * │ ┌──────────────────────────────┐ │
270 * │ │ 📁 folder_a │ │
271 * │ │ 📁 folder_b │ │
272 * │ │ 📁 folder_c │ │
273 * │ └──────────────────────────────┘ │
274 * │ [ Select this folder ] │
275 * └──────────────────────────────────────────────┘
276 */
277 dlm.selectDest = function () {
278 var browsePath = _destPath;
279 var overlay = D.createElement('div');
280 overlay.className = 'dl-dest-overlay';
281 overlay.style.cssText = 'position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.65);' +
282 'display:flex;align-items:center;justify-content:center;animation:fadeIn .15s ease;';
283 
284 var card = D.createElement('div');
285 card.style.cssText = 'background:var(--sf);border:1px solid var(--bd2);border-radius:16px;' +
286 'width:min(460px,90vw);max-height:70vh;display:flex;flex-direction:column;' +
287 'box-shadow:0 32px 80px rgba(0,0,0,.6);overflow:hidden;';
288 
289 /* Header */
290 var hdr = D.createElement('div');
291 hdr.style.cssText = 'display:flex;align-items:center;padding:16px 20px;' +
292 'border-bottom:1px solid var(--bd);gap:10px;';
293 hdr.innerHTML = '<div style="flex:1;font-weight:700;font-size:14px;color:var(--tx);">' +
294 'Choose download destination</div>' +
295 '<button id="dl-dest-close" style="background:none;border:none;color:var(--tx3);' +
296 'font-size:20px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>';
297 
298 /* Breadcrumb bar */
299 var bcBar = D.createElement('div');
300 bcBar.style.cssText = 'padding:10px 20px 4px;font-size:11px;color:var(--tx3);' +
301 'font-family:monospace;white-space:nowrap;overflow-x:auto;';
302 
303 /* Body — folder list */
304 var body = D.createElement('div');
305 body.style.cssText = 'flex:1;overflow-y:auto;padding:8px 12px;min-height:120px;max-height:50vh;';
306 
307 /* Footer — back, new folder, select */
308 var ftr = D.createElement('div');
309 ftr.style.cssText = 'padding:12px 20px;border-top:1px solid var(--bd);display:flex;gap:8px;';
310 ftr.innerHTML = '<button id="dl-dest-up" class="btn" style="padding:6px 10px;font-size:11px;">' +
311 '&uarr; Back</button>' +
312 '<button id="dl-dest-mkdir" class="btn" style="padding:6px 10px;font-size:11px;">' +
313 '+ New Folder</button>' +
314 '<div style="flex:1;"></div>' +
315 '<button id="dl-dest-ok" class="btn" style="padding:8px 18px;font-size:12px;font-weight:700;' +
316 'background:var(--ac);color:#fff;border:none;border-radius:8px;cursor:pointer;">' +
317 'Select this folder</button>';
318 
319 card.appendChild(hdr);
320 card.appendChild(bcBar);
321 card.appendChild(body);
322 card.appendChild(ftr);
323 overlay.appendChild(card);
324 D.body.appendChild(overlay);
325 
326 /* Close overlay */
327 function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
328 overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
329 hdr.querySelector('#dl-dest-close').onclick = close;
330 
331 /* Navigate up */
332 ftr.querySelector('#dl-dest-up').onclick = function () {
333 var parent = Z.parent(browsePath);
334 if (parent !== null) { browsePath = parent; loadDir(browsePath); }
335 };
336 
337 /* Create new folder in current directory */
338 ftr.querySelector('#dl-dest-mkdir').onclick = function () {
339 Z.modal.prompt('New Folder', '').then(function (name) {
340 if (!name) return;
341 Z.api.mkdir(browsePath, name).then(function () {
342 Z.toast('Folder created', 'ok');
343 loadDir(browsePath);
344 }).catch(function (e) {
345 Z.toast('Failed: ' + e.message, 'er');
346 });
347 });
348 };
349 
350 /* Confirm selection */
351 ftr.querySelector('#dl-dest-ok').onclick = function () {
352 _destPath = browsePath;
353 var el = $('dl-dest-path');
354 if (el) el.textContent = _destPath;
355 close();
356 };
357 
358 /* Render breadcrumb */
359 function renderBreadcrumb(path) {
360 var parts = path.split('/').filter(function (s) { return s.length > 0; });
361 var html = '<span style="color:var(--ac);cursor:pointer;" data-path="/">/</span>';
362 var cum = '';
363 for (var i = 0; i < parts.length; i++) {
364 cum += '/' + parts[i];
365 html += ' <span style="color:var(--tx3);">›</span> ' +
366 '<span style="color:var(--ac);cursor:pointer;" data-path="' + cum + '">' +
367 parts[i] + '</span>';
368 }
369 bcBar.innerHTML = html;
370 /* Wire breadcrumb clicks */
371 var spans = bcBar.querySelectorAll('span[data-path]');
372 for (var s = 0; s < spans.length; s++) {
373 (function (sp) {
374 sp.onclick = function () {
375 browsePath = sp.getAttribute('data-path');
376 loadDir(browsePath);
377 };
378 })(spans[s]);
379 }
380 }
381 
382 /* Load directory listing */
383 function loadDir(path) {
384 renderBreadcrumb(path);
385 body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--tx3);font-size:12px;">Loading…</div>';
386 Z.api.list(path).then(function (data) {
387 body.innerHTML = '';
388 if (!data || !data.entries || !data.entries.length) {
389 body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--tx3);font-size:12px;">Empty directory</div>';
390 return;
391 }
392 /* Show only directories, sorted alphabetically */
393 var dirs = data.entries.filter(function (e) { return e.type === 'directory'; });
394 dirs.sort(function (a, b) { return a.name.localeCompare(b.name); });
395 
396 if (!dirs.length) {
397 body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--tx3);font-size:12px;">No subdirectories</div>';
398 return;
399 }
400 
401 for (var i = 0; i < dirs.length; i++) {
402 (function (entry) {
403 var row = D.createElement('div');
404 row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:8px 12px;' +
405 'border-radius:8px;cursor:pointer;color:var(--tx2);transition:all .1s;font-size:12px;';
406 row.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
407 'stroke-width="2" style="flex-shrink:0;color:var(--ac);"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 ' +
408 '1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>' +
409 '<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' +
410 entry.name + '</span>' +
411 '<span style="color:var(--tx3);font-size:10px;">›</span>';
412 row.onmouseenter = function () { row.style.background = 'var(--sf2)'; row.style.color = 'var(--tx)'; };
413 row.onmouseleave = function () { row.style.background = 'none'; row.style.color = 'var(--tx2)'; };
414 row.onclick = function () {
415 browsePath = Z.join(path, entry.name);
416 loadDir(browsePath);
417 };
418 body.appendChild(row);
419 })(dirs[i]);
420 }
421 }).catch(function (err) {
422 body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--er);font-size:12px;">' +
423 'Error loading directory: ' + err.message + '</div>';
424 });
425 }
426 
427 loadDir(browsePath);
428 };
429 
430 Z.downloadMgr = dlm;
431 
432})(ZFTPD);
433