diff --git a/README.md b/README.md index 0aceef7..a1a0820 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,17 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke Office files open in Seafile's own OnlyOffice integration. - **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile. - **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume). -- **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audio, Podcast, Podcast Admin, Mail, +- **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audiobooks, Podcast, Podcast Admin, Mail, Calendar, Tasks, Search; Home and Settings are always on), and give them a one-time invite code to create their own passkey. Everyone's tasks, NE Hub events, settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users//`). Limits are enforced by the server, not just hidden. You can change a person's tabs, sign them out everywhere, turn them off, or delete them and their data. An existing single-user install becomes the admin automatically on first start (the original files are left in place as a backup). -- **Files**: also rename, move, share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation. +- **Files**: a Finder/Explorer-style browser. Tap **Select** (or long-press on a phone, Cmd/Ctrl-click or Shift-click on a computer) to pick several items and + Move, Copy (to any folder in any library, with a New folder button in the picker) or Delete them together. Rename checks for clashes, sort by name/date/size/kind + (folders stay on top), filter a folder, right-click for the item menu, and keys: Ctrl/Cmd-A, Delete, F2, Enter, Esc. **Drag files or whole folders from your computer + onto the page** (or onto a folder in the list) to upload; uploads show progress, retry on a bad connection, and can be cancelled. **Drag items onto a folder or a + breadcrumb** to move them (hold Alt/Option to copy). **Drag a single file out to your desktop** to download it (Chrome and Edge only; browsers cannot drag out folders + or several files). Also share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation. - **Calendar**: month grid and agenda. Add, edit and delete events: - **NE Hub calendar**: events you create are stored in `/data/events.json` and published as a private subscribe link (Manage calendars -> copy link) so Outlook, Google or Apple Calendar can show them (read-only there). @@ -42,9 +47,9 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke - **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser. - **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks. - **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the queue (play next / add to queue) and position are remembered. -- **Audio**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons. +- **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons. Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks - the person may use the Audio tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page, + the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page, script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET` (and optionally `AUDIOBOOKS_SSO_URL`, default `/sso`) for short-lived signed links: `?token=.`, where `body` is base64url JSON `{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied. diff --git a/public/app.js b/public/app.js index 8b81d9b..47d7d14 100644 --- a/public/app.js +++ b/public/app.js @@ -62,6 +62,7 @@ const ICONS = { file: '', dl: '', audio: '', + sort: '', refresh: '', full: '', ext: '', @@ -70,6 +71,8 @@ const ICONS = { play: '', pause: '', next: '', + spin: '', + x: '', prev: '', repeat: '', shuffle: '', @@ -136,7 +139,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)'); @@ -164,7 +167,31 @@ 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; +// 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 = ``; + $('#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 +201,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 +229,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') : ''} @@ -748,7 +776,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, '', '
'); @@ -763,14 +791,23 @@ function chooseFolder(title, start) {
`; 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 = `

${esc(locLabel({ repoName, dir }))}

${subs.map(x => ``).join('')}
-
`; +
${opts.newFolder ? `` : ''}
`; 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 = '
'; + 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 = `

${esc(e.message)}

`; } @@ -1052,9 +1089,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 +1239,47 @@ function markPlaying() { function renderMini() { const t = cur(), el = $('#player'); if (!t) return (el.hidden = true); el.hidden = false; - el.innerHTML = `
- -
`; - $('#mt').onclick = toggle; $('#mn').onclick = next; $('#mo').onclick = openNow; + el.innerHTML = `
+ + + +
`; + $('#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 +1290,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; @@ -1332,7 +1394,7 @@ function viewSettings() {
${MAIL.filter(m => m.id !== 'icloud').map(m => ` `).join('')}

iCloud Mail can't take a hint; use Keychain autofill or stay signed in.

-

Audiobooks sign-in

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.

+

Audiobooks sign-in

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.

@@ -1427,7 +1489,7 @@ function viewFiles([repo, ...parts]) { const dir = '/' + parts.join('/'); $('#view').innerHTML = `

Files

Loading…

`; - loadLibs(repo); + libsP = loadLibs(repo); if (repo) loadDir(repo, dir); } @@ -1448,6 +1510,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 = `

Libraries

