|
|
|
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
|
|
|
|
|
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
|
|
|
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
|
|
|
|
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
|
|
|
|
|
const SYNCED = new Set(['mail_hints', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
|
|
|
|
|
const SYNCED = new Set(['mail_hints', 'mail_on', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
|
|
|
|
|
const outbox = new Map(); let outTimer = null, warned = false;
|
|
|
|
|
function flushSettings() {
|
|
|
|
|
outTimer = null;
|
|
|
|
@@ -49,6 +49,7 @@ async function loadSettings() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ICONS = {
|
|
|
|
|
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
|
|
|
|
|
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
|
|
|
|
|
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
|
|
|
|
|
files: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
|
|
|
|
@@ -62,6 +63,7 @@ const ICONS = {
|
|
|
|
|
file: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5"/>',
|
|
|
|
|
dl: '<path d="M12 4v12M7 11l5 5 5-5M5 20h14"/>',
|
|
|
|
|
audio: '<path d="M4 14v-2a8 8 0 0116 0v2"/><rect x="3" y="14" width="4" height="7" rx="1.5"/><rect x="17" y="14" width="4" height="7" rx="1.5"/>',
|
|
|
|
|
sort: '<path d="M7 4v16M4 17l3 3 3-3M17 20V4M14 7l3-3 3 3"/>',
|
|
|
|
|
refresh: '<path d="M20 12a8 8 0 11-2.3-5.7M20 4v5h-5"/>',
|
|
|
|
|
full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
|
|
|
|
ext: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"/>',
|
|
|
|
@@ -70,6 +72,8 @@ const ICONS = {
|
|
|
|
|
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
|
|
|
|
|
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
|
|
|
|
|
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
|
|
|
|
|
spin: '<path d="M12 3a9 9 0 109 9" class="spin"/>',
|
|
|
|
|
x: '<path d="M6 6l12 12M18 6L6 18"/>',
|
|
|
|
|
prev: '<path d="M18 5L8 12l10 7zM6 5v14"/>',
|
|
|
|
|
repeat: '<path d="M4 11V9a3 3 0 013-3h12M16 3l3 3-3 3M20 13v2a3 3 0 01-3 3H5M8 21l-3-3 3-3"/>',
|
|
|
|
|
shuffle: '<path d="M3 7h3c4 0 5 10 9 10h6M3 17h3c1.5 0 2.5-1.5 3.5-3M15 7h6M18 4l3 3-3 3M18 14l3 3-3 3"/>',
|
|
|
|
@@ -93,6 +97,8 @@ const MAIL = [
|
|
|
|
|
|
|
|
|
|
// Saved account addresses (browser-only, no passwords) are passed as sign-in hints so the provider opens the right mailbox.
|
|
|
|
|
const mailHints = () => store.get('mail_hints', {});
|
|
|
|
|
// Which mail apps this person wants (all of them until they choose).
|
|
|
|
|
const mailOn = () => { const v = store.get('mail_on', null); return Array.isArray(v) ? v : MAIL.map(m => m.id); };
|
|
|
|
|
function mailUrl(m, kind) {
|
|
|
|
|
const base = m[kind], hint = (mailHints()[m.id] || '').trim();
|
|
|
|
|
if (!hint) return base;
|
|
|
|
@@ -102,7 +108,7 @@ function mailUrl(m, kind) {
|
|
|
|
|
return base; // iCloud has no sign-in hint
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cfg = { podcast: 'https://podcast.necloud.us/', verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
|
|
|
|
|
let cfg = { webmail: '', podcast: 'https://podcast.necloud.us/', verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
|
|
|
|
|
|
|
|
|
|
// ---- Seafile client (via same-origin proxy)
|
|
|
|
|
const sf = {
|
|
|
|
@@ -136,7 +142,7 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
|
|
|
|
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
|
|
|
|
|
|
|
|
// ---- Navigation
|
|
|
|
|
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
|
|
|
|
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
|
|
|
|
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' };
|
|
|
|
|
const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
|
|
|
|
const phone = matchMedia('(max-width: 767px)');
|
|
|
|
@@ -152,7 +158,10 @@ function renderNav(active = activeTab) {
|
|
|
|
|
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
|
|
|
|
|
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>` : '');
|
|
|
|
|
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
|
|
|
|
|
} else $('#nav').innerHTML = brand + mine.map(tab).join('');
|
|
|
|
|
} else {
|
|
|
|
|
$('#nav').innerHTML = brand + mine.map(tab).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
|
|
|
|
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
phone.addEventListener('change', () => renderNav());
|
|
|
|
|
|
|
|
|
@@ -164,7 +173,38 @@ function openMore() {
|
|
|
|
|
|
|
|
|
|
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
|
|
|
|
let editor;
|
|
|
|
|
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
|
|
|
|
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
|
|
|
|
function applyNav() { const p = navPref(); document.body.classList.toggle('nav-mini', !!p.mini); document.body.classList.toggle('nav-hidden', !!p.hidden); }
|
|
|
|
|
function setNav(patch) { try { localStorage.setItem('nav_pref', JSON.stringify({ ...navPref(), ...patch })); } catch {} applyNav(); }
|
|
|
|
|
applyNav();
|
|
|
|
|
addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) { e.preventDefault(); setNav({ hidden: !navPref().hidden }); } });
|
|
|
|
|
// In-app Back / Forward (an installed app has no browser buttons). Each history entry remembers its position so we know what is possible.
|
|
|
|
|
let navIdx = 0, navMax = 0, navStarted = false;
|
|
|
|
|
const parentHash = () => { const p = location.hash.split('/'); return p.length > 3 ? p.slice(0, -1).join('/') : ''; }; // e.g. #/files/lib/a/b -> #/files/lib/a
|
|
|
|
|
function trackNav() {
|
|
|
|
|
const s = history.state;
|
|
|
|
|
if (s && s.i != null) navIdx = s.i;
|
|
|
|
|
else { navIdx = navStarted ? navIdx + 1 : 0; navMax = Math.max(navMax, navIdx); if (navStarted) navMax = navIdx; try { history.replaceState({ ...(s || {}), i: navIdx }, ''); } catch {} }
|
|
|
|
|
navStarted = true; navMax = Math.max(navMax, navIdx);
|
|
|
|
|
}
|
|
|
|
|
function drawNav() {
|
|
|
|
|
const el = $('#navbar'); if (!el) return;
|
|
|
|
|
const canBack = navIdx > 0 || !!parentHash(), canFwd = navIdx < navMax;
|
|
|
|
|
if (!el.dataset.on) {
|
|
|
|
|
el.dataset.on = 1;
|
|
|
|
|
el.innerHTML = `<button class="ibtn navtoggle" id="nvm" aria-label="Show or hide the menu" title="Show or hide the menu (Ctrl/Cmd+B)">${icon('menu')}</button><button class="ibtn" id="nvb" aria-label="Back" title="Back">${icon('back')}</button><button class="ibtn" id="nvf" aria-label="Forward" title="Forward">${icon('chev')}</button><span id="nvt"></span>`;
|
|
|
|
|
$('#nvm').onclick = () => setNav({ hidden: !navPref().hidden });
|
|
|
|
|
$('#nvb').onclick = () => { if (navIdx > 0) history.back(); else if (parentHash()) location.hash = parentHash(); };
|
|
|
|
|
$('#nvf').onclick = () => history.forward();
|
|
|
|
|
}
|
|
|
|
|
$('#nvb').disabled = !canBack; $('#nvf').disabled = !canFwd;
|
|
|
|
|
const t = TABS.find(x => x[0] === activeTab); $('#nvt').textContent = t ? t[1] : '';
|
|
|
|
|
el.hidden = false;
|
|
|
|
|
}
|
|
|
|
|
addEventListener('popstate', e => { if (e.state && e.state.i != null) { navIdx = e.state.i; drawNav(); } });
|
|
|
|
|
function route() {
|
|
|
|
|
trackNav();
|
|
|
|
|
const [, id = 'home', ...rest] = location.hash.split('/');
|
|
|
|
|
let name = routes[id] ? id : 'home';
|
|
|
|
|
if (name === 'edit' ? !(allowed('files') || allowed('office')) : !allowed(name)) { name = 'home'; if (location.hash !== '#/home') history.replaceState(null, '', '#/home'); } // not one of their tabs
|
|
|
|
@@ -174,6 +214,7 @@ function route() {
|
|
|
|
|
document.body.classList.remove('inedit', 'onaudio');
|
|
|
|
|
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
|
|
|
|
|
renderNav(name === 'edit' ? 'files' : name);
|
|
|
|
|
drawNav();
|
|
|
|
|
routes[name](rest.map(decodeURIComponent));
|
|
|
|
|
$('#view').scrollTop = 0;
|
|
|
|
|
}
|
|
|
|
@@ -201,7 +242,7 @@ function viewHome() {
|
|
|
|
|
${allowed('tasks') ? tile('#/tasks', '#4f7cff', 'check', 'Tasks', 'To-do lists') : ''}
|
|
|
|
|
${allowed('calendar') ? tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda') : ''}
|
|
|
|
|
${allowed('office') ? tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice') : ''}
|
|
|
|
|
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks') : ''}
|
|
|
|
|
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen') : ''}
|
|
|
|
|
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
|
|
|
|
|
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
|
|
|
|
|
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts') : ''}
|
|
|
|
@@ -674,17 +715,40 @@ async function renderTasksCard(el) {
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function viewMail() {
|
|
|
|
|
$('#view').innerHTML = `<div class="wrap"><h1>Mail</h1>
|
|
|
|
|
<p class="sub">Opens each provider's own webmail in a new tab. Nothing is stored here.</p>
|
|
|
|
|
<h2>Inboxes</h2><div class="list">${MAIL.map(m => `
|
|
|
|
|
<a class="row" href="${esc(mailUrl(m, 'inbox'))}" target="_blank" rel="noopener">
|
|
|
|
|
<span class="ic" style="background:${m.color}">${icon('mail')}</span>
|
|
|
|
|
<span class="grow"><b>${esc(m.name)}</b><small>Inbox</small></span>${icon('chev', 'chev')}</a>`).join('')}</div>
|
|
|
|
|
<h2>Compose</h2><div class="list">${MAIL.map(m => `
|
|
|
|
|
<a class="row" href="${esc(mailUrl(m, 'compose'))}" target="_blank" rel="noopener">
|
|
|
|
|
<span class="ic" style="background:${m.color}">${icon('edit')}</span>
|
|
|
|
|
<span class="grow"><b>New message</b><small>from ${esc(m.name)}</small></span>${icon('chev', 'chev')}</a>`).join('')}</div></div>`;
|
|
|
|
|
// Mail: with WEBMAIL_URL set, your own webmail (which you can allow to be framed) opens inside the app and keeps running between tabs.
|
|
|
|
|
// The provider shortcuts (Gmail, Outlook, ...) stay one tap away, because those sites refuse to be framed.
|
|
|
|
|
function viewMail(args = []) {
|
|
|
|
|
if (cfg.webmail && args[0] !== 'accounts') return viewWebmail();
|
|
|
|
|
return viewMailCards();
|
|
|
|
|
}
|
|
|
|
|
function viewWebmail() {
|
|
|
|
|
const old = $('#audiowrap iframe[data-k="mail"]');
|
|
|
|
|
$('#view').innerHTML = `<div class="abbar"><span class="grow"><b>Mail</b><small id="abst">${old ? '' : 'Opening…'}</small></span>
|
|
|
|
|
<a class="ibtn" href="#/mail/accounts" aria-label="Gmail, Outlook and other shortcuts" title="Gmail, Outlook and other shortcuts">${icon('mail')}</a>
|
|
|
|
|
<button class="ibtn" id="ar" aria-label="Reload" title="Reload">${icon('refresh')}</button>
|
|
|
|
|
<button class="ibtn" id="af" aria-label="Full screen" title="Full screen">${icon('full')}</button>
|
|
|
|
|
<a class="ibtn" href="${esc(cfg.webmail)}" target="_blank" rel="noopener" aria-label="Open in new tab" title="Open in new tab">${icon('ext')}</a></div>`;
|
|
|
|
|
const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; };
|
|
|
|
|
const open = fresh => {
|
|
|
|
|
setSt('Opening…'); let f = $('#audiowrap iframe[data-k="mail"]'); if (f) f.remove();
|
|
|
|
|
showFrame('mail', cfg.webmail + (fresh ? (cfg.webmail.includes('?') ? '&' : '?') + '_=' + Date.now() : ''), 'Mail', 'fullscreen; clipboard-write; clipboard-read');
|
|
|
|
|
f = $('#audiowrap iframe[data-k="mail"]'); let done = false;
|
|
|
|
|
f.addEventListener('load', () => { done = true; setSt(''); });
|
|
|
|
|
setTimeout(() => { if (!done) setSt('Still loading… if it says it refused to connect, the webmail must allow framing (see the README)'); }, 10000);
|
|
|
|
|
};
|
|
|
|
|
if (!old) open(false); else { showFrame('mail', '', 'Mail', ''); setSt(''); }
|
|
|
|
|
$('#ar').onclick = () => open(true);
|
|
|
|
|
$('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="mail"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); };
|
|
|
|
|
}
|
|
|
|
|
function viewMailCards() {
|
|
|
|
|
const hints = mailHints(), on = mailOn(), shown = MAIL.filter(m => on.includes(m.id)), anyHint = shown.some(m => hints[m.id]);
|
|
|
|
|
$('#view').innerHTML = `<div class="wrap">${cfg.webmail ? `<div class="bar" style="margin-top:0"><a class="btn ghost" href="#/mail">${icon('back')}Webmail</a></div>` : ''}<h1>Mail</h1>
|
|
|
|
|
<p class="sub">Each account opens in its own webmail tab. Nothing is stored here.</p>
|
|
|
|
|
${shown.length ? '' : '<p class="empty">No mail apps chosen. Pick the ones you use in <a href="#/settings" style="color:var(--accent)">Settings</a>.</p>'}<div class="mailgrid">${shown.map(m => `<div class="mcard" style="--mc:${m.color}">
|
|
|
|
|
<div class="mhd"><span class="mbadge">${icon('mail')}</span><span class="grow"><b>${esc(m.name)}</b><small>${esc(hints[m.id] || (m.id === 'icloud' ? 'Opens iCloud Mail' : 'No address saved'))}</small></span></div>
|
|
|
|
|
<div class="mact"><a class="btn" href="${esc(mailUrl(m, 'inbox'))}" target="_blank" rel="noopener">${icon('mail')}Inbox</a>
|
|
|
|
|
<a class="btn ghost" href="${esc(mailUrl(m, 'compose'))}" target="_blank" rel="noopener">${icon('edit')}Compose</a></div></div>`).join('')}</div>
|
|
|
|
|
${anyHint || !shown.length ? '' : '<p class="sub" style="margin-top:14px">Tip: save your address for each account in <a href="#/settings" style="color:var(--accent)">Settings</a> so the right mailbox opens.</p>'}</div>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join('');
|
|
|
|
@@ -748,7 +812,7 @@ async function officeLocation(ask) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pick a library and folder in Seafile (same structure as the Files tab). Resolves null if cancelled.
|
|
|
|
|
function chooseFolder(title, start) {
|
|
|
|
|
function chooseFolder(title, start, opts = {}) {
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
let repo = start ? start.repo : null, repoName = start ? start.repoName : '', dir = start ? start.dir : '/', done = false;
|
|
|
|
|
const el = openSheet(title, '', '<div id="cf"></div>');
|
|
|
|
@@ -763,14 +827,23 @@ function chooseFolder(title, start) {
|
|
|
|
|
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button></div>`;
|
|
|
|
|
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; repoName = repos.find(r => r.id === repo).name; dir = '/'; draw(); });
|
|
|
|
|
} else {
|
|
|
|
|
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).filter(x => x.type === 'dir').sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
const hidden = opts.hide ? opts.hide(repo, dir) : [], blocked = opts.blocked ? opts.blocked(repo, dir) : false;
|
|
|
|
|
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).filter(x => x.type === 'dir' && !hidden.includes(x.name)).sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
box.innerHTML = `<p class="sub" style="word-break:break-all">${esc(locLabel({ repoName, dir }))}</p><div class="list" style="background:var(--panel2);border:0;max-height:45dvh;overflow:auto">
|
|
|
|
|
<button class="row" data-up="1">${icon('back')}<span class="grow"><b>${dir === '/' ? 'All libraries' : 'Up'}</b></span></button>
|
|
|
|
|
${subs.map(x => `<button class="row" data-d="${esc(x.name)}">${icon('files')}<span class="grow"><b>${esc(x.name)}</b></span>${icon('chev', 'chev')}</button>`).join('')}</div>
|
|
|
|
|
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="cs" style="flex:1;justify-content:center">Save here</button></div>`;
|
|
|
|
|
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button>${opts.newFolder ? `<button class="btn ghost" id="cn" aria-label="New folder">${icon('newdir')}</button>` : ''}<button class="btn" id="cs" style="flex:1;justify-content:center" ${blocked ? 'disabled' : ''}>${esc(opts.okLabel || 'Save here')}</button></div>`;
|
|
|
|
|
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = joinP(dir, b.dataset.d); draw(); });
|
|
|
|
|
box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); };
|
|
|
|
|
box.querySelector('#cs').onclick = () => finish({ repo, repoName, dir });
|
|
|
|
|
const cn = box.querySelector('#cn'); if (cn) cn.onclick = () => {
|
|
|
|
|
cn.disabled = true; const f = document.createElement('form'); f.style.marginTop = '10px';
|
|
|
|
|
f.innerHTML = '<input type="text" id="cmn" placeholder="Folder name" autocomplete="off" autocapitalize="off" required><div class="err" id="cme" style="margin-top:6px"></div>';
|
|
|
|
|
box.insertBefore(f, box.querySelector('.bar')); f.querySelector('input').focus();
|
|
|
|
|
f.onsubmit = async ev => { ev.preventDefault(); const n = f.querySelector('input').value.trim(), bad = badName(n, subs); if (bad) { f.querySelector('#cme').textContent = bad; return; }
|
|
|
|
|
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(joinP(dir, n))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'mkdir' }) }); dir = joinP(dir, n); draw(); }
|
|
|
|
|
catch (e) { f.querySelector('#cme').textContent = e.message; } };
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
box.querySelector('#cx').onclick = () => finish(null);
|
|
|
|
|
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
|
|
|
@@ -1052,9 +1125,9 @@ function setShuffle(on) {
|
|
|
|
|
if (on) { pl.orig = [...pl.q]; pl.q = [...pl.q.slice(0, pl.i + 1), ...shuffled(pl.q.slice(pl.i + 1))]; }
|
|
|
|
|
else if (pl.orig) { const have = new Set(pl.q.map(x => x.key)), keep = pl.orig.filter(x => have.has(x.key)), known = new Set(keep.map(x => x.key));
|
|
|
|
|
pl.q = [...keep, ...pl.q.filter(x => !known.has(x.key))]; pl.i = Math.max(0, pl.q.findIndex(x => x.key === t.key)); pl.orig = null; }
|
|
|
|
|
pl.shuffle = on; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); saveState(); tcKick(); updateNow();
|
|
|
|
|
pl.shuffle = on; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); saveState(); tcKick(); updateNow(); modeUI();
|
|
|
|
|
}
|
|
|
|
|
function setRepeat(r) { pl.repeat = r; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); updateNow(); }
|
|
|
|
|
function setRepeat(r) { pl.repeat = r; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); updateNow(); modeUI(); }
|
|
|
|
|
const srcOf = t => (pl.alt.has(t.key) ? altSrc(t) : t.src);
|
|
|
|
|
// Some files a browser cannot decode directly; Plex can convert those to MP3 on the fly.
|
|
|
|
|
const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSearchParams({ path: '/library/metadata/' + t.key, mediaIndex: 0, partIndex: 0, protocol: 'http', directPlay: 0, directStream: 0, audioCodec: 'mp3', maxAudioBitrate: 320 });
|
|
|
|
@@ -1202,20 +1275,47 @@ function markPlaying() {
|
|
|
|
|
function renderMini() {
|
|
|
|
|
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
|
|
|
|
|
el.hidden = false;
|
|
|
|
|
el.innerHTML = `<div class="mini"><div class="bar2"><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
|
|
|
|
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button>
|
|
|
|
|
<button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button></div>`;
|
|
|
|
|
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mo').onclick = openNow;
|
|
|
|
|
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
|
|
|
|
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
|
|
|
|
|
<button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
|
|
|
|
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
|
|
|
|
|
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
|
|
|
|
|
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
|
|
|
|
|
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
|
|
|
|
$('#mx').onclick = stopPlayer;
|
|
|
|
|
// tap or drag along the thin bar to seek
|
|
|
|
|
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
|
|
|
|
|
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
|
|
|
|
|
// swipe the track area: left for next, right for previous
|
|
|
|
|
const mini = el.querySelector('.mini'); let x0 = null, y0 = 0;
|
|
|
|
|
mini.onpointerdown = e => { if (e.target.closest('button:not(.meta), #mseek')) return; x0 = e.clientX; y0 = e.clientY; miniSwiped = false; };
|
|
|
|
|
mini.onpointerup = e => { if (x0 === null) return; const dx = e.clientX - x0, dy = e.clientY - y0; x0 = null;
|
|
|
|
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 2) { miniSwiped = true; setTimeout(() => { miniSwiped = false; }, 300); dx < 0 ? next() : prev(); } };
|
|
|
|
|
mini.onpointercancel = () => { x0 = null; };
|
|
|
|
|
modeUI(); syncUI();
|
|
|
|
|
}
|
|
|
|
|
let miniSwiped = false;
|
|
|
|
|
function stopPlayer() {
|
|
|
|
|
pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {}
|
|
|
|
|
if ('mediaSession' in navigator) { navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; }
|
|
|
|
|
tcKick(); saveState(); renderMini(); closeSheet(); markPlaying();
|
|
|
|
|
}
|
|
|
|
|
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
|
|
|
|
|
function modeUI() {
|
|
|
|
|
document.querySelectorAll('#ms, #nps').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
|
|
|
|
|
document.querySelectorAll('#mr, #npr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
|
|
|
|
|
}
|
|
|
|
|
function syncUI() {
|
|
|
|
|
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
|
|
|
|
const ic = icon(a.paused ? 'play' : 'pause');
|
|
|
|
|
const mt = $('#mt'); if (mt && mt.dataset.s !== String(a.paused)) { mt.innerHTML = ic; mt.dataset.s = a.paused; }
|
|
|
|
|
const state = pl.buf && !a.paused ? 'buf' : String(a.paused), ic = state === 'buf' ? icon('spin') : icon(a.paused ? 'play' : 'pause');
|
|
|
|
|
const mt = $('#mt'); if (mt && mt.dataset.s !== state) { mt.innerHTML = ic; mt.dataset.s = state; }
|
|
|
|
|
const bf = $('#mbuf'); if (bf) { let e = 0; try { for (let i = 0; i < a.buffered.length; i++) if (a.buffered.start(i) <= a.currentTime + 1) e = Math.max(e, a.buffered.end(i)); } catch {} bf.style.width = d ? Math.min(100, (e / d) * 100) + '%' : '0'; }
|
|
|
|
|
const tm = $('#mtm'); if (tm) { const v = fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
|
|
|
|
|
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
|
|
|
|
|
const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
|
|
|
|
|
const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
|
|
|
|
|
const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
|
|
|
|
|
const nt = $('#npt'); if (nt && nt.dataset.s !== String(a.paused)) { nt.innerHTML = ic; nt.dataset.s = a.paused; }
|
|
|
|
|
const nt = $('#npt'); if (nt && nt.dataset.s !== state) { nt.innerHTML = ic; nt.dataset.s = state; }
|
|
|
|
|
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
|
|
|
|
|
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
|
|
|
|
|
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
|
|
|
|
@@ -1226,9 +1326,7 @@ function updateNow() {
|
|
|
|
|
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
|
|
|
|
|
const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
|
|
|
|
|
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
|
|
|
|
|
const sh = $('#nps'), rp = $('#npr');
|
|
|
|
|
if (sh) { sh.classList.toggle('on', pl.shuffle); sh.setAttribute('aria-pressed', pl.shuffle); }
|
|
|
|
|
if (rp) { rp.classList.toggle('on', pl.repeat !== 'off'); rp.dataset.r = pl.repeat === 'one' ? '1' : ''; rp.setAttribute('aria-label', 'Repeat: ' + pl.repeat); }
|
|
|
|
|
modeUI();
|
|
|
|
|
}
|
|
|
|
|
function openNow() {
|
|
|
|
|
const t = cur(); if (!t) return;
|
|
|
|
@@ -1328,11 +1426,12 @@ function viewSettings() {
|
|
|
|
|
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
|
|
|
|
|
</div>
|
|
|
|
|
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
|
|
|
|
|
<div id="sec-mail"><h2>Mail accounts</h2><p class="sub">Your address for each account, so its inbox and new-message buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.</p>
|
|
|
|
|
<div class="card" style="max-width:none">${MAIL.filter(m => m.id !== 'icloud').map(m => `<label for="h-${m.id}">${esc(m.name)}</label>
|
|
|
|
|
<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}">`).join('')}
|
|
|
|
|
<p class="sub" style="margin-top:12px">iCloud Mail can't take a hint; use Keychain autofill or stay signed in.</p></div>
|
|
|
|
|
</div><div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.</p>
|
|
|
|
|
<div id="sec-mail"><h2>Mail apps</h2><p class="sub">Choose the mail apps you use and add your address for each, so its inbox and compose buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.</p>
|
|
|
|
|
<div class="card" style="max-width:none">${MAIL.map(m => `<div class="macct"><label class="chk" style="margin:${m.id === MAIL[0].id ? 0 : 14}px 0 0"><input type="checkbox" data-on="${m.id}" ${mailOn().includes(m.id) ? 'checked' : ''}><span>${esc(m.name)}</span></label>
|
|
|
|
|
${m.id === 'icloud' ? '<small class="sub" style="display:block;margin:2px 0 0 32px">iCloud Mail can\'t take a hint; use Keychain autofill or stay signed in.</small>'
|
|
|
|
|
: `<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}" style="margin-top:6px" ${mailOn().includes(m.id) ? '' : 'hidden'}>`}</div>`).join('')}
|
|
|
|
|
</div>
|
|
|
|
|
</div><div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.</p>
|
|
|
|
|
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
|
|
|
|
|
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
|
|
|
|
|
<label for="ab-p">Password</label>
|
|
|
|
@@ -1343,6 +1442,10 @@ function viewSettings() {
|
|
|
|
|
hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
|
|
|
|
|
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
|
|
|
|
|
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
|
|
|
|
|
document.querySelectorAll('input[data-on]').forEach(c => c.onchange = () => {
|
|
|
|
|
const on = [...document.querySelectorAll('input[data-on]:checked')].map(x => x.dataset.on); store.set('mail_on', on);
|
|
|
|
|
const h = document.getElementById('h-' + c.dataset.on); if (h) h.hidden = !c.checked;
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll('input[data-id]').forEach(i => i.oninput = () => {
|
|
|
|
|
const h = mailHints(); const v = i.value.trim(); if (v) h[i.dataset.id] = v; else delete h[i.dataset.id]; store.set('mail_hints', h);
|
|
|
|
|
});
|
|
|
|
@@ -1427,7 +1530,7 @@ function viewFiles([repo, ...parts]) {
|
|
|
|
|
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>`;
|
|
|
|
|
loadLibs(repo);
|
|
|
|
|
libsP = loadLibs(repo);
|
|
|
|
|
if (repo) loadDir(repo, dir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1448,6 +1551,7 @@ function filesLogin(msg = '', needOtp = false) {
|
|
|
|
|
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>
|
|
|
|
@@ -1462,62 +1566,333 @@ async function loadLibs(active) {
|
|
|
|
|
} catch (e) { $('#libs').innerHTML = `<p class="err">${esc(e.message)}</p>`; if (!sf.token()) setTimeout(route, 800); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadDir(repo, dir) {
|
|
|
|
|
const main = $('#fmain');
|
|
|
|
|
// ---- 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' });
|
|
|
|
|
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); 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');
|
|
|
|
|
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 });
|
|
|
|
|
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', '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() || it.tries >= 3) throw new Error(it.file.name + ': ' + e.message);
|
|
|
|
|
await sleep(1500 * it.tries);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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>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>`;
|
|
|
|
|
$('#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 onclick="location.hash='#/files/${enc(repo)}'">Library</button>`].concat(segs.map((s, i) =>
|
|
|
|
|
`<span>/</span><button onclick="location.hash='${hash('/' + segs.slice(0, i + 1).join('/'))}'">${esc(s)}</button>`)).join('');
|
|
|
|
|
main.innerHTML = `<div class="bar">
|
|
|
|
|
<a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a>
|
|
|
|
|
<label class="btn ghost" style="margin:0;color:var(--text)">${icon('up')}Upload<input id="up" type="file" multiple hidden></label>
|
|
|
|
|
<button class="btn ghost" id="mk">${icon('newdir')}New…</button></div>
|
|
|
|
|
<div class="crumbs">${crumbs}</div><div id="items"><p class="empty">Loading…</p></div>`;
|
|
|
|
|
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 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="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>
|
|
|
|
|
<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 = 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>`;
|
|
|
|
|
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) });
|
|
|
|
|
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: { 'Content-Type': 'application/x-www-form-urlencoded' } }); loadDir(repo, dir); }
|
|
|
|
|
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); }
|
|
|
|
|
};
|
|
|
|
|
$('#mk').onclick = () => actionSheet('Create new', '', [
|
|
|
|
|
{ label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir')) },
|
|
|
|
|
...[['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);
|
|
|
|
|
loadDir(repo, dir);
|
|
|
|
|
if (x !== 'txt') openFile(repo, joinP(dir, name), name, dir);
|
|
|
|
|
} catch (e) { toast(e.message); }
|
|
|
|
|
}) })),
|
|
|
|
|
]);
|
|
|
|
|
$('#up').onchange = async e => {
|
|
|
|
|
const files = [...e.target.files]; if (!files.length) return;
|
|
|
|
|
try {
|
|
|
|
|
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
|
|
|
|
|
const u = new URL(link, cfg.seafile);
|
|
|
|
|
for (const f of files) {
|
|
|
|
|
toast('Uploading ' + f.name + '…');
|
|
|
|
|
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', f);
|
|
|
|
|
const r = await fetch('/sf' + u.pathname + '?ret-json=1', { method: 'POST', body: fd });
|
|
|
|
|
if (!r.ok) throw new Error('Upload failed: ' + f.name);
|
|
|
|
|
}
|
|
|
|
|
toast('Uploaded'); loadDir(repo, dir);
|
|
|
|
|
} catch (err) { toast(err.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(),
|
|
|
|
|
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)) })),
|
|
|
|
|
]),
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
|
|
|
|
|
items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
|
|
|
$('#items').innerHTML = items.length ? `<div class="list">${items.map((it, i) => it.type === 'dir'
|
|
|
|
|
? `<div class="row it"><a class="rmain" href="${hash(joinP(dir, it.name))}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(it.name)}</b><small>${fmtDate(it.mtime)}</small></span></a><button class="ibtn" data-i="${i}" aria-label="Actions for ${esc(it.name)}">${icon('more')}</button></div>`
|
|
|
|
|
: `<div class="row it"><button class="rmain" data-open="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(it.name)}</b><small>${fmtSize(it.size)} · ${fmtDate(it.mtime)}</small></span></button><button class="ibtn" data-i="${i}" aria-label="Actions for ${esc(it.name)}">${icon('more')}</button></div>`).join('')}</div>`
|
|
|
|
|
: '<p class="empty">This folder is empty</p>';
|
|
|
|
|
const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
|
|
|
|
|
$('#items').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => itemSheet(items[b.dataset.i]));
|
|
|
|
|
$('#items').querySelectorAll('button[data-open]').forEach(b => b.onclick = () => openFile(repo, joinP(dir, items[b.dataset.open].name), items[b.dataset.open].name, dir));
|
|
|
|
|
} catch (e) { $('#items').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
|
|
|
|
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(); if (!quiet) $('#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();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (v.tok !== tok || !document.contains(main)) return;
|
|
|
|
|
if (!sf.token()) return route();
|
|
|
|
|
$('#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);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---- Bottom sheet (dialog on tablet/desktop). Body content is built by the caller from trusted/escaped HTML.
|
|
|
|
|
function openSheet(title, sub, bodyHtml) {
|
|
|
|
@@ -1542,12 +1917,17 @@ function actionSheet(title, sub, actions) {
|
|
|
|
|
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); actions[b.dataset.i].run(); });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function textSheet(title, label, okLabel, onOk, initial = '') {
|
|
|
|
|
function textSheet(title, label, okLabel, onOk, initial = '', validate) {
|
|
|
|
|
const el = openSheet(title, '', `<form><label>${esc(label)}</label><input type="text" id="tv" autocapitalize="off" autocomplete="off" required value="${esc(initial)}">
|
|
|
|
|
<div class="err" id="te" style="margin-top:6px"></div>
|
|
|
|
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button>
|
|
|
|
|
<button class="btn" style="flex:1;justify-content:center">${esc(okLabel)}</button></div></form>`);
|
|
|
|
|
el.querySelector('#sx').onclick = closeSheet;
|
|
|
|
|
el.querySelector('form').onsubmit = e => { e.preventDefault(); const v = el.querySelector('#tv').value.trim(); if (v) { closeSheet(); onOk(v); } };
|
|
|
|
|
el.querySelector('form').onsubmit = e => {
|
|
|
|
|
e.preventDefault(); const v = el.querySelector('#tv').value.trim(); if (!v) return;
|
|
|
|
|
const bad = validate && validate(v); if (bad) { el.querySelector('#te').textContent = bad; return; }
|
|
|
|
|
closeSheet(); onOk(v);
|
|
|
|
|
};
|
|
|
|
|
setTimeout(() => { const t = el.querySelector('#tv'); t.focus(); const d = initial.lastIndexOf('.'); t.setSelectionRange(0, d > 0 ? d : initial.length); }, 50);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1605,65 +1985,28 @@ async function shareLink(repo, path, isDir) {
|
|
|
|
|
} catch (e) { toast(e.message); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renameItem(repo, path, name, dir, isDir) {
|
|
|
|
|
textSheet('Rename', 'New name', 'Rename', async n => {
|
|
|
|
|
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'); loadDir(repo, dir); }
|
|
|
|
|
catch (e) { toast(e.message); }
|
|
|
|
|
}, name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Folder picker for Move (same library).
|
|
|
|
|
function moveItem(repo, name, dir) {
|
|
|
|
|
let at = dir;
|
|
|
|
|
const el = openSheet('Move to…', name, '<div id="pf"></div>');
|
|
|
|
|
const draw = async () => {
|
|
|
|
|
const box = el.querySelector('#pf');
|
|
|
|
|
box.innerHTML = '<p class="empty">Loading…</p>';
|
|
|
|
|
try {
|
|
|
|
|
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(at)}`)).filter(x => x.type === 'dir' && joinP(at, x.name) !== joinP(dir, name));
|
|
|
|
|
box.innerHTML = `<p class="sub" style="word-break:break-all">${esc(at)}</p><div class="list" style="background:var(--panel2);border:0;max-height:40dvh;overflow:auto">
|
|
|
|
|
${at !== '/' ? `<button class="row" data-up="1">${icon('back')}<span class="grow"><b>Up</b></span></button>` : ''}
|
|
|
|
|
${subs.map(x => `<button class="row" data-d="${esc(x.name)}">${icon('files')}<span class="grow"><b>${esc(x.name)}</b></span>${icon('chev', 'chev')}</button>`).join('')}</div>
|
|
|
|
|
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="mv" style="flex:1;justify-content:center"${at === dir ? ' disabled' : ''}>Move here</button></div>`;
|
|
|
|
|
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { at = joinP(at, b.dataset.d); draw(); });
|
|
|
|
|
const up = box.querySelector('[data-up]'); if (up) up.onclick = () => { at = at.slice(0, at.lastIndexOf('/')) || '/'; draw(); };
|
|
|
|
|
box.querySelector('#sx').onclick = closeSheet;
|
|
|
|
|
box.querySelector('#mv').onclick = async () => {
|
|
|
|
|
try { await sf.req('/api/v2.1/repos/sync-batch-move-item/', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ src_repo_id: repo, src_parent_dir: dir, dst_repo_id: repo, dst_parent_dir: at, src_dirents: [name] }) });
|
|
|
|
|
closeSheet(); toast('Moved'); loadDir(repo, dir); } catch (e) { toast(e.message); }
|
|
|
|
|
};
|
|
|
|
|
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
|
|
|
|
};
|
|
|
|
|
draw();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fileSheet(repo, path, name, dir) {
|
|
|
|
|
const enc = encodeURIComponent, acts = [];
|
|
|
|
|
const enc = encodeURIComponent, acts = [], ro = libPerm[repo] === 'r', it = { name, type: 'file' };
|
|
|
|
|
if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) });
|
|
|
|
|
if (cfg.editor && EDIT_EXT.has(ext(name))) acts.push({ label: 'Edit here', icon: 'edit', run: () => { addRecent(repo, path, name); location.hash = '#/edit/' + enc(repo) + path.split('/').filter(Boolean).map(s => '/' + enc(s)).join(''); } });
|
|
|
|
|
if (cfg.editor && EDIT_EXT.has(ext(name)) && !ro) acts.push({ label: 'Edit here', icon: 'edit', run: () => { addRecent(repo, path, name); location.hash = '#/edit/' + enc(repo) + path.split('/').filter(Boolean).map(s => '/' + enc(s)).join(''); } });
|
|
|
|
|
if (OFFICE_EXT.has(ext(name))) acts.push({ label: 'Open in Seafile', icon: 'doc', run: () => window.open(`${cfg.seafile}/lib/${enc(repo)}/file${path.split('/').map(enc).join('/')}`, '_blank', 'noopener') });
|
|
|
|
|
acts.push({ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, false) });
|
|
|
|
|
acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
|
|
|
|
|
acts.push({ label: 'Move', icon: 'files', run: () => moveItem(repo, name, dir) });
|
|
|
|
|
acts.push({ label: 'Download', icon: 'dl', run: async () => {
|
|
|
|
|
try { const url = await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(path)}&reuse=1`); window.open(url, '_blank', 'noopener'); }
|
|
|
|
|
catch (e) { toast(e.message); } } });
|
|
|
|
|
acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () =>
|
|
|
|
|
confirmSheet('Delete file?', name, 'Delete', async () => {
|
|
|
|
|
try { await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(path)}`, { method: 'DELETE' }); toast('Deleted'); loadDir(repo, dir); }
|
|
|
|
|
catch (e) { toast(e.message); } }) });
|
|
|
|
|
acts.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, path) });
|
|
|
|
|
if (!ro) acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
|
|
|
|
|
if (!ro) acts.push({ label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) });
|
|
|
|
|
acts.push({ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) });
|
|
|
|
|
if (!ro) acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) });
|
|
|
|
|
actionSheet(name, '', acts);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dirSheet(repo, path, name, dir) {
|
|
|
|
|
const ro = libPerm[repo] === 'r', it = { name, type: '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: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) },
|
|
|
|
|
{ label: 'Move', icon: 'files', run: () => moveItem(repo, name, dir) },
|
|
|
|
|
{ label: 'Delete', icon: 'trash', danger: true, run: () => confirmSheet('Delete folder?', name + ' and everything in it', 'Delete', async () => {
|
|
|
|
|
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(path)}`, { method: 'DELETE' }); toast('Deleted'); loadDir(repo, dir); }
|
|
|
|
|
catch (e) { toast(e.message); } }) },
|
|
|
|
|
...(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]) }]),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|