feat: upload progress by the file manager spinner, and a list of the uploads behind it
This commit is contained in:
1 parent
10b8995041
commit
3e46116a9b
1 file changed
+112
-23
@@ -19,6 +19,23 @@
|
||||
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
|
||||
border-radius:50%; animation:spin .7s linear infinite; display:none; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
/* Busy indicator; while files upload it also shows how far they got, and a
|
||||
click on it lists them. */
|
||||
.busy { display:inline-flex; align-items:center; gap:6px; background:none; border:0; padding:0 4px;
|
||||
height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; }
|
||||
.busy:hover { background:none; }
|
||||
.busy.up { cursor:pointer; color:var(--text); }
|
||||
.busy.up:hover { color:var(--accent); }
|
||||
#updlg { max-width:480px; }
|
||||
.upl-item { padding:8px 0; border-bottom:1px solid var(--border); }
|
||||
.upl-item:last-child { border-bottom:0; }
|
||||
.upl-row { display:flex; gap:12px; justify-content:space-between; font-size:14px; }
|
||||
.upl-row .n { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.upl-row .st { flex:none; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.upl-row .st.ok { color:var(--ok); }
|
||||
.upl-row .st.bad { color:var(--danger); }
|
||||
.upl-bar { height:4px; margin-top:6px; border-radius:2px; background:#0c0f13; overflow:hidden; }
|
||||
.upl-bar i { display:block; height:100%; background:var(--accent); transform-origin:left; transition:transform .2s; }
|
||||
#drop { border:1px dashed var(--border); border-radius:10px; }
|
||||
#drop.drag { border-color:var(--accent); background:#12202f; }
|
||||
table.fm td, table.fm th { padding:7px 8px; }
|
||||
@@ -29,7 +46,7 @@
|
||||
.act { display:flex; gap:6px; justify-content:flex-end; }
|
||||
table.fm .c-sz { width:120px; }
|
||||
table.fm .c-mt { width:160px; }
|
||||
table.fm .c-act { width:200px; }
|
||||
table.fm .c-act { width:160px; }
|
||||
.fm .nm { overflow-wrap:anywhere; }
|
||||
/* Phones: no date column, and the row buttons only as wide as they are. */
|
||||
@media (max-width: 640px) {
|
||||
@@ -71,7 +88,7 @@
|
||||
<div class="fm-bar">
|
||||
<button class="btn ghost small" id="up">↑ Вверх</button>
|
||||
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
|
||||
<span class="spinner" id="spin"></span>
|
||||
<button type="button" class="busy" id="busy" title=""><span class="spinner" id="spin"></span><span id="uppct"></span></button>
|
||||
<button class="btn ghost small" id="refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="fm-bar">
|
||||
@@ -109,6 +126,14 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="updlg">
|
||||
<div class="dlg-h">
|
||||
<strong style="flex:1">Загрузка файлов</strong>
|
||||
<button class="btn ghost small" id="updlg-close">Закрыть</button>
|
||||
</div>
|
||||
<div class="dlg-b" id="updlg-list"></div>
|
||||
</dialog>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
@@ -128,7 +153,6 @@
|
||||
// Monochrome inline icons (inherit button color via currentColor).
|
||||
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
|
||||
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_REN = SVG + '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>';
|
||||
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
|
||||
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||||
let cwd = '/';
|
||||
@@ -176,15 +200,13 @@
|
||||
'<td><div class="act">' +
|
||||
(isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') +
|
||||
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
||||
'<button class="btn ghost small icon" data-act="name" title="Переименовать">'+ICON_REN+'</button>' +
|
||||
'<button class="btn ghost small icon" data-act="ren" title="Переместить">'+ICON_MV+'</button>' +
|
||||
'<button class="btn ghost small icon" data-act="ren" title="Переместить / переименовать">'+ICON_MV+'</button>' +
|
||||
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
|
||||
'</div></td>';
|
||||
const nm = tr.querySelector('.nm');
|
||||
nm.onclick = () => e.is_dir ? load(join(cwd, e.name)) : view(e);
|
||||
tr.querySelector('.ck').onchange = ev => { const n=e.name; ev.target.checked?selected.add(n):selected.delete(n); updateSel(); };
|
||||
tr.querySelector('[data-act="del"]').onclick = () => del(e);
|
||||
tr.querySelector('[data-act="name"]').onclick = () => rename(e);
|
||||
tr.querySelector('[data-act="ren"]').onclick = () => move(e);
|
||||
const uz = tr.querySelector('[data-act="unzip"]'); if (uz) uz.onclick = () => unzip(e);
|
||||
tb.appendChild(tr);
|
||||
@@ -242,15 +264,6 @@
|
||||
r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
}
|
||||
|
||||
// Rename in place: only the name is asked for, the folder stays.
|
||||
async function rename(e) {
|
||||
const n = prompt('Новое имя:', e.name);
|
||||
if (!n || n === e.name) return;
|
||||
if (n.includes('/')) { toast('имя без "/" - чтобы перенести в другую папку, есть кнопка «Переместить»', true); return; }
|
||||
const r = await api('move', {src: join(cwd, e.name), dst: join(cwd, n)});
|
||||
r.ok ? (toast('переименовано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
}
|
||||
|
||||
async function unzip(e) {
|
||||
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
|
||||
r.ok ? (toast('распаковано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
@@ -292,17 +305,93 @@
|
||||
if (e.key === 'Enter') { e.preventDefault(); navTo($('path').value); }
|
||||
});
|
||||
|
||||
// uploads
|
||||
// uploads: one file at a time, each with its progress. What has gone up
|
||||
// shows as a percentage by the spinner; a click on it lists the files.
|
||||
const uploads = [];
|
||||
let upActive = false, upDoneTimer = null;
|
||||
const UP_ST = { wait: 'в очереди', send: '', write: 'запись на клиенте…', done: 'готово', fail: 'ошибка' };
|
||||
function renderUploads() {
|
||||
const pend = uploads.filter(u => u.state === 'wait' || u.state === 'send' || u.state === 'write');
|
||||
const counted = uploads.filter(u => !u.skip);
|
||||
const total = counted.reduce((n, u) => n + u.size, 0) || 1;
|
||||
const sent = counted.reduce((n, u) => n + (u.state === 'wait' ? 0 : u.state === 'send' ? u.size * u.frac : u.size), 0);
|
||||
const busy = $('busy');
|
||||
busy.classList.toggle('up', uploads.length > 0);
|
||||
busy.title = uploads.length ? 'Показать загружаемые файлы' : '';
|
||||
$('uppct').textContent = pend.length ? Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '') : '';
|
||||
const list = $('updlg-list');
|
||||
if (!$('updlg').open) return;
|
||||
list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>';
|
||||
for (const u of uploads) {
|
||||
const pct = u.state === 'send' ? Math.floor(100 * u.frac) : u.state === 'wait' ? 0 : 100;
|
||||
const st = u.state === 'send' ? pct + '%' : u.state === 'fail' ? (u.error || UP_ST.fail) : UP_ST[u.state];
|
||||
const el = document.createElement('div');
|
||||
el.className = 'upl-item';
|
||||
el.innerHTML = '<div class="upl-row"><span class="n"></span><span class="st"></span></div><div class="upl-bar"><i></i></div>';
|
||||
el.querySelector('.n').textContent = u.name + ' · ' + fmtSize(u.size);
|
||||
const stEl = el.querySelector('.st');
|
||||
stEl.textContent = st;
|
||||
stEl.className = 'st' + (u.state === 'done' ? ' ok' : u.state === 'fail' ? ' bad' : '');
|
||||
el.querySelector('.upl-bar i').style.transform = 'scaleX(' + pct / 100 + ')';
|
||||
if (u.state === 'fail') el.querySelector('.upl-bar i').style.background = 'var(--danger)';
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
$('busy').onclick = () => { if (!uploads.length) return; $('updlg').showModal(); renderUploads(); };
|
||||
$('updlg-close').onclick = () => $('updlg').close();
|
||||
|
||||
// XHR rather than fetch: it reports how much of the request has gone out.
|
||||
function writeWithProgress(path, bytes, onFrac) {
|
||||
return new Promise(resolve => {
|
||||
const x = new XMLHttpRequest();
|
||||
x.open('POST', '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/write');
|
||||
x.setRequestHeader('Content-Type', 'application/json');
|
||||
x.upload.onprogress = ev => { if (ev.lengthComputable) onFrac(ev.loaded / ev.total); };
|
||||
x.upload.onload = () => onFrac(1);
|
||||
x.onload = () => { try { resolve(JSON.parse(x.responseText)); } catch (e) { resolve({ok: false, error: 'HTTP ' + x.status}); } };
|
||||
x.onerror = () => resolve({ok: false, error: 'ошибка сети'});
|
||||
x.send(JSON.stringify({path, data: bytesToB64(bytes)}));
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadFiles(files) {
|
||||
for (const f of files) {
|
||||
if (f.size > 32*1024*1024) { toast('файл '+f.name+' больше 32 МБ', true); continue; }
|
||||
const buf = new Uint8Array(await f.arrayBuffer());
|
||||
const r = await api('write', {path: join(cwd, f.name), data: bytesToB64(buf)});
|
||||
if (!r.ok) { toast(r.error||('ошибка '+f.name), true); return; }
|
||||
clearTimeout(upDoneTimer);
|
||||
// A new batch starts a fresh list once the last one is over.
|
||||
if (!upActive) uploads.length = 0;
|
||||
const dir = cwd;
|
||||
const batch = [...files].map(f => f.size > 32*1024*1024
|
||||
? {file: f, name: f.name, size: f.size, dir, state: 'fail', frac: 0, error: 'больше 32 МБ', skip: true}
|
||||
: {file: f, name: f.name, size: f.size, dir, state: 'wait', frac: 0, error: ''});
|
||||
uploads.push(...batch);
|
||||
renderUploads();
|
||||
if (upActive) return;
|
||||
upActive = true;
|
||||
setBusy(true);
|
||||
let failed = 0;
|
||||
// Files refused up front count as failed too.
|
||||
const refused = () => uploads.filter(u => u.skip).length;
|
||||
for (let u; (u = uploads.find(x => x.state === 'wait')); ) {
|
||||
u.state = 'send'; renderUploads();
|
||||
const buf = new Uint8Array(await u.file.arrayBuffer());
|
||||
const r = await writeWithProgress(join(u.dir, u.name), buf, frac => {
|
||||
u.frac = frac;
|
||||
if (frac >= 1 && u.state === 'send') u.state = 'write';
|
||||
renderUploads();
|
||||
});
|
||||
if (r.ok) u.state = 'done';
|
||||
else { u.state = 'fail'; u.error = r.error || 'ошибка'; failed++; }
|
||||
renderUploads();
|
||||
if (u.dir === cwd) load(cwd);
|
||||
}
|
||||
toast('загружено'); load(cwd);
|
||||
setBusy(false);
|
||||
upActive = false;
|
||||
failed += refused();
|
||||
toast(failed ? 'не загружено: ' + failed : 'загружено', !!failed);
|
||||
renderUploads();
|
||||
// The list stays a while for a look, then the indicator goes quiet.
|
||||
upDoneTimer = setTimeout(() => { if (!upActive) { uploads.length = 0; renderUploads(); } }, failed ? 30000 : 8000);
|
||||
}
|
||||
$('upl').onchange = ev => uploadFiles(ev.target.files);
|
||||
$('upl').onchange = ev => { uploadFiles(ev.target.files); ev.target.value = ''; };
|
||||
const drop = $('drop');
|
||||
['dragenter','dragover'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); drop.classList.add('drag'); }));
|
||||
['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); }));
|
||||
|
||||
Reference in new issue
Block a user