${repos.map(r => ` ${icon('files')} @@ -1462,62 +1525,333 @@ async function loadLibs(active) { } catch (e) { $('#libs').innerHTML = `

${esc(e.message)}

`; 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 = `
Uploading ${esc(live.length === 1 ? cur.file.name : live.length + ' files')}${pct}%
`; + $('#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 = `
${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed${esc(bad[0].err || '')}
`; + $('#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 = '

Loading…

'; + 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 = [``].concat(segs.map((s, i) => - `/`)).join(''); - main.innerHTML = `
- ${icon('back')}Libraries - -
-
${crumbs}

Loading…

`; + const crumbs = `` + segs.map((s, i) => `/`).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 = `
${crumbs}
+

Loading…

`; + const go = p => { location.hash = hash(p); }; + + const drawTop = () => { + const n = v.sel.size, one = n === 1 ? selItems()[0] : null; + $('#ftop').innerHTML = v.selecting + ? `
${n ? n + ' selected' : 'Select items'} +
+
${ro ? '' : ``} + + ${ro ? '' : ``} +
` + : `
${icon('back')}Libraries + ${ro ? '' : ``} + + + +
`; + }; + 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 `
+ ${v.selecting ? `` : ''} + + ${v.selecting ? '' : ``}
`; }; + box.innerHTML = list.length ? `
${list.map(row).join('')}
` + : `

${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}

`; + 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 ? `
${items.map((it, i) => it.type === 'dir' - ? `` - : `
`).join('')}
` - : '

This folder is empty

'; - 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 = `

${esc(e.message)}

`; } + 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 = '

Loading…

'; + 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 = `

${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}

`; + $('#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 +1876,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, '', `
+
`); 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 +1944,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, '
'); - const draw = async () => { - const box = el.querySelector('#pf'); - box.innerHTML = '

Loading…

'; - 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 = `

${esc(at)}

- ${at !== '/' ? `` : ''} - ${subs.map(x => ``).join('')}
-
`; - 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 = `

${esc(e.message)}

`; } - }; - 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]) }]), ]); } diff --git a/public/index.html b/public/index.html index 5d42441..acf0145 100644 --- a/public/index.html +++ b/public/index.html @@ -49,7 +49,7 @@
-
+
diff --git a/public/style.css b/public/style.css index 3030d46..337a94e 100644 --- a/public/style.css +++ b/public/style.css @@ -169,8 +169,19 @@ input[type=search] { width: 100%; min-height: 44px; padding: 0 14px; border-radi .ibtn:active { background: var(--panel2); } .ibtn.big { width: 64px; height: 64px; background: var(--accent); color: var(--accent-t); } .ibtn.big svg { width: 30px; height: 30px; } -.mini .bar2 { position: absolute; left: 0; right: 0; top: 0; height: 2px; background: var(--line); } -.mini .bar2 i { display: block; height: 100%; background: var(--accent); width: 0; } +.mini .bar2 { position: absolute; left: 0; right: 0; top: 0; height: 4px; background: var(--line); cursor: pointer; touch-action: none; } +.mini .bar2::before { content: ''; position: absolute; left: 0; right: 0; top: -10px; bottom: -8px; } /* a taller area to grab */ +.mini .bar2 u { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--muted); opacity: .45; } +.mini .bar2 i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); width: 0; } +.mini .mtime { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; display: none; } +.mini .wide-only { display: none; } +.mini .ibtn.on { color: var(--accent); } +.mini .ibtn { position: relative; flex: none; } +.mini .ibtn[data-r="1"]::after { content: '1'; position: absolute; font-size: 10px; font-weight: 700; right: 8px; bottom: 6px; } +.mini { touch-action: pan-y; } +@media (min-width: 768px) { .mini .mtime { display: block; } .mini .wide-only { display: grid; } .mini .bar2 { height: 5px; } } +svg .spin, .spin { transform-origin: 12px 12px; animation: sp .8s linear infinite; } +@keyframes sp { to { transform: rotate(360deg); } } .np { text-align: center; } .np .art { width: min(72vw, 300px); aspect-ratio: 1; border-radius: 16px; object-fit: cover; background: var(--panel2); margin: 8px auto 16px; display: block; } .np input[type=range] { width: 100%; accent-color: var(--accent); } @@ -260,3 +271,33 @@ input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user .np .ibtn.on { color: var(--accent); } .np .ibtn[data-r="1"]::after { content: '1'; position: absolute; font-size: 10px; font-weight: 700; margin: 0 0 -14px 14px; } .np .ibtn { position: relative; } + +/* Files: selection, drag and drop, upload progress */ +.selbar { position: sticky; top: 0; z-index: 3; background: var(--bg); padding-bottom: 4px; } +.selbar .bar { margin: 0 0 8px; align-items: center; } +.selbar .selcount { margin-right: auto; } +.selbar .bar { gap: 6px; } +.selbar .btn { padding-left: 10px; padding-right: 10px; } +.row.fr { cursor: default; } +.row.fr.sel { background: var(--panel2); } +.row .ck { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--line); display: grid; place-items: center; flex: none; margin-left: 4px; } +.row .ck.on { background: var(--accent); border-color: var(--accent); color: var(--accent-t); } +.row .ck svg { width: 14px; height: 14px; } +.dropt { outline: 2px dashed var(--accent); outline-offset: -2px; background: var(--panel2); } +#fmain.dropt { border-radius: 16px; } +#upbar { position: fixed; z-index: 15; left: 50%; transform: translateX(-50%); top: calc(var(--sat) + 10px); width: min(520px, calc(100vw - 24px)); display: flex; align-items: center; gap: 8px; + background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 10px 12px; box-shadow: 0 8px 28px rgba(0,0,0,.25); } +#upbar .upm { flex: 1; min-width: 0; position: relative; padding-bottom: 6px; } +#upbar .upm b, #upbar .upm small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#upbar .upm small { color: var(--muted); } +#upbar .upm i { position: absolute; left: 0; bottom: 0; height: 3px; border-radius: 2px; background: var(--accent); transition: width .2s; } +#fmain { min-height: 240px; } + +/* Back / Forward bar */ +#navbar { display: flex; align-items: center; gap: 2px; padding: calc(var(--sat) + 4px) calc(var(--sar) + 8px) 0 calc(var(--sal) + 8px); } +#navbar[hidden], body.editing #navbar { display: none; } +#navbar .ibtn { width: 40px; height: 40px; } +#navbar .ibtn:disabled { opacity: .3; cursor: default; } +#navbar #nvt { color: var(--muted); font-size: 13px; margin-left: 6px; } +body:not(.editing) #view { padding-top: 8px; } +@media (min-width: 768px) { #navbar { padding-left: 24px; } body:not(.editing) #view { padding-top: 12px; } } diff --git a/tools/player-test.js b/tools/player-test.js index a0bb190..c5398bd 100644 --- a/tools/player-test.js +++ b/tools/player-test.js @@ -7,7 +7,7 @@ class FakeAudio{constructor(){this.paused=true;this.currentTime=0;this.duration= addEventListener(e,f){(this._l[e]=this._l[e]||[]).push(f)} fire(e){(this._l[e]||[]).forEach(f=>f())} play(){this.paused=false;this.fire('play');return Promise.resolve()} pause(){if(!this.paused){this.paused=true;this.fire('pause')}} getAttribute(){return this.src||null} load(){}} const toasts=[];const store={d:{},get(k,d){return this.d[k]??d},set(k,v){this.d[k]=v},del(k){delete this.d[k]}}; -const el=()=>({classList:{toggle(){},add(){}},setAttribute(){},dataset:{},style:{},querySelector:()=>null,querySelectorAll:()=>[],set innerHTML(v){},set textContent(v){}}); +const el=()=>({classList:{toggle(){},add(){}},setAttribute(){},dataset:{},style:{},querySelector:()=>el(),querySelectorAll:()=>[],set innerHTML(v){},set textContent(v){}}); const ctx={Audio:FakeAudio,store,toast:m=>toasts.push(m),px:{art:()=>'x'},icon:()=>'',esc:s=>s,fmtDur:()=>'',$:()=>el(),document:{querySelectorAll:()=>[]}, navigator:{onLine:true},addEventListener:(e,f)=>{listeners[e]=f},setTimeout,clearTimeout,fetch:()=>new Promise(()=>{}),URL:{createObjectURL:()=>'blob:x',revokeObjectURL(){}}, Image:class{},MediaMetadata:class{},openSheet:()=>({}),closeSheet(){},actionSheet(){},Response,URLSearchParams,AbortController,Promise,Set,Map,Date,Math,saveStateHook:0,location:{hash:''}}; diff --git a/users.js b/users.js index c73b71e..7825085 100644 --- a/users.js +++ b/users.js @@ -13,7 +13,7 @@ fs.mkdirSync(DATA_DIR, { recursive: true }); // Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own). const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'calendar', 'tasks', 'search']; const ALWAYS = ['home', 'settings']; -const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audio', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' }; +const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' }; let cache = null; const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };