Files
hub/public/files.js

424 lines
33 KiB
JavaScript

'use strict';
// ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret)
async function viewEdit([repo, ...rawParts], opts = {}) {
const parts = rawParts.filter(Boolean);
const path = '/' + parts.join('/'), name = parts[parts.length - 1] || '';
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
const v = $('#view');
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b></div>
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
if (!sf.token()) return (location.hash = '#/files');
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
try {
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) });
const conf = await r.json();
if (!r.ok) return fail(conf.error || 'Could not open document');
if (!window.DocsAPI) await new Promise((ok, no) => {
const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js';
s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s);
});
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
$('#oomsg').remove();
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
editor = new DocsAPI.DocEditor('oo', conf);
} catch (e) { fail(e.message); }
}
// ---- Files
function viewFiles([repo, ...parts]) {
if (!sf.token()) return filesLogin();
const dir = '/' + parts.join('/');
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
<div class="libs" id="libs"><p class="empty">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
libsP = loadLibs(repo);
if (repo) loadDir(repo, dir);
}
function filesLogin(msg = '', needOtp = false) {
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><p class="sub">Sign in to ${esc(new URL(cfg.seafile).host)}</p>
<form class="card" id="lf"><label>Email / username</label><input type="text" name="u" autocomplete="username" autocapitalize="off" required>
<label>Password</label><input type="password" name="p" autocomplete="current-password" required>
${needOtp ? '<label>2FA code</label><input type="text" name="o" inputmode="numeric" autocomplete="one-time-code">' : ''}
<div class="bar"><button class="btn" type="submit">Sign in</button></div><div class="err">${esc(msg)}</div></form></div>`;
$('#lf').onsubmit = async e => {
e.preventDefault();
const f = new FormData(e.target);
try { await sf.login(f.get('u'), f.get('p'), f.get('o')); route(); }
catch (err) { filesLogin(err.message, !!err.otp || needOtp); }
};
}
async function loadLibs(active) {
try {
const repos = (await sf.req('/api2/repos/')).sort((a, b) => a.name.localeCompare(b.name));
libNames = Object.fromEntries(repos.map(r => [r.id, r.name])); libPerm = Object.fromEntries(repos.map(r => [r.id, r.permission]));
$('#libs').innerHTML = `<div class="bar" style="margin-top:0"><button class="btn ghost" id="nl">${icon('newdir')}New library</button></div><h2 style="margin-top:8px">Libraries</h2><div class="list">${repos.map(r => `
<a class="row" href="#/files/${encodeURIComponent(r.id)}" ${r.id === active ? 'style="background:var(--panel2)"' : ''}>
<span class="ic" style="background:#2f9e6e">${icon('files')}</span>
<span class="grow"><b>${esc(r.name)}</b><small>${r.encrypted ? 'Encrypted · ' : ''}${esc(r.type === 'repo' ? 'Mine' : r.owner_name || r.owner || 'Shared')}</small></span>${icon('chev', 'chev')}</a>`).join('') || '<p class="empty">No libraries</p>'}</div>`;
$('#nl').onclick = () => textSheet('New library', 'Library name', 'Create', async name => {
try { const r = await sf.req('/api2/repos/', { method: 'POST', body: new URLSearchParams({ name }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); location.hash = '#/files/' + encodeURIComponent(r.repo_id); }
catch (e) { toast(e.message); }
});
if (!active) return;
const r = repos.find(x => x.id === active);
if (r && r.encrypted) $('#fmain').innerHTML = '<p class="empty">This library is encrypted. Unlock it in the Seafile web UI first.</p>';
} catch (e) { $('#libs').innerHTML = `<p class="err">${esc(e.message)}</p>`; if (!sf.token()) setTimeout(route, 800); }
}
// ---- Files: browse, select, move/copy/delete, rename, upload (drag in from the computer, drag a file out to download)
let fv = null, libsP = Promise.resolve(), libNames = {}, libPerm = {};
const SORTS = [['name', 'Name'], ['mtime', 'Date modified'], ['size', 'Size'], ['type', 'Kind']];
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); // (one collator is much faster than localeCompare in a big folder)
const nameCmp = (a, b) => collator.compare(a.name, b.name);
function sortItems(items, s) {
const by = { name: nameCmp, mtime: (a, b) => a.mtime - b.mtime, size: (a, b) => (a.size || 0) - (b.size || 0), type: (a, b) => ext(a.name).localeCompare(ext(b.name)) }[s.k] || nameCmp;
return [...items].sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) // folders stay on top
: (a.type === 'dir' && s.k !== 'name' && s.k !== 'mtime') ? nameCmp(a, b) : s.d * by(a, b) || nameCmp(a, b));
}
const badName = (n, items, self) => !n || n === '.' || n === '..' ? 'Enter a name' : /[\/\\]/.test(n) ? 'A name cannot contain / or \\' : n.length > 255 ? 'That name is too long'
: items.some(x => x.name.toLowerCase() === n.toLowerCase() && x.name !== self) ? 'Something with that name is already here' : '';
const sfJson = (path, body) => sf.req(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const hereItems = (repo, dir) => (fv && fv.repo === repo && fv.dir === dir ? fv.items : []);
// After something changed in a folder, redraw it if it is the one on screen.
function afterChange(repo, dir) { if (fv && document.contains(fv.root) && fv.repo === repo && fv.dir === dir) { fv.sel.clear(); fv.selecting = false; fv.reload(); } }
async function removeItems(repo, dir, items) {
const enc = encodeURIComponent;
try { await sfJson('/api/v2.1/repos/batch-delete-item/', { repo_id: repo, parent_dir: dir, dirents: items.map(x => x.name) }); }
catch (e) { // older servers have no batch call: one at a time
const failed = [];
for (const it of items) { try { await sf.req(`/api2/repos/${enc(repo)}/${it.type === 'dir' ? 'dir' : 'file'}/?p=${enc(joinP(dir, it.name))}`, { method: 'DELETE' }); } catch { failed.push(it.name); } }
if (failed.length) throw new Error('Could not delete: ' + failed.join(', '));
}
}
function deleteItems(repo, dir, items) {
const one = items.length === 1;
confirmSheet(one ? (items[0].type === 'dir' ? 'Delete folder?' : 'Delete file?') : `Delete ${items.length} items?`,
one ? items[0].name + (items[0].type === 'dir' ? ' and everything in it' : '') : 'Folders are deleted with everything inside them', 'Delete', async () => {
try { await removeItems(repo, dir, items); afterChange(repo, dir); if (!(await offerUndo(repo, dir, items, () => afterChange(repo, dir)))) toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); afterChange(repo, dir); }
});
}
async function transferItems(mode, repo, dir, names, dst) {
await sfJson(`/api/v2.1/repos/sync-batch-${mode}-item/`, { src_repo_id: repo, src_parent_dir: dir, dst_repo_id: dst.repo, dst_parent_dir: dst.dir, src_dirents: names });
}
function moveSheet(mode, repo, dir, items) {
const names = items.map(x => x.name), folders = items.filter(x => x.type === 'dir').map(x => joinP(dir, x.name)), verb = mode === 'move' ? 'Move' : 'Copy';
chooseFolder(verb + (items.length === 1 ? ' “' + names[0] + '”' : ' ' + items.length + ' items') + ' to…', { repo, repoName: libNames[repo] || '', dir },
{ okLabel: verb + ' here', hide: (r, d) => (r === repo && d === dir ? names : []), newFolder: true,
blocked: (r, d) => r === repo && ((mode === 'move' && d === dir) || folders.some(f => d === f || d.startsWith(f + '/'))) }).then(async dst => {
if (!dst) return;
try { await transferItems(mode, repo, dir, names, dst); toast((mode === 'move' ? 'Moved' : 'Copied') + (items.length > 1 ? ' ' + items.length + ' items' : '')); }
catch (e) { toast(e.message); }
afterChange(repo, dir);
});
}
function renameItem(repo, path, name, dir, isDir) {
textSheet('Rename', 'New name', 'Rename', async n => {
if (n === name) return;
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${isDir ? 'dir' : 'file'}/?p=${encodeURIComponent(path)}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'rename', newname: n }) }); toast('Renamed'); }
catch (e) { toast(e.message); }
afterChange(repo, dir);
}, name, n => badName(n, hereItems(repo, dir), name));
}
async function downloadItem(repo, path) {
try { window.open(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), '_blank', 'noopener'); } catch (e) { toast(e.message); }
}
// ---- Uploads: a queue that survives leaving the folder, with progress, retries and a way to cancel
const uq = { items: [], running: 0 };
const upActive = () => uq.items.some(x => x.state === 'wait' || x.state === 'up');
async function enqueueUploads(repo, dir, entries) {
if (!entries.length) return;
entries = await resolveUploadConflicts(repo, dir, entries); // a name that is already there: replace, keep both, or skip
if (!entries.length) return;
for (const e of entries) uq.items.push({ repo, dir, file: e.file, rel: e.rel || '', replace: !!e.replace, pct: 0, state: 'wait', tries: 0 });
drawUpBar(); pumpUploads();
}
function pumpUploads() {
while (uq.running < 2) {
const it = uq.items.find(x => x.state === 'wait'); if (!it) break;
uq.running++; it.state = 'up';
sendUpload(it).then(() => { it.state = 'done'; }, err => { it.state = it.cancelled ? 'cancel' : 'fail'; it.err = err.message; })
.finally(() => { uq.running--; drawUpBar(); pumpUploads(); if (!upActive()) uploadsFinished(); });
}
}
async function sendUpload(it) {
const enc = encodeURIComponent;
for (;;) {
it.tries++; it.pct = 0;
try {
const link = await sf.req(`/api2/repos/${enc(it.repo)}/upload-link/?p=${enc(it.dir)}`); // a fresh link each time, so an expired one never blocks a retry
await new Promise((ok, no) => {
const x = it.xhr = new XMLHttpRequest(), fd = new FormData();
fd.append('parent_dir', it.dir); fd.append('replace', it.replace ? '1' : '0'); if (it.rel) fd.append('relative_path', it.rel); fd.append('file', it.file, it.file.name);
x.open('POST', '/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1');
x.upload.onprogress = e => { if (e.lengthComputable) { it.pct = e.loaded / e.total; drawUpBar(); } };
x.onload = () => x.status >= 200 && x.status < 300 ? ok() : no(Object.assign(new Error(x.status === 413 ? 'Too large for the server' : x.status === 403 ? 'Not allowed (or out of space)' : 'Upload failed (' + x.status + ')'), { fatal: x.status >= 400 && x.status < 500 }));
x.onerror = () => no(new Error('Connection lost'));
x.onabort = () => no(Object.assign(new Error('Cancelled'), { fatal: true }));
x.send(fd);
});
return;
} catch (e) {
if (it.cancelled) throw e;
if (e.fatal || !sf.token()) throw new Error(it.file.name + ': ' + e.message);
if (!navigator.onLine) { it.waiting = true; drawUpBar(); await waitOnline(); it.waiting = false; it.tries = Math.max(0, it.tries - 1); } // no connection: wait for it instead of failing
else if (it.tries >= 5) throw new Error(it.file.name + ': ' + e.message);
else await sleep(Math.min(15000, 1500 * 2 ** (it.tries - 1)));
}
}
}
function uploadsFinished() {
const done = uq.items.filter(x => x.state === 'done').length, bad = uq.items.filter(x => x.state === 'fail').length;
if (done) toast(done + (done === 1 ? ' file' : ' files') + ' uploaded' + (bad ? ', ' + bad + ' failed' : ''));
uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar();
if (fv && document.contains(fv.root)) fv.reload(true);
}
function drawUpBar() {
let b = $('#upbar');
const live = uq.items.filter(x => x.state === 'wait' || x.state === 'up'), bad = uq.items.filter(x => x.state === 'fail');
if (!live.length && !bad.length) { if (b) b.remove(); return; }
if (!b) { b = document.createElement('div'); b.id = 'upbar'; document.body.appendChild(b); }
if (live.length) {
const total = uq.items.length, doneN = uq.items.filter(x => x.state === 'done').length, cur = live.find(x => x.state === 'up') || live[0];
const pct = Math.round(((doneN + live.reduce((a, x) => a + (x.state === 'up' ? x.pct : 0), 0)) / total) * 100);
b.innerHTML = `<div class="upm"><b>${live.some(x => x.waiting) ? 'Waiting for a connection…' : 'Uploading ' + esc(live.length === 1 ? cur.file.name : live.length + ' files')}</b><small>${pct}%${cur.tries > 1 && !cur.waiting ? ' · retrying' : ''}</small><i style="width:${pct}%"></i></div><button class="btn ghost" id="upx">Cancel</button>`;
$('#upx').onclick = () => { uq.items.forEach(x => { if (x.state === 'wait') x.state = 'cancel'; if (x.state === 'up') { x.cancelled = true; x.xhr && x.xhr.abort(); } }); uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar(); };
} else {
b.innerHTML = `<div class="upm"><b>${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed</b><small>${esc(bad[0].err || '')}</small></div><button class="btn" id="upr">Retry</button><button class="btn ghost" id="upd">Dismiss</button>`;
$('#upr').onclick = () => { bad.forEach(x => { x.state = 'wait'; x.tries = 0; x.cancelled = false; }); drawUpBar(); pumpUploads(); };
$('#upd').onclick = () => { uq.items = uq.items.filter(x => x.state !== 'fail'); drawUpBar(); };
}
}
addEventListener('beforeunload', e => { if (upActive()) { e.preventDefault(); e.returnValue = ''; } });
// Everything dropped from the computer, including whole folders (their structure is kept).
async function droppedFiles(dt) {
const ents = [...(dt.items || [])].map(i => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null)).filter(Boolean), out = [];
if (!ents.length) return [...dt.files].map(file => ({ file, rel: '' }));
const walk = async (en, rel) => {
if (en.isFile) await new Promise(ok => en.file(f => { out.push({ file: f, rel }); ok(); }, ok));
else if (en.isDirectory) {
const here = rel ? rel + '/' + en.name : en.name, rd = en.createReader(); let batch;
do { batch = await new Promise(ok => rd.readEntries(ok, () => ok([]))); for (const c of batch) await walk(c, here); } while (batch.length);
}
};
for (const en of ents) await walk(en, '');
return out;
}
const dlUrls = new Map(); // direct download addresses, fetched ahead so a file can be dragged out to the desktop
async function prefetchDl(repo, path) {
const k = repo + path, c = dlUrls.get(k); if (c && Date.now() - c.t < 45 * 60e3) return;
dlUrls.set(k, { t: Date.now() });
try { const u = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`); dlUrls.set(k, { t: Date.now(), url: new URL(u, cfg.seafile).href }); } catch { dlUrls.delete(k); }
}
async function loadDir(repo, dir) {
const main = $('#fmain'); if (!main) return;
main.innerHTML = '<p class="empty">Loading…</p>';
await libsP.catch(() => {});
if (!document.contains(main)) return;
const enc = encodeURIComponent, ro = libPerm[repo] === 'r';
const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join('');
const segs = dir.split('/').filter(Boolean);
const crumbs = `<button data-go="/">${esc(libNames[repo] || 'Library')}</button>` + segs.map((s, i) => `<span>/</span><button data-go="/${esc(segs.slice(0, i + 1).join('/'))}">${esc(s)}</button>`).join('');
const v = fv = { root: main, repo, dir, items: [], sel: new Set(), selecting: false, last: null, filter: '', tok: null, limit: 300, stale: false };
const sortCfg = () => store.get('files_sort', { k: 'name', d: 1 });
const byName = n => v.items.find(x => x.name === n);
const selItems = () => v.items.filter(x => v.sel.has(x.name));
const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); };
main.innerHTML = `<div id="ftop"></div><div class="crumbs">${crumbs}</div>
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
const go = p => { location.hash = hash(p); };
const drawTop = () => {
const n = v.sel.size, one = n === 1 ? selItems()[0] : null;
$('#ftop').innerHTML = v.selecting
? `<div class="selbar"><div class="bar"><b class="selcount">${n ? n + ' selected' : 'Select items'}</b>
<button class="btn ghost" data-a="all">${n && n === visible().length ? 'None' : 'All'}</button><button class="btn" data-a="done">Done</button></div>
<div class="bar">${ro ? '' : `<button class="btn ghost" data-a="move" ${n ? '' : 'disabled'}>${icon('files')}Move</button>`}
<button class="btn ghost" data-a="copy" ${n ? '' : 'disabled'}>${icon('copy')}Copy</button>
${ro ? '' : `<button class="btn ghost" data-a="del" ${n ? '' : 'disabled'} style="color:var(--danger)">${icon('trash')}Delete</button>`}
<button class="btn ghost" data-a="zip" ${n ? '' : 'disabled'}>${icon('dl')}Zip</button><button class="btn ghost" data-a="more" aria-label="More" ${one ? '' : 'disabled'}>${icon('more')}</button></div></div>`
: `<div class="bar"><a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a>
${ro ? '' : `<button class="btn ghost" data-a="up">${icon('up')}Upload</button><button class="btn ghost" data-a="new">${icon('newdir')}New…</button>`}
<button class="btn ghost" data-a="sel">${icon('check')}Select</button>
<button class="btn ghost" data-a="sort" aria-label="Sort">${icon('sort')}</button>
<button class="btn ghost" data-a="refresh" aria-label="Refresh">${icon('refresh')}</button><button class="btn ghost" data-a="lib" aria-label="More for this folder">${icon('more')}</button>
<input id="upf" type="file" multiple hidden><input id="upd2" type="file" webkitdirectory hidden></div>`;
};
const drawItems = () => {
const list = visible(), box = $('#items'), ff = $('#ff');
ff.hidden = v.items.length < 8;
const row = it => { const d = it.type === 'dir', on = v.sel.has(it.name);
return `<div class="row it fr${on ? ' sel' : ''}" data-n="${esc(it.name)}" ${ro ? '' : 'draggable="true"'}>
${v.selecting ? `<span class="ck${on ? ' on' : ''}" aria-hidden="true">${on ? icon('check') : ''}</span>` : ''}
<button class="rmain" aria-label="${d ? 'Open folder ' : 'Open '}${esc(it.name)}"><span class="ic" style="background:${d ? '#e0a82b' : OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon(d ? 'files' : 'file')}</span>
<span class="grow"><b>${esc(it.name)}</b><small>${d ? fmtDate(it.mtime) : fmtSize(it.size) + ' · ' + fmtDate(it.mtime)}</small></span></button>
${v.selecting ? '' : `<button class="ibtn" data-more aria-label="Actions for ${esc(it.name)}">${icon('more')}</button>`}</div>`; };
box.innerHTML = (v.stale ? `<p class="sub" style="margin:0 0 8px" role="status">${v.stale === 'old' ? 'Showing the last saved list. ' : ''}${esc(v.staleWhy || '')}</p>` : '') + (list.length ? `<div class="list">${list.slice(0, v.limit).map(row).join('')}</div>${list.length > v.limit ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="fmore">Show ${Math.min(300, list.length - v.limit)} more (${list.length - v.limit} not shown)</button></div>` : ''}`
: `<p class="empty">${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}</p>`);
const fm = $('#fmore'); if (fm) fm.onclick = () => { v.limit += 300; drawItems(); };
const dirs = v.items.filter(x => x.type === 'dir').length, files = v.items.length - dirs, size = v.items.reduce((a, x) => a + (x.size || 0), 0);
$('#fsum').textContent = v.items.length ? [dirs && dirs + (dirs === 1 ? ' folder' : ' folders'), files && files + (files === 1 ? ' file' : ' files'), size && fmtSize(size)].filter(Boolean).join(' · ') : '';
};
const redraw = () => { drawTop(); drawItems(); };
const toggle = (it, range) => {
const vis = visible().map(x => x.name);
if (range && v.last && vis.includes(v.last) && vis.includes(it.name)) { const a = vis.indexOf(v.last), b = vis.indexOf(it.name); vis.slice(Math.min(a, b), Math.max(a, b) + 1).forEach(n => v.sel.add(n)); }
else { v.sel.has(it.name) ? v.sel.delete(it.name) : v.sel.add(it.name); v.last = it.name; }
v.selecting = true; redraw();
};
const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
const openIt = it => it.type === 'dir' ? go(joinP(dir, it.name)) : openFile(repo, joinP(dir, it.name), it.name, dir);
const oneActions = it => {
const p = joinP(dir, it.name), d = it.type === 'dir', a = [{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, p, d) }];
if (!ro) a.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, p, it.name, dir, d) });
if (!d) a.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, p) });
if (d) a.push({ label: 'Download as zip', icon: 'dl', run: () => zipDownload(repo, dir, [it.name]) });
if (!d) a.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, p, it.name, ro) });
return a;
};
const create = async (path, op, extra = {}, kind = 'file') => {
try { await sf.req(`/api2/repos/${enc(repo)}/${kind}/?p=${enc(path)}`, { method: 'POST', body: new URLSearchParams({ operation: op, ...extra }), headers: FORM }); v.reload(true); }
catch (e) { toast(e.message); }
};
const actions = {
all: () => { const vis = visible(); if (v.sel.size === vis.length) v.sel.clear(); else vis.forEach(x => v.sel.add(x.name)); redraw(); },
move: () => v.sel.size && moveSheet('move', repo, dir, selItems()),
copy: () => v.sel.size && moveSheet('copy', repo, dir, selItems()),
del: () => v.sel.size && deleteItems(repo, dir, selItems()),
more: () => { const it = selItems()[0]; if (it) actionSheet(it.name, '', oneActions(it)); },
done: () => { v.selecting = false; v.sel.clear(); redraw(); },
sel: () => { v.selecting = true; redraw(); },
refresh: () => v.reload(),
zip: () => v.sel.size && zipDownload(repo, dir, [...v.sel]),
lib: () => actionSheet(dir === '/' ? (libNames[repo] || 'Library') : dir.split('/').pop(), 'This folder', [
{ label: 'Search this library', icon: 'refresh', run: () => libSearch(repo) },
{ label: 'Recycle bin', icon: 'trash', run: () => recycleBin(repo, dir) },
...(dir === '/' ? [] : [{ label: 'Download this folder as zip', icon: 'dl', run: () => zipDownload(repo, dir.replace(/\/[^/]*$/, '') || '/', [dir.split('/').pop()]) }]),
...(v.items.length ? [{ label: 'Download everything here as zip', icon: 'dl', run: () => zipDownload(repo, dir, v.items.map(x => x.name)) }] : []),
]),
sort: () => { const c = sortCfg(); actionSheet('Sort by', 'Folders stay on top', SORTS.map(([k, l]) => ({ label: l + (c.k === k ? (c.d > 0 ? ' ↑' : ' ↓') : ''), icon: 'sort',
run: () => { store.set('files_sort', { k, d: c.k === k ? -c.d : 1 }); drawItems(); } }))); },
up: () => actionSheet('Upload', '', [{ label: 'Files', icon: 'up', run: () => $('#upf').click() }, { label: 'A folder', icon: 'newdir', run: () => $('#upd2').click() }]),
new: () => actionSheet('Create new', '', [
{ label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir'), '', n => badName(n, v.items)) },
...[['Document', 'docx'], ['Spreadsheet', 'xlsx'], ['Presentation', 'pptx'], ['Text file', 'txt']].map(([label, x]) => ({ label, icon: 'doc',
run: () => textSheet('New ' + label.toLowerCase(), 'File name', 'Create', async n => {
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
try { const name = await createFromTemplate(repo, dir, want, x); v.reload(true); if (x !== 'txt') openFile(repo, joinP(dir, name), name, dir); }
catch (e) { toast(e.message); }
}, '', n => badName(n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x, v.items)) })),
]),
};
v.actions = actions; v.selItems = selItems; v.openIt = openIt; v.visible = visible; v.one = oneActions;
$('#ftop').onclick = e => { const b = e.target.closest('[data-a]'); if (b && !b.disabled && actions[b.dataset.a]) actions[b.dataset.a](); };
$('#ftop').onchange = e => {
const pick = e.target.id === 'upf' || e.target.id === 'upd2' ? [...e.target.files] : null; if (!pick) return;
enqueueUploads(repo, dir, pick.map(f => ({ file: f, rel: f.webkitRelativePath ? f.webkitRelativePath.split('/').slice(0, -1).join('/') : '' }))); e.target.value = '';
};
$('#ff').oninput = e => { v.filter = e.target.value; drawItems(); };
main.querySelector('.crumbs').onclick = e => { const b = e.target.closest('[data-go]'); if (b) go(b.dataset.go); };
// tap / click / ctrl-click / shift-click, right-click, and long-press on a touch screen
const box = $('#items'); let suppress = false, lastTouch = 0, lp = null;
box.onclick = e => {
if (suppress) { suppress = false; return; }
const r = e.target.closest('.fr'); if (!r) return; const it = byName(r.dataset.n); if (!it) return;
if (e.target.closest('[data-more]')) return itemSheet(it);
if (v.selecting || e.metaKey || e.ctrlKey || e.shiftKey) return toggle(it, e.shiftKey);
openIt(it);
};
box.oncontextmenu = e => {
const r = e.target.closest('.fr'); if (!r) return; e.preventDefault(); if (Date.now() - lastTouch < 1200) return;
const it = byName(r.dataset.n); if (!it) return;
if (v.selecting && v.sel.size > 1 && v.sel.has(it.name)) return actionSheet(v.sel.size + ' selected', '', [ ...(ro ? [] : [{ label: 'Move', icon: 'files', run: actions.move }]), { label: 'Copy', icon: 'copy', run: actions.copy }, ...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: actions.del }]) ]);
itemSheet(it);
};
box.onpointerdown = e => {
if (e.pointerType !== 'touch') return; lastTouch = Date.now();
const r = e.target.closest('.fr'); if (!r || v.selecting) return; const x0 = e.clientX, y0 = e.clientY;
const stop = () => { clearTimeout(lp); box.removeEventListener('pointermove', mv); };
const mv = m => { if (Math.abs(m.clientX - x0) + Math.abs(m.clientY - y0) > 10) stop(); };
box.addEventListener('pointermove', mv); box.addEventListener('pointerup', stop, { once: true }); box.addEventListener('pointercancel', stop, { once: true });
lp = setTimeout(() => { stop(); const it = byName(r.dataset.n); if (!it) return; suppress = true; v.sel.add(it.name); v.last = it.name; v.selecting = true; redraw(); try { navigator.vibrate && navigator.vibrate(12); } catch {} }, 450);
};
box.onmouseover = e => { const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (it && it.type !== 'dir' && !ro) prefetchDl(repo, joinP(dir, it.name)); };
// Drag: out to the desktop (one file, Chrome/Edge), onto a folder or breadcrumb to move (Alt/Option copies), in from the computer to upload.
box.ondragstart = e => {
const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (!it) return;
const names = v.sel.has(it.name) && v.sel.size ? [...v.sel] : [it.name];
e.dataTransfer.setData('application/x-hub-items', JSON.stringify({ repo, dir, names })); e.dataTransfer.effectAllowed = 'copyMove';
const c = it.type !== 'dir' && names.length === 1 && dlUrls.get(repo + joinP(dir, it.name));
if (c && c.url) e.dataTransfer.setData('DownloadURL', `application/octet-stream:${it.name.replace(/:/g, '-')}:${c.url}`);
else if (it.type !== 'dir') prefetchDl(repo, joinP(dir, it.name));
};
const target = e => { const t = e.target.closest('.fr, [data-go]'); if (!t) return null;
if (t.dataset.go) return { el: t, path: t.dataset.go }; const it = byName(t.dataset.n); return it && it.type === 'dir' ? { el: t, path: joinP(dir, it.name) } : null; };
const clearDrop = () => main.querySelectorAll('.dropt').forEach(x => x.classList.remove('dropt')), isFiles = e => [...(e.dataTransfer.types || [])].includes('Files');
main.ondragover = e => {
const mine = [...(e.dataTransfer.types || [])].includes('application/x-hub-items'); if (!mine && !isFiles(e) || ro) return;
e.preventDefault(); clearDrop(); const t = target(e); if (t) t.el.classList.add('dropt'); else if (isFiles(e)) main.classList.add('dropt'); e.dataTransfer.dropEffect = mine && e.altKey ? 'copy' : mine ? 'move' : 'copy';
};
main.ondragleave = e => { if (!main.contains(e.relatedTarget)) { clearDrop(); main.classList.remove('dropt'); } };
main.ondrop = async e => {
if (ro) return; e.preventDefault(); clearDrop(); main.classList.remove('dropt');
const t = target(e), mine = e.dataTransfer.getData('application/x-hub-items');
if (mine) {
const { names } = JSON.parse(mine); if (!t || t.path === dir || names.some(n => joinP(dir, n) === t.path)) return;
try { await transferItems(e.altKey ? 'copy' : 'move', repo, dir, names, { repo, dir: t.path }); toast((e.altKey ? 'Copied' : 'Moved') + (names.length > 1 ? ' ' + names.length + ' items' : '')); } catch (err) { toast(err.message); }
return afterChange(repo, dir);
}
if (isFiles(e)) { const files = await droppedFiles(e.dataTransfer); enqueueUploads(repo, t ? t.path : dir, files); }
};
v.reload = async quiet => {
const tok = v.tok = Symbol(), saved = dcGet(repo, dir);
const restore = () => { if (v.scrolled) return; v.scrolled = true; const y = fScroll.get(location.hash); if (y) requestAnimationFrame(() => { const view = $('#view'); if (view) view.scrollTop = y; }); };
if (!quiet) {
if (!v.items.length && saved) { v.items = saved.items; v.stale = 'old'; v.staleWhy = 'Updating…'; redraw(); restore(); } // the saved list at once, the real one right behind it
else $('#items').innerHTML = '<p class="empty">Loading…</p>';
}
try {
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
if (v.tok !== tok || !document.contains(main)) return;
v.items = items; v.stale = false; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n);
dcPut(repo, dir, items);
redraw(); restore();
} catch (e) {
if (v.tok !== tok || !document.contains(main)) return;
if (!sf.token()) return route();
const why = navigator.onLine ? e.message : 'You are offline.';
if (saved && v.items.length) { // keep showing the saved list instead of an error
v.stale = 'old'; v.staleWhy = why + ' Saved ' + fmtDate(saved.t / 1000) + '. '; redraw(); restore();
const n = $('#items .sub'); if (n) { n.insertAdjacentHTML('beforeend', '<a href="#" id="fretry2" style="color:var(--accent)">Try again</a>'); $('#fretry2').onclick = ev => { ev.preventDefault(); v.reload(); }; }
return;
}
$('#items').innerHTML = `<p class="err">${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}</p><div class="bar"><button class="btn ghost" id="fretry">Try again</button></div>`;
$('#fretry').onclick = () => v.reload();
}
};
drawTop(); v.reload();
}
const joinP = (d, n) => (d === '/' ? '' : d) + '/' + n;
// Keep the folder fresh when you come back to the tab, and add Finder-style keys.
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && fv && document.contains(fv.root) && !upActive() && !document.getElementById('sheet')) fv.reload(true); });
addEventListener('keydown', e => {
if (!fv || !document.contains(fv.root) || document.getElementById('sheet') || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) return;
const mod = e.metaKey || e.ctrlKey, n = fv.sel.size, one = n === 1 ? fv.selItems()[0] : null, a = fv.actions;
if (e.key === 'Escape' && (n || fv.selecting)) { a.done(); }
else if (mod && e.key.toLowerCase() === 'a') { e.preventDefault(); fv.selecting = true; fv.visible().forEach(x => fv.sel.add(x.name)); a.sel(); }
else if ((e.key === 'Delete' || e.key === 'Backspace') && n) { e.preventDefault(); a.del(); }
else if (e.key === 'F2' && one) { e.preventDefault(); const p = joinP(fv.dir, one.name); renameItem(fv.repo, p, one.name, fv.dir, one.type === 'dir'); }
else if (e.key === 'Enter' && one) fv.openIt(one);
});