413 lines
22 KiB
HTML
413 lines
22 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Файлы · {{.Agent.Name}} - LPRA</title>
|
||
<link rel="stylesheet" href="/static/app.css">
|
||
<style>
|
||
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
|
||
/* Normalize buttons and the file <label> button so they line up on one row. */
|
||
.fm-bar .btn, .fm-bar button, .fm-bar label.btn {
|
||
display:inline-flex; align-items:center; justify-content:center;
|
||
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
|
||
}
|
||
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
|
||
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
|
||
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
|
||
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
|
||
.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; }
|
||
.fm .nm { cursor:pointer; }
|
||
.fm .nm:hover { color:var(--accent); }
|
||
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
|
||
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
||
.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:160px; }
|
||
.fm .nm { overflow-wrap:anywhere; }
|
||
/* Phones: no date column, and the row buttons only as wide as they are. */
|
||
@media (max-width: 640px) {
|
||
table.fm .c-mt, table.fm td.mt { display:none; }
|
||
table.fm .c-sz { width:auto; white-space:nowrap; }
|
||
table.fm .c-act { width:1%; }
|
||
table.fm td, table.fm th { padding:7px 4px; }
|
||
.ic { margin-right:4px; }
|
||
.fm-bar input.fm-path { flex:1 1 100%; order:-1; }
|
||
textarea#edit { font-size:13px; }
|
||
}
|
||
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
||
border-radius:12px; padding:0; max-width:900px; width:92vw; }
|
||
dialog::backdrop { background:rgba(0,0,0,.6); }
|
||
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
|
||
.dlg-b { padding:16px 18px; }
|
||
textarea#edit { width:100%; height:52vh; background:#0c0f13; color:var(--text);
|
||
border:1px solid var(--border); border-radius:8px; padding:12px;
|
||
font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||
.toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
|
||
background:#2a2f3a; border:1px solid var(--border); border-radius:8px;
|
||
padding:10px 16px; font-size:14px; opacity:0; transition:opacity .2s; pointer-events:none; }
|
||
.toast.show { opacity:1; }
|
||
.toast.err { background:#3a1d1d; border-color:#6b2b2b; color:#ffb3b3; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="topbar">
|
||
<div class="brand">LPRA · Файлы - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||
<nav>
|
||
<a href="/">← Машины</a>
|
||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||
</nav>
|
||
</header>
|
||
<div class="wrap" style="max-width:1100px">
|
||
<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">
|
||
<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">
|
||
<button class="btn small" id="mkdir">+ Папка</button>
|
||
<button class="btn small" id="newfile">+ Файл</button>
|
||
<label class="btn small" style="cursor:pointer">+ Загрузить<input type="file" id="upl" multiple hidden></label>
|
||
<span class="spacer" style="flex:1"></span>
|
||
<span id="selinfo" class="muted" style="font-size:13px"></span>
|
||
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
|
||
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
|
||
</div>
|
||
|
||
<div class="card" id="drop" style="padding:0">
|
||
<table class="fm">
|
||
<thead><tr>
|
||
<th style="width:28px"><input type="checkbox" id="all"></th>
|
||
<th>Имя</th><th class="sz c-sz">Размер</th>
|
||
<th class="sz c-mt">Изменён</th><th class="c-act"></th>
|
||
</tr></thead>
|
||
<tbody id="list"></tbody>
|
||
</table>
|
||
</div>
|
||
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
|
||
</div>
|
||
|
||
<dialog id="viewer">
|
||
<div class="dlg-h">
|
||
<strong id="vname" style="flex:1; overflow:hidden; text-overflow:ellipsis"></strong>
|
||
<button class="btn small" id="save" style="display:none">Сохранить</button>
|
||
<button class="btn ghost small" id="closev">Закрыть</button>
|
||
</div>
|
||
<div class="dlg-b">
|
||
<div id="vmsg" class="muted" style="margin-bottom:8px; font-size:13px"></div>
|
||
<textarea id="edit" spellcheck="false"></textarea>
|
||
</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>
|
||
(function () {
|
||
const agentID = {{.Agent.ID}};
|
||
let busyN = 0;
|
||
function setBusy(on) { busyN += on ? 1 : -1; if (busyN < 0) busyN = 0; document.getElementById('spin').style.display = busyN > 0 ? 'inline-block' : 'none'; }
|
||
const api = (op, body) => {
|
||
setBusy(true);
|
||
return fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + op, {
|
||
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body || {})
|
||
}).then(r => r.json()).catch(e => ({ok:false, error:'ошибка сети'})).finally(() => setBusy(false));
|
||
};
|
||
|
||
const EDIT_LIMIT = {{.EditLimit}};
|
||
|
||
// 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_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 = '/';
|
||
let entries = [];
|
||
const selected = new Set();
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const toast = (msg, err) => { const t=$('toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); setTimeout(()=>t.className='toast',2200); };
|
||
const b64ToBytes = b64 => { const bin=atob(b64); const u=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i); return u; };
|
||
const bytesToB64 = bytes => { let s=''; const CH=0x8000; for(let i=0;i<bytes.length;i+=CH) s+=String.fromCharCode.apply(null, bytes.subarray(i,i+CH)); return btoa(s); };
|
||
const join = (dir, name) => (dir.endsWith('/') ? dir : dir+'/') + name;
|
||
const parent = p => { if(p==='/'||p==='') return '/'; const i=p.replace(/\/+$/,'').lastIndexOf('/'); return i<=0 ? '/' : p.slice(0,i); };
|
||
const fmtSize = n => { if(n<1024)return n+' B'; if(n<1048576)return (n/1024).toFixed(1)+' K'; if(n<1073741824)return (n/1048576).toFixed(1)+' M'; return (n/1073741824).toFixed(1)+' G'; };
|
||
const fmtTime = t => t ? new Date(t*1000).toLocaleString() : '';
|
||
|
||
async function load(path) {
|
||
const r = await api('list', {path});
|
||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
|
||
$('path').value = cwd;
|
||
render();
|
||
}
|
||
|
||
// Editable path: Enter navigates, or reports a missing directory.
|
||
async function navTo(p) {
|
||
p = (p || '').trim() || '/';
|
||
const r = await api('list', {path: p});
|
||
if (!r.ok) { toast(r.error || 'нет такого каталога', true); $('path').value = cwd; return; }
|
||
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
|
||
$('path').value = cwd; render();
|
||
}
|
||
|
||
function render() {
|
||
const tb = $('list'); tb.innerHTML = '';
|
||
updateSel();
|
||
for (const e of entries) {
|
||
const tr = document.createElement('tr');
|
||
const isZip = !e.is_dir && /\.zip$/i.test(e.name);
|
||
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
|
||
tr.innerHTML =
|
||
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
|
||
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
|
||
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
||
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
|
||
'<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="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="ren"]').onclick = () => move(e);
|
||
const uz = tr.querySelector('[data-act="unzip"]'); if (uz) uz.onclick = () => unzip(e);
|
||
tb.appendChild(tr);
|
||
}
|
||
}
|
||
const esc = s => s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||
|
||
function updateSel() {
|
||
const n = selected.size;
|
||
$('selinfo').textContent = n ? ('выбрано: '+n) : '';
|
||
$('zipsel').disabled = !n; $('delsel').disabled = !n;
|
||
}
|
||
|
||
async function view(e) {
|
||
const r = await api('read', {path: join(cwd, e.name)});
|
||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||
const d = r.data;
|
||
$('vname').textContent = e.name;
|
||
const ta = $('edit'); const save = $('save'); const msg = $('vmsg');
|
||
if (d.binary) {
|
||
ta.value = '(бинарный файл, просмотр недоступен)'; ta.readOnly = true; save.style.display='none';
|
||
msg.textContent = 'Размер: '+fmtSize(d.size);
|
||
} else {
|
||
ta.value = new TextDecoder().decode(b64ToBytes(d.content));
|
||
const editable = d.size <= EDIT_LIMIT && !d.truncated;
|
||
ta.readOnly = !editable;
|
||
save.style.display = editable ? '' : 'none';
|
||
msg.textContent = 'Размер: '+fmtSize(d.size) + (d.truncated?' · показано первые '+fmtSize(EDIT_LIMIT):'') + (editable?' · редактируемо':' · только чтение (больше '+fmtSize(EDIT_LIMIT)+')');
|
||
save.onclick = async () => {
|
||
const bytes = new TextEncoder().encode(ta.value);
|
||
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
|
||
if (w.ok) { toast('сохранено'); $('viewer').close(); load(cwd); } else toast(w.error||'ошибка', true);
|
||
};
|
||
}
|
||
$('viewer').showModal();
|
||
}
|
||
$('closev').onclick = () => $('viewer').close();
|
||
|
||
async function del(e) {
|
||
if (e.is_dir) {
|
||
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
|
||
const r = await api('deltree', {path: join(cwd, e.name)});
|
||
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
|
||
} else {
|
||
if (!confirm('Удалить файл "'+e.name+'"?')) return;
|
||
const r = await api('delete', {path: join(cwd, e.name)});
|
||
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
|
||
}
|
||
}
|
||
|
||
async function move(e) {
|
||
const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name));
|
||
if (!dst) return;
|
||
const r = await api('move', {src: join(cwd, e.name), dst});
|
||
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);
|
||
}
|
||
|
||
$('mkdir').onclick = async () => {
|
||
const n = prompt('Имя новой папки:'); if (!n) return;
|
||
const r = await api('mkdir', {path: join(cwd, n)});
|
||
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||
};
|
||
$('newfile').onclick = async () => {
|
||
const n = prompt('Имя нового файла:'); if (!n) return;
|
||
const r = await api('write', {path: join(cwd, n), data: ''});
|
||
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||
};
|
||
$('delsel').onclick = async () => {
|
||
if (!confirm('Удалить выбранное ('+selected.size+')?')) return;
|
||
for (const n of selected) {
|
||
const e = entries.find(x => x.name === n);
|
||
await api(e && e.is_dir ? 'deltree' : 'delete', {path: join(cwd, n)});
|
||
}
|
||
toast('удалено'); load(cwd);
|
||
};
|
||
$('zipsel').onclick = async () => {
|
||
const name = prompt('Имя архива:', 'archive.zip'); if (!name) return;
|
||
const paths = [...selected].map(n => join(cwd, n));
|
||
const r = await api('zip', {paths, dest: join(cwd, name)});
|
||
r.ok ? (toast('архив создан'), load(cwd)) : toast(r.error||'ошибка', true);
|
||
};
|
||
$('all').onchange = ev => {
|
||
selected.clear();
|
||
if (ev.target.checked) entries.forEach(e => selected.add(e.name));
|
||
document.querySelectorAll('.ck').forEach(c => c.checked = ev.target.checked);
|
||
updateSel();
|
||
};
|
||
$('up').onclick = () => load(parent(cwd));
|
||
$('refresh').onclick = () => load(cwd);
|
||
$('path').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); navTo($('path').value); }
|
||
});
|
||
|
||
// 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) {
|
||
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);
|
||
}
|
||
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); 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'); }));
|
||
drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
|
||
|
||
// Start in the home directory of the machine's user; an agent too old to
|
||
// know "~" fails that listing, so fall back to the root.
|
||
(async () => {
|
||
const r = await api('list', {path: '~' + {{.Agent.LoginUser}}});
|
||
if (!r.ok) { load('/'); return; }
|
||
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
|
||
$('path').value = cwd;
|
||
render();
|
||
})();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|