Office: search, kind filter, sorting, pinned and recent documents, saved list, upload and drag-and-drop, document menu; Movies & TV mini player keeps its controls visible
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f08443c38
commit
2bde697955
9 files changed
+147
-56
No files matched your search
+1
-1
@@ -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(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']);
|
||||
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']);
|
||||
const outbox = new Map(); let outTimer = null, warned = false;
|
||||
function flushSettings() {
|
||||
outTimer = null;
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
<script src="/audiobooks.js"></script>
|
||||
<script src="/music.js"></script>
|
||||
<script src="/video.js"></script>
|
||||
<script src="/office.js"></script>
|
||||
<script src="/player.js"></script>
|
||||
<script src="/lyrics-view.js"></script>
|
||||
<script src="/settings.js"></script>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
// Office: make, find and open documents, spreadsheets and presentations kept in Seafile (they open in OnlyOffice inside the app).
|
||||
// Search, a filter by kind, sorting, pinned and recently opened documents, a saved copy of the list that shows at once, and upload by button or by dropping files.
|
||||
const OFFICE_KINDS = {
|
||||
doc: { label: 'Documents', one: 'Document', color: '#3d6bf0', exts: ['docx', 'doc', 'odt', 'rtf'] },
|
||||
sheet: { label: 'Spreadsheets', one: 'Spreadsheet', color: '#2f9e6e', exts: ['xlsx', 'xls', 'ods', 'csv'] },
|
||||
slide: { label: 'Presentations', one: 'Presentation', color: '#e0742b', exts: ['pptx', 'ppt', 'odp'] },
|
||||
};
|
||||
const ofKind = n => Object.keys(OFFICE_KINDS).find(k => OFFICE_KINDS[k].exts.includes(ext(n))) || 'doc';
|
||||
const ofIsDoc = n => EDIT_EXT.has(ext(n)) && ext(n) !== 'txt';
|
||||
const ofState = { q: '', kind: 'all', sort: store.get('office_sort', 'mtime'), limit: 40, docs: [], loc: null };
|
||||
const ofKey = loc => 'oc:' + (ME ? ME.id : '') + ':' + loc.repo + ':' + loc.dir;
|
||||
const ofPins = () => { const p = store.get('office_pins', []); return Array.isArray(p) ? p : []; };
|
||||
const ofId = (repo, path) => repo + '|' + path;
|
||||
const ofPath = f => (f.parent_dir || '/').replace(/\/?$/, '/') + f.name;
|
||||
|
||||
function ofOpen(repo, path, name) {
|
||||
addRecent(repo, path, name);
|
||||
if (cfg.editor) location.hash = officeHash(repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(repo)}/file${encP(path)}`, '_blank', 'noopener');
|
||||
}
|
||||
function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `<span class="ic ofic" style="background:${k.color}">${icon('doc')}<em>${esc(ext(name).slice(0, 4).toUpperCase())}</em></span>`; }
|
||||
|
||||
function viewOffice(args = []) {
|
||||
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
|
||||
const signed = !!sf.token();
|
||||
$('#view').innerHTML = `<div class="wrap" id="ofwrap"><div class="ptop"><h1>Office</h1><span class="pspace"></span>${signed ? `<button class="btn ghost" id="ofup">${icon('up')}Upload</button><input type="file" id="ofupf" multiple hidden accept=".docx,.doc,.odt,.rtf,.xlsx,.xls,.ods,.csv,.pptx,.ppt,.odp">` : ''}<a class="btn ghost" href="#/files">${icon('files')}All files</a></div>
|
||||
<div class="ocreate">${Object.entries(OFFICE_KINDS).map(([k, o]) => `<button class="octile" data-k="${k}" style="--c:${o.color}"><span class="ic" style="background:${o.color}">${icon('plus')}</span><span><b>New ${o.one.toLowerCase()}</b><small>${o.exts[0].toUpperCase()}</small></span></button>`).join('')}</div>
|
||||
${cfg.editor ? '' : '<p class="sub" style="margin:8px 0 0">Editing inside the app is switched off on this server, so documents open in Seafile instead.</p>'}
|
||||
<p class="sub" id="olib" style="margin:12px 0 0">${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
|
||||
<div id="oflt" class="ofilters" hidden><input type="search" id="oq" placeholder="Search documents" autocomplete="off" aria-label="Search documents" value="${esc(ofState.q)}"><div class="sgrp" id="okinds" role="tablist" aria-label="Kind"></div><select id="osort" class="psel" aria-label="Sort"><option value="mtime">Newest first</option><option value="name">Name</option><option value="size">Largest first</option></select></div>
|
||||
<div id="odocs"></div>${signed ? '' : '<div class="bar"><a class="btn" href="#/files">Sign in</a></div>'}</div>`;
|
||||
$('#view').querySelectorAll('.octile').forEach(t => t.onclick = () => newOfficeDoc(OFFICE_KINDS[t.dataset.k].exts[0], OFFICE_KINDS[t.dataset.k].one.toLowerCase()));
|
||||
if (!signed) return;
|
||||
$('#ofup').onclick = () => $('#ofupf').click();
|
||||
$('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); };
|
||||
const wrap = $('#ofwrap');
|
||||
wrap.ondragover = e => { if ([...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); wrap.classList.add('dropt'); } };
|
||||
wrap.ondragleave = e => { if (!wrap.contains(e.relatedTarget)) wrap.classList.remove('dropt'); };
|
||||
wrap.ondrop = async e => { e.preventDefault(); wrap.classList.remove('dropt'); const files = (await droppedFiles(e.dataTransfer)).filter(f => ofIsDoc(f.file.name)); if (files.length) ofUpload(files); else toast('Drop Word, Excel or PowerPoint files here'); };
|
||||
$('#oq').oninput = e => { ofState.q = e.target.value; ofState.limit = 40; ofDraw(); };
|
||||
$('#osort').value = ofState.sort; $('#osort').onchange = e => { ofState.sort = e.target.value; store.set('office_sort', ofState.sort); ofDraw(); };
|
||||
ofLoad();
|
||||
}
|
||||
|
||||
async function ofUpload(entries) {
|
||||
const loc = ofState.loc || await officeLocation(true); if (!loc) return;
|
||||
await enqueueUploads(loc.repo, loc.dir, entries);
|
||||
const t = setInterval(() => { if (!upActive()) { clearInterval(t); if (location.hash.startsWith('#/office') && $('#odocs')) ofLoad(true); } }, 1200);
|
||||
setTimeout(() => clearInterval(t), 10 * 60e3);
|
||||
}
|
||||
|
||||
async function ofLoad(quiet) {
|
||||
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 = '<div class="bar"><button class="btn" id="chl2">Choose location</button></div>';
|
||||
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); return;
|
||||
}
|
||||
ofState.loc = loc;
|
||||
el.innerHTML = `Saved in <b>${esc(locLabel(loc))}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>`;
|
||||
$('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); ofState.docs = []; viewOffice(); } }); };
|
||||
let saved = null; try { saved = JSON.parse(localStorage.getItem(ofKey(loc))); } catch {}
|
||||
if (!quiet && saved && saved.docs) { ofState.docs = saved.docs; ofDraw(); } else if (!quiet) ofDraw(true);
|
||||
const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`);
|
||||
if (!location.hash.startsWith('#/office') || !$('#odocs')) return;
|
||||
ofState.docs = all.filter(f => f.type === 'file' && ofIsDoc(f.name)).map(f => ({ name: f.name, parent_dir: f.parent_dir || '/', mtime: f.mtime, size: f.size || 0 }));
|
||||
try { localStorage.setItem(ofKey(loc), JSON.stringify({ t: Date.now(), docs: ofState.docs.slice(0, 400) })); } catch {}
|
||||
ofDraw();
|
||||
} catch (e) {
|
||||
const el = $('#olib'); if (!el) return;
|
||||
if (ofState.docs.length) { el.innerHTML += ` · <span class="err">${esc(navigator.onLine ? e.message : 'offline')} (showing the saved list)</span>`; ofDraw(); }
|
||||
else { el.innerHTML = `<span class="err">${esc(e.message)}</span> <a href="#" id="ofretry" style="color:var(--accent)">Try again</a>`; $('#ofretry').onclick = ev => { ev.preventDefault(); ofLoad(); }; }
|
||||
}
|
||||
}
|
||||
|
||||
function ofDraw(skeleton) {
|
||||
const box = $('#odocs'), loc = ofState.loc; if (!box || !loc) return;
|
||||
$('#oflt').hidden = !ofState.docs.length;
|
||||
const pins = new Set(ofPins()), q = ofState.q.trim().toLowerCase(), counts = { all: ofState.docs.length };
|
||||
for (const k of Object.keys(OFFICE_KINDS)) counts[k] = ofState.docs.filter(d => ofKind(d.name) === k).length;
|
||||
$('#okinds').innerHTML = [['all', 'All']].concat(Object.entries(OFFICE_KINDS).map(([k, o]) => [k, o.label])).filter(([k]) => k === 'all' || counts[k]).map(([k, l]) => `<a href="#" role="tab" data-k="${k}" aria-selected="${k === ofState.kind}" class="${k === ofState.kind ? 'on' : ''}">${l}</a>`).join('');
|
||||
$('#okinds').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); ofState.kind = a.dataset.k; ofState.limit = 40; ofDraw(); });
|
||||
const cmp = { mtime: (a, b) => b.mtime - a.mtime, name: (a, b) => collator.compare(a.name, b.name), size: (a, b) => b.size - a.size }[ofState.sort] || ((a, b) => b.mtime - a.mtime);
|
||||
const match = d => (ofState.kind === 'all' || ofKind(d.name) === ofState.kind) && (!q || (d.name + ' ' + d.parent_dir).toLowerCase().includes(q));
|
||||
const list = ofState.docs.filter(match).sort(cmp), shown = list.slice(0, ofState.limit), pinned = shown.filter(d => pins.has(ofId(loc.repo, ofPath(d))));
|
||||
const recents = !q && ofState.kind === 'all' ? store.get('recents', []).filter(r => r.repo === loc.repo && ofIsDoc(r.name)).slice(0, 6) : [];
|
||||
const row = d => { const path = ofPath(d), on = pins.has(ofId(loc.repo, path)), i = shown.indexOf(d);
|
||||
return `<div class="row it orow"><button class="rmain" data-o="${i}">${ofIcon(d.name)}<span class="grow"><b>${esc(d.name)}</b><small>${esc((d.parent_dir === '/' ? '' : d.parent_dir.replace(/^\//, '') + ' · ') + fmtDate(d.mtime) + (d.size ? ' · ' + fmtSize(d.size) : ''))}</small></span></button>
|
||||
<button class="ibtn" data-pin="${i}" aria-label="${on ? 'Unpin' : 'Pin'} ${esc(d.name)}" title="${on ? 'Unpin' : 'Pin to the top'}" style="${on ? 'color:var(--accent)' : ''}">${icon(on ? 'check' : 'plus')}</button><button class="ibtn" data-m="${i}" aria-label="Actions for ${esc(d.name)}">${icon('more')}</button></div>`; };
|
||||
box.innerHTML = skeleton ? '<p class="empty">Loading your documents…</p>' : (recents.length ? `<h2>Recently opened</h2><div class="orecent">${recents.map((r, i) => `<button class="octile rc" data-r="${i}" style="--c:${OFFICE_KINDS[ofKind(r.name)].color}">${ofIcon(r.name)}<span><b>${esc(r.name)}</b><small>${esc(new Date(r.t).toLocaleDateString([], { month: 'short', day: 'numeric' }))}</small></span></button>`).join('')}</div>` : '')
|
||||
+ (pinned.length && !q ? `<h2>Pinned</h2><div class="list">${pinned.map(row).join('')}</div>` : '')
|
||||
+ (list.length ? `<h2>${q ? 'Results' : ofState.kind === 'all' ? 'All documents' : OFFICE_KINDS[ofState.kind].label} <small style="color:var(--muted);font-weight:400">${list.length}</small></h2><div class="list">${shown.map(row).join('')}</div>${list.length > shown.length ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="ofmore">Show ${Math.min(40, list.length - shown.length)} more (${list.length - shown.length} not shown)</button></div>` : ''}`
|
||||
: `<p class="empty">${ofState.docs.length ? 'Nothing matches.' : 'No documents here yet. Create one above, or drop files on this page.'}</p>`);
|
||||
box.querySelectorAll('[data-o]').forEach(b => b.onclick = () => { const d = shown[+b.dataset.o]; if (d) ofOpen(loc.repo, ofPath(d), d.name); });
|
||||
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const d = shown[+b.dataset.m]; if (d) fileSheet(loc.repo, ofPath(d), d.name, d.parent_dir === '/' ? '/' : d.parent_dir.replace(/\/$/, '')); });
|
||||
box.querySelectorAll('[data-pin]').forEach(b => b.onclick = () => { const d = shown[+b.dataset.pin]; if (!d) return; const id = ofId(loc.repo, ofPath(d)), cur = ofPins(); store.set('office_pins', cur.includes(id) ? cur.filter(x => x !== id) : [id, ...cur].slice(0, 50)); ofDraw(); });
|
||||
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { const r = recents[+b.dataset.r]; if (r) ofOpen(r.repo, r.path, r.name); });
|
||||
const more = $('#ofmore'); if (more) more.onclick = () => { ofState.limit += 40; ofDraw(); };
|
||||
}
|
||||
@@ -56,6 +56,7 @@ function openPalette() {
|
||||
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
|
||||
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
|
||||
if (allowed('mail') && cfg.mail) out.push({ label: 'New email', hint: 'Create', ic: 'mail', run: () => { location.hash = '#/mail'; setTimeout(() => mailCompose(), 300); }, kw: 'compose write message' });
|
||||
if (allowed('office')) for (const [k, o] of Object.entries(OFFICE_KINDS)) out.push({ label: 'New ' + o.one.toLowerCase(), hint: 'Create', ic: 'doc', run: () => { location.hash = '#/office'; setTimeout(() => newOfficeDoc(o.exts[0], o.one.toLowerCase()), 250); }, kw: 'office word excel powerpoint ' + o.exts.join(' ') });
|
||||
if (allowed('links')) out.push({ label: 'New link', hint: 'Create', ic: 'plus', run: () => linkSheet(), kw: 'add bookmark' });
|
||||
out.push({ label: 'Hide or show the menu', hint: 'View', ic: 'menu', run: () => setNav({ hidden: !navPref().hidden }), kw: 'sidebar' });
|
||||
out.push({ label: 'Refresh the app', hint: 'App', ic: 'refresh', run: () => location.reload(), kw: 'reload update' });
|
||||
|
||||
+27
-7
@@ -655,13 +655,33 @@ body.nav-mini .ngrp span { display: none; }
|
||||
@media (max-width: 700px) { .vtop .vtc { display: none; } .vnav .sgrp { margin-left: 0; } }
|
||||
|
||||
/* Video mini player */
|
||||
#vmini { position: fixed; right: 14px; bottom: calc(var(--nav-h) + var(--sab) + 76px); width: 300px; max-width: calc(100vw - 28px); z-index: 45; border-radius: 12px; overflow: hidden; background: #000; box-shadow: 0 8px 28px rgba(0, 0, 0, .45); border: 1px solid var(--line); }
|
||||
#vmini { position: fixed; right: 14px; bottom: calc(var(--nav-h) + var(--sab) + 76px); width: 340px; max-width: calc(100vw - 28px); z-index: 45; border-radius: 12px; overflow: hidden; background: #000; box-shadow: 0 8px 28px rgba(0, 0, 0, .45); border: 1px solid var(--line); }
|
||||
@media (min-width: 768px) { #vmini { bottom: 84px; } }
|
||||
#vmini[hidden] { display: none; }
|
||||
.vmslot { cursor: pointer; } .vmslot video { width: 100%; display: block; aspect-ratio: 16 / 9; background: #000; }
|
||||
.vmover { position: absolute; left: 0; right: 0; top: 0; padding: 6px 8px; display: flex; align-items: center; gap: 8px; color: #fff; background: linear-gradient(rgba(0, 0, 0, .75), transparent); opacity: 0; transition: opacity .2s; }
|
||||
#vmini:hover .vmover, #vmini:focus-within .vmover { opacity: 1; }
|
||||
@media (hover: none) { .vmover { opacity: 1; } }
|
||||
.vmtitle { flex: 1; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vmbtns { display: flex; gap: 2px; } .vmbtns button { border: 0; background: rgba(255, 255, 255, .15); color: #fff; width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; cursor: pointer; } .vmbtns button svg { width: 18px; height: 18px; }
|
||||
.vmprog { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255, 255, 255, .25); display: block; } .vmprog b { display: block; height: 100%; background: var(--accent); }
|
||||
|
||||
/* Office */
|
||||
.ocreate { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-top: 4px; }
|
||||
.octile { display: flex; align-items: center; gap: 12px; text-align: left; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--panel); min-width: 0; }
|
||||
.octile:hover { border-color: var(--c, var(--accent)); }
|
||||
.octile b, .octile small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .octile small { color: var(--muted); }
|
||||
.octile > span:last-child { min-width: 0; }
|
||||
.ofic { position: relative; } .ofic em { position: absolute; bottom: 2px; font-style: normal; font-size: 8px; font-weight: 700; letter-spacing: .02em; color: #fff; }
|
||||
.ofic svg { margin-top: -4px; }
|
||||
.ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; }
|
||||
.ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; }
|
||||
.orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||
.octile.rc { padding: 10px 12px; }
|
||||
.orow .rmain { min-width: 0; }
|
||||
#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; }
|
||||
|
||||
.vmctl { background: var(--panel); color: var(--text); padding: 6px 10px 8px; }
|
||||
.vmhead { display: flex; align-items: center; gap: 4px; }
|
||||
.vmtitle { flex: 1; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vmctl input[type=range] { width: 100%; margin: 2px 0; accent-color: var(--accent); cursor: pointer; }
|
||||
.vmrow { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.vmmid { display: flex; align-items: center; gap: 2px; }
|
||||
.vmb { border: 0; background: transparent; color: var(--text); min-width: 34px; height: 34px; border-radius: 8px; display: inline-grid; place-items: center; cursor: pointer; }
|
||||
.vmb:hover { background: var(--panel2); } .vmb:disabled { opacity: .35; cursor: default; } .vmb[hidden] { display: none; }
|
||||
.vmb svg { width: 20px; height: 20px; } .vmb.big { width: 40px; height: 40px; background: var(--accent); color: var(--accent-t); border-radius: 50%; } .vmb.big svg { width: 22px; height: 22px; }
|
||||
.vmb .vskip em { font-size: 8px; top: 6px; }
|
||||
@@ -111,46 +111,6 @@ async function renderTasksCard(el) {
|
||||
|
||||
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 = `<div class="wrap"><h1>Office</h1><p class="sub">Create, open and edit documents without leaving this tab. Everything is saved in Seafile.</p>
|
||||
<div class="grid">
|
||||
<button class="tile" data-x="docx" data-l="document"><span class="ic" style="background:#3d6bf0">${icon('doc')}</span><b>Document</b><small>Word (.docx)</small></button>
|
||||
<button class="tile" data-x="xlsx" data-l="spreadsheet"><span class="ic" style="background:#2f9e6e">${icon('doc')}</span><b>Spreadsheet</b><small>Excel (.xlsx)</small></button>
|
||||
<button class="tile" data-x="pptx" data-l="presentation"><span class="ic" style="background:#e0742b">${icon('doc')}</span><b>Presentation</b><small>PowerPoint (.pptx)</small></button>
|
||||
</div>
|
||||
<p class="sub" id="olib" style="margin-top:12px">${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
|
||||
<div id="odocs"></div>
|
||||
<div class="bar"><a class="btn ghost" href="#/files">Browse all files</a>${sf.token() ? '' : '<a class="btn" href="#/files">Sign in</a>'}</div></div>`;
|
||||
$('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l));
|
||||
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 = '<div class="bar"><button class="btn" id="chl2">Choose location</button></div>';
|
||||
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } });
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `Saved in <b>${esc(locLabel(loc))}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>`;
|
||||
$('#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 ? `<h2>Documents</h2><div class="list">${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 `<button class="row" data-i="${i}"><span class="ic" style="background:${color}">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.parent_dir || '/')} · ${fmtDate(f.mtime)}</small></span></button>`; }).join('')}</div>`
|
||||
: '<p class="empty">No documents here yet. Create one above.</p>';
|
||||
$('#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 = `<span class="err">${esc(e.message)}</span>`; }
|
||||
})();
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+15
-5
@@ -220,7 +220,9 @@ function vdUi() {
|
||||
if (r) { if (!drag) r.value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; const shown = drag ? r.value / 1000 * t : p; $('#vcur').textContent = vdClock(shown); $('#vrem').textContent = '-' + vdClock(Math.max(0, t - shown)); }
|
||||
const pp = $('#vpp'); if (pp) { pp.innerHTML = icon(el.paused ? 'play' : 'pause'); pp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
|
||||
const pv = $('#vprev'), nx = $('#vnextep'); if (pv) pv.disabled = !vp.sibs || vp.sibIdx <= 0; if (nx) nx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
|
||||
const mp = $('#vmpp'); if (mp) mp.innerHTML = icon(el.paused ? 'play' : 'pause'); const pr = $('#vmprog b'); if (pr) pr.style.width = (t ? Math.min(100, 100 * p / t) : 0) + '%';
|
||||
const mp = $('#vmpp'); if (mp) { mp.innerHTML = icon(el.paused ? 'play' : 'pause'); mp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
|
||||
const mr = $('#vmrange'); if (mr) { const d = vp.miniDrag; if (!d) mr.value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; const shown = d ? mr.value / 1000 * t : p; $('#vmcur').textContent = vdClock(shown); $('#vmrem').textContent = '-' + vdClock(Math.max(0, t - shown)); }
|
||||
const mpv = $('#vmprev'), mnx = $('#vmnext'); if (mpv) mpv.disabled = !vp.sibs || vp.sibIdx <= 0; if (mnx) mnx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
|
||||
}
|
||||
/** The mini player: the video keeps playing in a small window while you use other tabs. */
|
||||
function videoMini(on) {
|
||||
@@ -229,14 +231,22 @@ function videoMini(on) {
|
||||
if (!vp || !vp.el) return;
|
||||
if (!box) {
|
||||
box = document.createElement('div'); box.id = 'vmini'; box.setAttribute('role', 'region'); box.setAttribute('aria-label', 'Video mini player');
|
||||
box.innerHTML = `<div class="vmslot"></div><div class="vmover"><b class="vmtitle"></b><div class="vmbtns"><button id="vmpp" aria-label="Play or pause"></button><button id="vmx" aria-label="Back to the player">${icon('full')}</button><button id="vmc" aria-label="Close">${icon('x')}</button></div></div><i class="vmprog" id="vmprog"><b></b></i>`;
|
||||
box.innerHTML = `<div class="vmslot"></div><div class="vmctl">
|
||||
<div class="vmhead"><b class="vmtitle"></b><button class="vmb" id="vmx" aria-label="Back to the player" title="Back to the player">${icon('full')}</button><button class="vmb" id="vmc" aria-label="Close" title="Close">${icon('x')}</button></div>
|
||||
<input type="range" id="vmrange" min="0" max="1000" step="1" value="0" aria-label="Seek">
|
||||
<div class="vmrow"><span id="vmcur">0:00</span><div class="vmmid"><button class="vmb" id="vmprev" aria-label="Previous episode" title="Previous episode" hidden>${icon('prev')}</button><button class="vmb" id="vmb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="vmb big" id="vmpp" aria-label="Play or pause"></button><button class="vmb" id="vmf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button><button class="vmb" id="vmnext" aria-label="Next episode" title="Next episode" hidden>${icon('next')}</button></div><span id="vmrem">-0:00</span></div></div>`;
|
||||
document.body.appendChild(box);
|
||||
box.querySelector('#vmpp').onclick = e => { e.stopPropagation(); const el = vp && vp.el; if (el) el.paused ? el.play().catch(() => {}) : el.pause(); };
|
||||
box.querySelector('#vmc').onclick = e => { e.stopPropagation(); videoStop(); };
|
||||
const q = s => box.querySelector(s), stop = f => e => { e.stopPropagation(); f(e); };
|
||||
q('#vmpp').onclick = stop(() => { const el = vp && vp.el; if (el) el.paused ? el.play().catch(() => {}) : el.pause(); });
|
||||
q('#vmc').onclick = stop(() => videoStop());
|
||||
const open = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
||||
box.querySelector('#vmx').onclick = e => { e.stopPropagation(); open(); }; box.querySelector('.vmslot').onclick = open;
|
||||
q('#vmx').onclick = stop(open); q('.vmslot').onclick = open;
|
||||
q('#vmb10').onclick = stop(() => videoSkip(-10)); q('#vmf10').onclick = stop(() => videoSkip(10));
|
||||
q('#vmprev').onclick = stop(() => videoGo(-1)); q('#vmnext').onclick = stop(() => videoGo(1));
|
||||
const r = q('#vmrange'); r.oninput = () => { vp && (vp.miniDrag = true); vdUi(); }; r.onchange = () => { if (!vp) return; const t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0; videoSeek(r.value / 1000 * t); vp.miniDrag = false; };
|
||||
}
|
||||
box.hidden = false; box.querySelector('.vmtitle').textContent = vdTitle(vp.m);
|
||||
const ep = vp.m.type === 'episode'; box.querySelector('#vmprev').hidden = !ep; box.querySelector('#vmnext').hidden = !ep;
|
||||
vp.el.controls = false; box.querySelector('.vmslot').appendChild(vp.el); document.body.classList.remove('onvideo'); document.body.classList.remove('vfullwin');
|
||||
const st = document.querySelector('.vstage.vfull'); if (st) st.classList.remove('vfull');
|
||||
vdUi();
|
||||
|
||||
Reference in new issue
Block a user