Files: sturdier Seafile pass-through (timeouts, retries, stop on disconnect), in-place sign-in, saved folder lists, upload name clashes, undo and recycle bin, library search, zip downloads, share link options, text viewer and editor
build-container / build (push) Successful in 20m19s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 09:16:40 -04:00
1 parent 004209ed4f
commit cedd6845ce
11 files changed
+399 -64

No files matched your search

+22 -5
View File
@@ -100,11 +100,23 @@ let cfg = { mail: '', podcast: 'https://podcast.necloud.us/', verse: 'https://ve
// ---- Seafile client (via same-origin proxy)
const sf = {
token: () => store.get('sf_token'),
async req(path, opts = {}) {
const headers = { Authorization: 'Token ' + sf.token(), ...(opts.headers || {}) };
const r = await fetch('/sf' + path, { ...opts, headers });
/** A call to Seafile through the hub. Reads are retried; a slow or unreachable Seafile gives a clear message; an expired sign-in asks for the
* password again in place (sfReauth) and then carries on, so you do not lose where you were. */
async req(path, opts = {}, tries = 0) {
const { _reauthed, timeout, ...rest } = opts, method = (rest.method || 'GET').toUpperCase(), idem = method === 'GET' || method === 'HEAD';
const headers = { Authorization: 'Token ' + sf.token(), ...(rest.headers || {}) };
let r;
try { r = await fetch('/sf' + path, { ...rest, headers, signal: rest.signal || AbortSignal.timeout(timeout || 45000) }); }
catch (e) {
if (idem && tries < 2 && e.name !== 'AbortError' && navigator.onLine) { await sleep(600 * (tries + 1)); return sf.req(path, opts, tries + 1); }
throw new Error(!navigator.onLine ? 'You are offline' : e.name === 'TimeoutError' ? 'Seafile took too long to answer. Try again in a moment.' : 'Could not reach the server');
}
if (r.status === 401 && !_reauthed) { await sfReauth(); return sf.req(path, { ...opts, _reauthed: true }); }
if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); }
if (!r.ok) throw new Error((await r.text().catch(() => '')).slice(0, 200) || 'Request failed (' + r.status + ')');
if (!r.ok) {
let m = (await r.text().catch(() => '')).slice(0, 200); try { const j = JSON.parse(m); m = j.error || j.error_msg || j.detail || m; } catch {}
throw new Error(m || (r.status === 504 ? 'Seafile took too long to answer' : r.status >= 500 ? 'Seafile had a problem (' + r.status + ')' : 'Request failed (' + r.status + ')'));
}
const t = await r.text();
try { return JSON.parse(t); } catch { return t; }
},
@@ -121,7 +133,12 @@ const sf = {
},
};
const toast = (m) => { const t = $('#toast'); t.textContent = m; t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 2600); };
/** A short message at the bottom; with `action` ({ label, run }) it has a button (Undo, say) and stays a little longer. */
const toast = (m, action) => {
const t = $('#toast'); t.textContent = m; t.classList.toggle('act', !!action);
if (action) { const b = document.createElement('button'); b.textContent = action.label; b.onclick = () => { t.classList.remove('on'); action.run(); }; t.appendChild(b); }
t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), action ? 9000 : 2600);
};
const ext = n => (n.split('.').pop() || '').toLowerCase();
const EDIT_EXT = new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt']);
const OFFICE_EXT =new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt', 'pdf']);
+4 -14
View File
@@ -1,6 +1,6 @@
'use strict';
// ---- File actions
const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac' };
const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic avif', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac opus', text: 'txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore' };
const previewKind = n => Object.keys(PREVIEW).find(k => PREVIEW[k].split(' ').includes(ext(n)));
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
const encP = p => p.split('/').map(encodeURIComponent).join('/');
@@ -21,6 +21,7 @@ function openFile(repo, path, name, dir) {
async function previewFile(repo, path, name) {
addRecent(repo, path, name);
if (previewKind(name) === 'text') return textPreview(repo, path, name).catch(e => toast(e.message));
try {
const url = (await fileUrl(repo, path)) + '?hubinline=1', kind = previewKind(name);
const media = kind === 'img' ? `<img src="${url}" alt="${esc(name)}" style="max-width:100%;max-height:70dvh;border-radius:12px;display:block;margin:8px auto">`
@@ -32,19 +33,7 @@ async function previewFile(repo, path, name) {
} catch (e) { toast(e.message); }
}
async function shareLink(repo, path, isDir) {
const p = isDir ? path.replace(/\/?$/, '/') : path;
try {
let link;
try { link = (await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORM, body: new URLSearchParams({ repo_id: repo, path: p }) })).link; }
catch { link = (await sf.req(`/api/v2.1/share-links/?repo_id=${encodeURIComponent(repo)}&path=${encodeURIComponent(p)}`))[0]?.link; }
if (!link) throw new Error('Could not create a share link');
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || '', `<input type="text" readonly id="sl" value="${esc(link)}" onfocus="this.select()">
<div class="bar"><button class="btn" id="cp1" style="flex:1;justify-content:center">${icon('copy')}Copy</button>${navigator.share ? '<button class="btn ghost" id="sh1" style="flex:1;justify-content:center">Share…</button>' : ''}</div>`);
el.querySelector('#cp1').onclick = async () => { try { await navigator.clipboard.writeText(link); toast('Link copied'); closeSheet(); } catch { el.querySelector('#sl').select(); toast('Press copy on the selected link'); } };
const sh = el.querySelector('#sh1'); if (sh) sh.onclick = () => navigator.share({ url: link }).catch(() => {});
} catch (e) { toast(e.message); }
}
const shareLink = (repo, path, isDir) => shareSheet(repo, path, isDir);
function fileSheet(repo, path, name, dir) {
const enc = encodeURIComponent, acts = [], ro = libPerm[repo] === 'r', it = { name, type: 'file' };
@@ -66,6 +55,7 @@ function dirSheet(repo, path, name, dir) {
actionSheet(name, 'Folder', [
{ label: 'Open', icon: 'files', run: () => { location.hash = '#/files/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); } },
{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) },
{ label: 'Download as zip', icon: 'dl', run: () => zipDownload(repo, dir, [name]) },
...(ro ? [] : [{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) }, { label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) }]),
{ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) },
...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) }]),
+209
View File
@@ -0,0 +1,209 @@
'use strict';
// Files, the sturdier parts: signing in again in place, a saved copy of each folder (instant, and still there offline), upload name clashes,
// undo and the recycle bin, search in a library, zip downloads, share link options, and text files you can read and edit.
// Uses sf, store, ME, jfetch, openSheet, closeSheet, confirmSheet, actionSheet, toast, esc, icon, fmtSize, fmtDate, joinP, libNames, libPerm, cfg from the other scripts.
// ---- Seafile sign-in expired: ask again without leaving the page, then the original request carries on
let reauthP = null;
function sfReauth() {
if (reauthP) return reauthP;
reauthP = new Promise((ok, no) => {
let settled = false;
const finish = (good, err) => { if (settled) return; settled = true; reauthP = null; good ? ok() : (store.del('sf_token'), no(err || new Error('Session expired, sign in again'))); };
const el = openSheet('Sign in to Seafile again', 'Your Seafile sign-in expired. You will stay where you are.', `<form id="rf"><label for="ru">Email / username</label><input type="text" id="ru" autocomplete="username" autocapitalize="off" required>
<label for="rp">Password</label><input type="password" id="rp" autocomplete="current-password" required><div id="rotp" hidden><label for="ro">2FA code</label><input type="text" id="ro" inputmode="numeric" autocomplete="one-time-code"></div>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Not now</button><button class="btn" id="rb" style="flex:1;justify-content:center">Sign in</button></div><div class="err" id="re" role="alert"></div></form>`);
el.querySelector('#sx').onclick = () => { closeSheet(); finish(false); };
el.querySelector('.scrim').addEventListener('click', () => finish(false));
el.querySelector('#rf').onsubmit = async e => {
e.preventDefault(); const b = el.querySelector('#rb'); b.disabled = true; el.querySelector('#re').textContent = '';
try { await sf.login(el.querySelector('#ru').value.trim(), el.querySelector('#rp').value, el.querySelector('#ro').value.trim() || undefined); closeSheet(); finish(true); }
catch (err) { b.disabled = false; if (err.otp) el.querySelector('#rotp').hidden = false; el.querySelector('#re').textContent = err.message; }
};
setTimeout(() => el.querySelector('#ru').focus(), 60);
});
return reauthP;
}
// ---- A saved copy of every folder you open (names, sizes, dates only): shown at once while the real list loads, and when Seafile cannot be reached
const dcIndex = () => 'dc:index:' + (ME ? ME.id : '');
const dcKey = (repo, dir) => 'dc:' + (ME ? ME.id : '') + ':' + repo + ':' + dir;
function dcGet(repo, dir) { try { const o = JSON.parse(localStorage.getItem(dcKey(repo, dir))); return o && Array.isArray(o.items) ? o : null; } catch { return null; } }
function dcPut(repo, dir, items) {
const key = dcKey(repo, dir), body = JSON.stringify({ t: Date.now(), items: items.slice(0, 1500).map(x => ({ id: x.id, name: x.name, type: x.type, size: x.size, mtime: x.mtime })) });
const touch = () => { const idx = (JSON.parse(localStorage.getItem(dcIndex()) || '[]')).filter(k => k !== key); idx.unshift(key); while (idx.length > 40) localStorage.removeItem(idx.pop()); localStorage.setItem(dcIndex(), JSON.stringify(idx)); };
try { localStorage.setItem(key, body); touch(); }
catch { try { const idx = JSON.parse(localStorage.getItem(dcIndex()) || '[]'); for (const k of idx.splice(Math.ceil(idx.length / 2))) localStorage.removeItem(k); localStorage.setItem(dcIndex(), JSON.stringify(idx)); localStorage.setItem(key, body); touch(); } catch { /* storage is full: go without */ } }
}
const fScroll = new Map(); // where each folder was scrolled to
document.addEventListener('scroll', e => { if (e.target && e.target.id === 'view' && /^#\/files\/./.test(location.hash)) fScroll.set(location.hash, e.target.scrollTop); }, true);
// ---- Uploading a file whose name is already there
async function resolveUploadConflicts(repo, dir, entries) {
let existing;
try { existing = new Set((await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).map(x => x.name.toLowerCase())); } catch { return entries; }
const clash = entries.filter(e => !e.rel && existing.has(e.file.name.toLowerCase())); // (files inside dropped folders keep Seafile's own renaming)
if (!clash.length) return entries;
const choice = await new Promise(ok => {
const el = openSheet(clash.length === 1 ? 'Replace “' + clash[0].file.name + '”?' : clash.length + ' files already exist here', 'Replacing keeps the old version in the file\'s history.',
`${clash.length > 1 ? `<div class="list" style="background:var(--panel2);border:0;max-height:30dvh;overflow:auto">${clash.map(c => `<div class="row"><span class="grow"><b>${esc(c.file.name)}</b></span></div>`).join('')}</div>` : ''}
<div class="bar"><button class="btn" id="cr" style="flex:1;justify-content:center">Replace</button><button class="btn ghost" id="ck" style="flex:1;justify-content:center">Keep both</button></div>
<div class="bar" style="margin-top:0"><button class="btn ghost" id="cs" style="flex:1;justify-content:center">Skip ${clash.length === 1 ? 'it' : 'those'}</button><button class="btn ghost" id="cc" style="flex:1;justify-content:center">Cancel upload</button></div>`);
let done = false; const pick = v => { if (done) return; done = true; closeSheet(); ok(v); };
el.querySelector('#cr').onclick = () => pick('replace'); el.querySelector('#ck').onclick = () => pick('both'); el.querySelector('#cs').onclick = () => pick('skip'); el.querySelector('#cc').onclick = () => pick('cancel');
el.querySelector('.scrim').addEventListener('click', () => pick('cancel'));
});
if (choice === 'cancel') return [];
if (choice === 'skip') return entries.filter(e => !clash.includes(e));
return entries.map(e => clash.includes(e) ? { ...e, replace: choice === 'replace' } : e);
}
const waitOnline = () => new Promise(ok => { if (navigator.onLine) return ok(); const f = () => { removeEventListener('online', f); ok(); }; addEventListener('online', f); });
// ---- Undo a delete, and the recycle bin (Seafile keeps deleted files for a while)
const trashWhen = t => typeof t === 'number' ? t * 1000 : Date.parse(t) || 0;
async function trashList(repo, path = '/', limit = 100) {
const r = await sf.req(`/api/v2.1/repos/${encodeURIComponent(repo)}/trash/?path=${encodeURIComponent(path)}&limit=${limit}`);
return (r && (r.items || r.data)) || [];
}
async function trashRestore(repo, t) {
const parent = (t.parent_dir || '/').replace(/\/+$/, '') || '';
await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${t.is_dir ? 'dir' : 'file'}/revert/`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ p: parent + '/' + t.obj_name, commit_id: t.commit_id }) });
}
/** Right after a delete: find the just-deleted items in the recycle bin and offer Undo. (Quietly nothing if Seafile has no recycle bin API.) */
async function offerUndo(repo, dir, items, afterRestore) {
try {
const names = new Set(items.map(x => x.name)), newest = new Map();
for (const t of await trashList(repo, dir, 60)) if (names.has(t.obj_name) && Date.now() - trashWhen(t.deleted_time) < 5 * 60e3 && (!newest.has(t.obj_name) || trashWhen(t.deleted_time) > trashWhen(newest.get(t.obj_name).deleted_time))) newest.set(t.obj_name, t);
if (!newest.size) return false;
toast(items.length === 1 ? 'Deleted “' + items[0].name + '”' : items.length + ' items deleted', { label: 'Undo', run: async () => {
let bad = 0; for (const t of newest.values()) { try { await trashRestore(repo, t); } catch { bad++; } }
toast(bad ? 'Could not restore ' + bad + ' item' + (bad === 1 ? '' : 's') : 'Restored'); if (afterRestore) afterRestore();
} });
return true;
} catch { return false; /* no undo available */ }
}
async function recycleBin(repo, dir) {
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty">Loading…</p>');
el.querySelector('.sheet').classList.add('wide');
try {
const list = (await trashList(repo, dir || '/', 100)).sort((a, b) => trashWhen(b.deleted_time) - trashWhen(a.deleted_time));
if (!document.body.contains(el)) return;
const body = el.querySelector('.empty');
body.outerHTML = list.length ? `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${list.map((t, i) => `<div class="row"><span class="ic" style="background:${t.is_dir ? '#e0a82b' : '#6a7280'}">${icon(t.is_dir ? 'files' : 'file')}</span>
<span class="grow"><b>${esc(t.obj_name)}</b><small style="white-space:normal">${esc((t.parent_dir || '/'))} · ${t.is_dir ? 'folder' : esc(fmtSize(t.size || 0))} · deleted ${esc(new Date(trashWhen(t.deleted_time)).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></span>
${libPerm[repo] === 'r' ? '' : `<button class="btn ghost" data-rs="${i}" style="min-height:36px">Restore</button>`}</div>`).join('')}</div><p class="sub">Showing the 100 most recent. Seafile empties its recycle bin after a while.</p>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`
: '<p class="empty">Nothing here. Deleted files and folders from this library show up here so you can get them back.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>';
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-rs]').forEach(b => b.onclick = async () => {
b.disabled = true; try { await trashRestore(repo, list[+b.dataset.rs]); b.textContent = 'Restored'; if (fv && fv.repo === repo) fv.reload(true); } catch (e) { b.disabled = false; toast(e.message); }
});
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
}
// ---- Search a whole library (names, from the Seafile index when it has one, otherwise by walking the folders)
function libSearch(repo) {
textSheet('Search this library', 'File or folder name', 'Search', async term => {
const el = openSheet('Results for “' + term + '”', libNames[repo] || '', '<p class="empty">Searching…</p>'); el.querySelector('.sheet').classList.add('wide');
try {
const { results } = await searchFiles(term, [{ id: repo, name: libNames[repo] || '' }]);
const mine = results.filter(r => r.repo_id === repo).slice(0, 60);
if (!document.body.contains(el)) return;
el.querySelector('.empty').outerHTML = `${mine.length ? `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${mine.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${r.is_dir ? '#e0a82b' : '#6a7280'}">${icon(r.is_dir ? 'files' : 'file')}</span><span class="grow"><b>${esc(r.name)}</b><small style="white-space:normal">${esc((r.fullpath || '').replace(/\/[^/]*$/, '') || '/')}</small></span></button>`).join('')}</div>` : '<p class="empty">Nothing found.</p>'}<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`;
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-r]').forEach(b => b.onclick = () => {
const r = mine[+b.dataset.r], parent = (r.fullpath || '').replace(/\/[^/]*$/, '') || '/'; closeSheet();
if (r.is_dir) location.hash = '#/files/' + encodeURIComponent(repo) + (r.fullpath || '').split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join('');
else openFile(repo, r.fullpath, r.name, parent);
});
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
});
}
// ---- Download several items, or a whole folder, as one zip (Seafile builds it; we wait for it)
async function zipDownload(repo, dir, names) {
try {
const q = new URLSearchParams({ parent_dir: dir }); names.forEach(n => q.append('dirents', n));
const task = await sf.req(`/api/v2.1/repos/${encodeURIComponent(repo)}/zip-task/?${q}`), token = task.zip_token;
if (!token) throw new Error('Seafile could not make a zip of that');
for (let i = 0; i < 300; i++) {
const p = await sf.req(`/api/v2.1/query-zip-progress/?token=${encodeURIComponent(token)}`);
const total = +p.total || 0, zipped = +p.zipped || 0;
if (p.failed || p.failed_reason) throw new Error('Seafile could not make the zip' + (p.failed_reason ? ': ' + p.failed_reason : ''));
if (total && zipped >= total) break;
toast('Preparing the zip… ' + (total ? Math.round(100 * zipped / total) + '%' : ''));
await sleep(i < 5 ? 500 : 1500);
if (i === 299) throw new Error('The zip is taking too long. Try fewer files.');
}
const a = document.createElement('a'); a.href = '/sf/seafhttp/zip/' + encodeURIComponent(token); a.download = ''; document.body.appendChild(a); a.click(); a.remove();
toast('Download started');
} catch (e) { toast(e.message); }
}
// ---- Share links with a password and an expiry, and the ones that already exist
async function shareSheet(repo, path, isDir) {
const p = isDir ? path.replace(/\/?$/, '/') : path, enc = encodeURIComponent, FORMH = { 'Content-Type': 'application/x-www-form-urlencoded' };
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty">Loading…</p>');
const draw = async () => {
let links = []; try { links = (await sf.req(`/api/v2.1/share-links/?repo_id=${enc(repo)}&path=${enc(p)}`)) || []; } catch (e) { if (!document.body.contains(el)) return; }
if (!document.body.contains(el)) return;
const host = el.querySelector('.empty') || el.querySelector('#shbody');
const html = `<div id="shbody">${links.length ? `<div class="list" style="background:var(--panel2);border:0">${links.map((l, i) => `<div class="row" style="flex-wrap:wrap"><span class="grow" style="min-width:0"><input type="text" readonly value="${esc(l.link)}" onfocus="this.select()" style="margin:0"><small style="white-space:normal">${l.expire_date ? (l.is_expired ? 'Expired ' : 'Expires ') + esc(new Date(l.expire_date).toLocaleDateString([], { dateStyle: 'medium' })) : 'Never expires'}${l.is_expired ? '' : ''}</small></span>
<button class="btn ghost" data-cp="${i}" style="min-height:36px">Copy</button><button class="btn danger" data-rm="${i}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
<form id="shf"><h3 style="margin:14px 0 4px;font-size:15px">${links.length ? 'New link' : 'Create a link'}</h3>
<label for="shp">Password (optional, at least 8 characters)</label><input type="password" id="shp" autocomplete="new-password" placeholder="No password">
<label for="she">Expires</label><select id="she"><option value="">Never</option><option value="1">In 1 day</option><option value="7" selected>In 7 days</option><option value="30">In 30 days</option><option value="90">In 90 days</option></select>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" id="shc" style="flex:1;justify-content:center">Create link</button></div><div class="err" id="she2" role="alert"></div></form></div>`;
host.outerHTML = html;
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-cp]').forEach(b => b.onclick = async () => { const l = links[+b.dataset.cp]; try { await navigator.clipboard.writeText(l.link); toast('Link copied'); } catch { toast('Select the link and copy it'); } });
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete this link?', 'Anyone using it loses access.', 'Delete', async () => { try { await sf.req(`/api/v2.1/share-links/${enc(links[+b.dataset.rm].token)}/`, { method: 'DELETE' }); shareSheet(repo, path, isDir); } catch (e) { toast(e.message); } }));
el.querySelector('#shf').onsubmit = async e => {
e.preventDefault(); const pw = el.querySelector('#shp').value, days = el.querySelector('#she').value, btn = el.querySelector('#shc');
if (pw && pw.length < 8) { el.querySelector('#she2').textContent = 'A password needs at least 8 characters.'; return; }
btn.disabled = true;
try {
const body = new URLSearchParams({ repo_id: repo, path: p }); if (pw) body.set('password', pw); if (days) body.set('expire_days', days);
const made = await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORMH, body });
try { await navigator.clipboard.writeText(made.link); toast('Link created and copied'); } catch { toast('Link created'); }
shareSheet(repo, path, isDir);
} catch (err) { btn.disabled = false; el.querySelector('#she2').textContent = err.message; }
};
};
draw();
}
// ---- Text files: read them here, and edit small ones (the old version stays in the file's history)
const TEXT_EXT = new Set('txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore'.split(' '));
async function textPreview(repo, path, name) {
const dir = path.replace(/\/[^/]*$/, '') || '/', ro = libPerm[repo] === 'r', url = (await fileUrl(repo, path)) + '?hubinline=1';
const el = openSheet(name, '', '<p class="empty">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
const dl = `<a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Download</a>`;
try {
const r = await fetch(url); if (!r.ok) throw new Error('Could not open the file (' + r.status + ')');
const len = +r.headers.get('content-length') || 0;
if (len > 1024 * 1024) { await r.body.cancel(); el.querySelector('.empty').outerHTML = `<p class="sub">This file is ${esc(fmtSize(len))}, too big to show here.</p><div class="bar">${dl}<button class="btn ghost" id="sx">Close</button></div>`; el.querySelector('#sx').onclick = closeSheet; return; }
let text = await r.text(); if (text.length > 1024 * 1024) text = text.slice(0, 1024 * 1024);
const view = () => {
if (!document.body.contains(el)) return;
const host = el.querySelector('#tpb') || el.querySelector('.empty');
host.outerHTML = `<div id="tpb"><pre class="ptxt">${esc(text)}</pre><div class="bar">${ro || len > 256 * 1024 ? '' : '<button class="btn" id="ted">Edit</button>'}${dl}<button class="btn ghost" id="sx">Close</button></div></div>`;
el.querySelector('#sx').onclick = closeSheet; const ed = el.querySelector('#ted'); if (ed) ed.onclick = edit;
};
const edit = () => {
el.querySelector('#tpb').outerHTML = `<div id="tpb"><textarea id="tta" class="ptxt-edit" spellcheck="false">${esc(text)}</textarea><div class="bar"><button class="btn" id="tsv">Save</button><button class="btn ghost" id="tcx">Cancel</button></div><div class="err" id="ter" role="alert"></div></div>`;
el.querySelector('#tcx').onclick = view;
el.querySelector('#tsv').onclick = async () => {
const b = el.querySelector('#tsv'); b.disabled = true; const next = el.querySelector('#tta').value;
try {
const link = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/update-link/?p=${encodeURIComponent(dir)}`), fd = new FormData();
fd.append('target_file', path); fd.append('parent_dir', dir); fd.append('file', new Blob([next], { type: 'text/plain' }), name);
const up = await fetch('/sf' + new URL(link, cfg.seafile).pathname, { method: 'POST', body: fd });
if (!up.ok) throw new Error('Could not save (' + up.status + ')');
text = next; toast('Saved'); view(); if (fv && fv.repo === repo) fv.reload(true);
} catch (e) { b.disabled = false; el.querySelector('#ter').textContent = e.message; }
};
};
view();
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
}
+42 -17
View File
@@ -71,7 +71,8 @@ async function loadLibs(active) {
// ---- 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 nameCmp = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
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
@@ -97,8 +98,7 @@ 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); toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); }
afterChange(repo, dir);
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) {
@@ -130,9 +130,11 @@ async function downloadItem(repo, path) {
// ---- 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');
function enqueueUploads(repo, dir, entries) {
async function enqueueUploads(repo, dir, entries) {
if (!entries.length) return;
for (const e of entries) uq.items.push({ repo, dir, file: e.file, rel: e.rel || '', pct: 0, state: 'wait', tries: 0 });
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() {
@@ -151,7 +153,7 @@ async function sendUpload(it) {
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', '0'); if (it.rel) fd.append('relative_path', it.rel); fd.append('file', it.file, it.file.name);
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 }));
@@ -162,8 +164,10 @@ async function sendUpload(it) {
return;
} catch (e) {
if (it.cancelled) throw e;
if (e.fatal || !sf.token() || it.tries >= 3) throw new Error(it.file.name + ': ' + e.message);
await sleep(1500 * it.tries);
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)));
}
}
}
@@ -181,7 +185,7 @@ function drawUpBar() {
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>Uploading ${esc(live.length === 1 ? cur.file.name : live.length + ' files')}</b><small>${pct}%</small><i style="width:${pct}%"></i></div><button class="btn ghost" id="upx">Cancel</button>`;
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>`;
@@ -221,7 +225,7 @@ async function loadDir(repo, dir) {
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 };
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));
@@ -239,12 +243,12 @@ async function loadDir(repo, dir) {
<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="more" aria-label="More" ${one ? '' : 'disabled'}>${icon('more')}</button></div></div>`
<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="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 = () => {
@@ -256,8 +260,9 @@ async function loadDir(repo, dir) {
<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 = list.length ? `<div class="list">${list.map(row).join('')}</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>`;
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(' · ') : '';
};
@@ -275,6 +280,7 @@ async function loadDir(repo, dir) {
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;
};
@@ -292,6 +298,13 @@ async function loadDir(repo, dir) {
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() }]),
@@ -368,15 +381,27 @@ async function loadDir(repo, dir) {
};
v.reload = async quiet => {
const tok = v.tok = Symbol(); if (!quiet) $('#items').innerHTML = '<p class="empty">Loading…</p>';
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; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n);
redraw();
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();
}
+1
View File
@@ -70,6 +70,7 @@
<script src="/lyrics-view.js"></script>
<script src="/settings.js"></script>
<script src="/files.js"></script>
<script src="/files-extra.js"></script>
<script src="/sheets.js"></script>
<script src="/file-actions.js"></script>
<script src="/signal-view.js"></script>
+6
View File
@@ -83,6 +83,8 @@ label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px
background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: 12px; opacity: 0; pointer-events: none;
transition: .2s; max-width: 90vw; z-index: 10; }
#toast.on { opacity: 1; transform: translate(-50%, 0); }
#toast.act { pointer-events: auto; display: flex; align-items: center; gap: 14px; }
#toast.act button { border: 0; background: none; color: var(--accent-fg, #7aa2ff); font-weight: 700; padding: 0 4px; font-size: 15px; }
/* Tablet / iPad (incl. 12.9" portrait 1024 + landscape 1366) and desktop: side rail */
@media (min-width: 768px) {
@@ -570,3 +572,7 @@ body.nav-mini .ngrp span { display: none; }
.sgrp a.on { background: var(--accent); color: var(--accent-t); font-weight: 600; }
.psel { width: auto; min-height: 38px; padding: 0 12px; border-radius: 999px; font-size: 14px; }
@media (max-width: 560px) { .ptop h1 { width: 100%; } .pspace { display: none; } .ptop .btn { margin-left: auto; } }
/* Text preview and editing */
.ptxt { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 60dvh; overflow: auto; background: var(--panel2); border-radius: 12px; padding: 12px 14px; font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; margin: 8px 0; }
.ptxt-edit { width: 100%; height: 55dvh; font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); resize: vertical; }