diff --git a/public/app.js b/public/app.js index 4ba5b3d..aed773c 100644 --- a/public/app.js +++ b/public/app.js @@ -128,6 +128,7 @@ function route() { try { editor?.destroyEditor(); } catch {} editor = null; document.body.classList.toggle('editing', name === 'edit'); + document.body.classList.remove('inedit'); renderNav(name === 'edit' ? 'files' : name); routes[name](rest.map(decodeURIComponent)); $('#view').scrollTop = 0; @@ -318,26 +319,96 @@ function viewMail() { New messagefrom ${esc(m.name)}${icon('chev', 'chev')}`).join('')}`; } -function viewOffice() { - const docs = store.get('recents', []).filter(r => EDIT_EXT.has(ext(r.name)) && ext(r.name) !== 'txt').slice(0, 8); - $('#view').innerHTML = `

Office

Create a file in Seafile and edit it right here.

+const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join(''); + +function viewOffice(args = []) { + if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true }); + $('#view').innerHTML = `

Office

Create, open and edit documents without leaving this tab. Everything is saved in Seafile.

-

- ${docs.length ? `

Recent documents

${docs.map((r, i) => ``).join('')}
` : ''} - -

Editing server: ${esc(new URL(cfg.onlyoffice).host)}

`; +

${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}

+
+
Browse all files${sf.token() ? '' : 'Sign in'}
`; $('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l)); - $('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { const r = docs[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/'); }); - if (sf.token()) sf.req('/api2/repos/').then(repos => { - const r = repos.find(x => x.id === store.get('office_repo')) || (repos.length === 1 ? repos[0] : null); - const el = $('#olib'); if (!el) return; - el.innerHTML = r ? `New files go in ${esc(r.name)} · change` : 'You will be asked which library to use.'; - const c = $('#chl'); if (c) c.onclick = e => { e.preventDefault(); store.del('office_repo'); pickLibrary().then(() => viewOffice()).catch(() => {}); }; - }).catch(() => {}); + if (!sf.token()) return; + (async () => { + try { + const loc = await officeLocation(false), el = $('#olib'); if (!el) return; + if (!loc) { + el.textContent = 'Choose where your documents are saved in Seafile.'; + $('#odocs').innerHTML = '
'; + $('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); + return; + } + el.innerHTML = `Saved in ${esc(locLabel(loc))} · change`; + $('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); }; + const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`); + const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60); + if (!location.hash.startsWith('#/office') || !$('#odocs')) return; + $('#odocs').innerHTML = docs.length ? `

Documents

${docs.map((f, i) => { + const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b'; + return ``; }).join('')}
` + : '

No documents here yet. Create one above.

'; + $('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => { + const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name; + addRecent(loc.repo, path, f.name); + if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener'); + }); + } catch (e) { const el = $('#olib'); if (el) el.innerHTML = `${esc(e.message)}`; } + })(); +} + +const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / ')); +// The saved Office location, if it still exists (the library may have been deleted). +async function officeLocation(ask) { + const loc = store.get('office_loc'); + if (loc) { + let repos; + try { repos = await sf.req('/api2/repos/'); } catch { return loc; } // a network hiccup must not forget the choice + const r = repos.find(x => x.id === loc.repo); + if (r) { + try { await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}`); return { ...loc, repoName: r.name }; } + catch (e) { if (!/not found|404|exist/i.test(e.message)) return { ...loc, repoName: r.name }; } + } + store.del('office_loc'); // library or folder is really gone + } + if (!ask) return null; + const l = await chooseFolder('Save documents in…'); if (l) store.set('office_loc', l); return l; +} + +// Pick a library and folder in Seafile (same structure as the Files tab). Resolves null if cancelled. +function chooseFolder(title, start) { + return new Promise(resolve => { + let repo = start ? start.repo : null, repoName = start ? start.repoName : '', dir = start ? start.dir : '/', done = false; + const el = openSheet(title, '', '
'); + el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); }); + const finish = v => { done = true; closeSheet(); resolve(v); }; + const draw = async () => { + const box = el.querySelector('#cf'); box.innerHTML = '

Loading…

'; + try { + if (!repo) { + const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name)); + box.innerHTML = `

Choose a library

${repos.map(r => ``).join('') || '

No libraries. Create one in Files.

'}
+
`; + 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)); + box.innerHTML = `

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

+ + ${subs.map(x => ``).join('')}
+
`; + 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 }); + } + box.querySelector('#cx').onclick = () => finish(null); + } catch (e) { box.innerHTML = `

${esc(e.message)}

`; } + }; + draw(); + }); } // Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor). @@ -355,30 +426,35 @@ async function createFromTemplate(repo, dir, name, x) { return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken } -// Which library new Office files go in (remembered once chosen). -async function pickLibrary() { - const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r'); - if (!repos.length) throw new Error('No library to create files in. Make one in Files first.'); - const saved = repos.find(r => r.id === store.get('office_repo')); - if (saved) return saved; - if (repos.length === 1) { store.set('office_repo', repos[0].id); return repos[0]; } - return new Promise((ok, no) => actionSheet('Choose a library', 'New files will be created here', repos.map(r => ({ label: r.name, icon: 'files', run: () => { store.set('office_repo', r.id); ok(r); } }))) - || 0); -} - async function newOfficeDoc(x, label) { if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; } - let repo; - try { repo = await pickLibrary(); } catch (e) { return toast(e.message); } - textSheet('New ' + label, 'File name (in ' + repo.name + ')', 'Create', async n => { + let loc; + try { loc = await officeLocation(true); } catch (e) { return toast(e.message); } + if (!loc) return; + const el = openSheet('New ' + label, '', `
+ +
`); + const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); }; + showLoc(); + el.querySelector('#sx').onclick = closeSheet; + el.querySelector('#nl').onclick = async () => { + // Sheets don't stack, so close this one, choose, then reopen with the new location. + const picked = await chooseFolder('Save in…', loc); + if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label); + }; + setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50); + el.querySelector('form').onsubmit = async e => { + e.preventDefault(); + const n = el.querySelector('#nn').value.trim(); if (!n) return; const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x; + const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; try { - const name = await createFromTemplate(repo.id, '/', want, x), path = '/' + name; - addRecent(repo.id, path, name); - if (cfg.editor) location.hash = '#/edit/' + encodeURIComponent(repo.id) + '/' + encodeURIComponent(name); - else window.open(`${cfg.seafile}/lib/${encodeURIComponent(repo.id)}/file/${encodeURIComponent(name)}`, '_blank', 'noopener'); - } catch (e) { toast(e.message); } - }, 'Untitled'); + const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name); + addRecent(loc.repo, path, name); closeSheet(); + if (cfg.editor) location.hash = officeHash(loc.repo, path); + else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener'); + } catch (err) { btn.disabled = false; el.querySelector('#ne').textContent = err.message; } + }; } @@ -675,11 +751,13 @@ function viewSettings() { } // ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret) -async function viewEdit([repo, ...parts]) { +async function viewEdit([repo, ...rawParts], opts = {}) { + const parts = rawParts.filter(Boolean); const path = '/' + parts.join('/'), name = parts[parts.length - 1] || ''; - const back = '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join(''); + const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join(''); + document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor const v = $('#view'); - v.innerHTML = `
${icon('back')}Close${esc(name)}
+ v.innerHTML = `
${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}${esc(name)}

Opening…

`; if (!sf.token()) return (location.hash = '#/files'); const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } }; @@ -692,7 +770,7 @@ async function viewEdit([repo, ...parts]) { const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js'; s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s); }); - if (!location.hash.startsWith('#/edit/')) return; + if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return; $('#oomsg').remove(); conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) }; editor = new DocsAPI.DocEditor('oo', conf); @@ -851,7 +929,7 @@ function addRecent(repo, path, name) { // Default tap on a file: edit documents, preview media, otherwise show the actions. function openFile(repo, path, name, dir) { - if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').map(s => '/' + encodeURIComponent(s)).join(''); } + if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); } else if (previewKind(name)) previewFile(repo, path, name); else fileSheet(repo, path, name, dir); } @@ -919,7 +997,7 @@ function moveItem(repo, name, dir) { function fileSheet(repo, path, name, dir) { const enc = encodeURIComponent, acts = []; 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('/').map(s => '/' + enc(s)).join(''); } }); + 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 (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) }); diff --git a/public/style.css b/public/style.css index 766c08d..c6f40f8 100644 --- a/public/style.css +++ b/public/style.css @@ -191,3 +191,6 @@ input[type=search] { width: 100%; min-height: 44px; padding: 0 14px; border-radi .row .art { object-fit: cover; flex: none; background: var(--panel2); } .dot { width: 12px; height: 12px; border-radius: 50%; flex: none; } + +/* Editor inside the Office tab: keep the nav, drop the page padding so the editor fills the space */ +body.inedit #view { padding: 0; display: flex; flex-direction: column; }