Polish: npm test runs every test file, two silent failures now log, Loading placeholders shimmer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d173555398
commit
551f970721
32 files changed
+68
-47
No files matched your search
@@ -49,7 +49,7 @@ const fail = (status, message) => Object.assign(new Error(message), { status });
|
|||||||
const accFile = () => path.join(users.dir(), 'mail-accounts.json');
|
const accFile = () => path.join(users.dir(), 'mail-accounts.json');
|
||||||
const legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several
|
const legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several
|
||||||
const accounts = () => {
|
const accounts = () => {
|
||||||
try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch {}
|
try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch (e) { if (e.code !== 'ENOENT') console.warn('[mail] could not read the saved mail accounts (they will look empty until this is fixed):', e.message); }
|
||||||
try { // migrate the old single-account file
|
try { // migrate the old single-account file
|
||||||
const old = vault.unseal(JSON.parse(fs.readFileSync(legacyFile(), 'utf8')).data);
|
const old = vault.unseal(JSON.parse(fs.readFileSync(legacyFile(), 'utf8')).data);
|
||||||
if (old) { const list = [{ id: crypto.randomBytes(3).toString('hex'), type: 'outlook', ...old }]; saveAccounts(list); fs.rmSync(legacyFile(), { force: true }); return list; }
|
if (old) { const list = [{ id: crypto.randomBytes(3).toString('hex'), type: 'outlook', ...old }]; saveAccounts(list); fs.rmSync(legacyFile(), { force: true }); return list; }
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body settingskeys transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
|
"test": "node tools/run-tests.js",
|
||||||
"audit": "npm audit --omit=dev --audit-level=high"
|
"audit": "npm audit --omit=dev --audit-level=high"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const abLoadProg = async () => { try { for (const [id, p] of Object.entries(awai
|
|||||||
|
|
||||||
async function viewBooks(args = []) {
|
async function viewBooks(args = []) {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try {
|
try {
|
||||||
const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]);
|
const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]);
|
||||||
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
|
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
|
||||||
@@ -105,7 +105,7 @@ function abSheet(b) {
|
|||||||
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
|
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
|
||||||
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
||||||
el.querySelector('#abc').onclick = async () => {
|
el.querySelector('#abc').onclick = async () => {
|
||||||
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
|
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
|
||||||
const ch = await abChapters(id);
|
const ch = await abChapters(id);
|
||||||
box.innerHTML = ch.length ? ch.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${abFmt(c.start)}</small></span></button>`).join('') : '<p class="empty">This book has no chapter list.</p>';
|
box.innerHTML = ch.length ? ch.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${abFmt(c.start)}</small></span></button>`).join('') : '<p class="empty">This book has no chapter list.</p>';
|
||||||
box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start));
|
box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start));
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function bbPaint(root) { // highlights, notes and picked verses, on top of the t
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function viewBible(args = []) {
|
async function viewBible(args = []) {
|
||||||
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty">Loading the Bible…</p></div>';
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty loading" role="status">Loading the Bible…</p></div>';
|
||||||
try { await bbLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Bible</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="bbretry">Try again</button></div></div>`; $('#bbretry').onclick = () => route(); return; }
|
try { await bbLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Bible</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="bbretry">Try again</button></div></div>`; $('#bbretry').onclick = () => route(); return; }
|
||||||
if (!location.hash.startsWith('#/bible')) return;
|
if (!location.hash.startsWith('#/bible')) return;
|
||||||
try { const s = await bbApi('/state'); bbSt = s; } catch {}
|
try { const s = await bbApi('/state'); bbSt = s; } catch {}
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ async function viewCalendar() {
|
|||||||
const views = `<div class="sgrp" role="tablist" aria-label="Calendar view">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" role="tab" data-v="${k}" aria-selected="${k === mode}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>`
|
const views = `<div class="sgrp" role="tablist" aria-label="Calendar view">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" role="tab" data-v="${k}" aria-selected="${k === mode}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>`
|
||||||
+ (mode === 'agenda' ? `<select id="crange" class="psel" aria-label="How far ahead">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<option value="${n}" ${n === store.get('cal_range', 7) ? 'selected' : ''}>${l}</option>`).join('')}</select>` : '');
|
+ (mode === 'agenda' ? `<select id="crange" class="psel" aria-label="How far ahead">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<option value="${n}" ${n === store.get('cal_range', 7) ? 'selected' : ''}>${l}</option>`).join('')}</select>` : '');
|
||||||
v.innerHTML = `<div class="wrap">${plannerHead('calendar', `<button class="btn" id="ne">${icon('cal')}Add event</button>`, views)}
|
v.innerHTML = `<div class="wrap">${plannerHead('calendar', `<button class="btn" id="ne">${icon('cal')}Add event</button>`, views)}
|
||||||
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
|
<div id="cbody"><p class="empty loading" role="status">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
|
||||||
$('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
|
$('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
|
||||||
if ($('#crange')) $('#crange').onchange = e => { store.set('cal_range', +e.target.value); viewCalendar(); };
|
if ($('#crange')) $('#crange').onchange = e => { store.set('cal_range', +e.target.value); viewCalendar(); };
|
||||||
v.querySelectorAll('.sgrp a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
|
v.querySelectorAll('.sgrp a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ function chEvent(ev) {
|
|||||||
|
|
||||||
function viewChat(args = []) { const id = args[0] ? decodeURIComponent(args[0]) : null; ch.curSig = id && id.startsWith('sg:') ? id.slice(3) : null; ch.cur = ch.curSig ? null : id; if (!ch.curSig && typeof sg === 'object') sg.inChat = false; return chMount($('#view')); }
|
function viewChat(args = []) { const id = args[0] ? decodeURIComponent(args[0]) : null; ch.curSig = id && id.startsWith('sg:') ? id.slice(3) : null; ch.cur = ch.curSig ? null : id; if (!ch.curSig && typeof sg === 'object') sg.inChat = false; return chMount($('#view')); }
|
||||||
async function chMount(root) {
|
async function chMount(root) {
|
||||||
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
|
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur || ch.curSig ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
|
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur || ch.curSig ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
|
||||||
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
|
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
|
||||||
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
|
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function crPickGoal(uid) {
|
|||||||
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
|
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
|
||||||
async function viewChores() {
|
async function viewChores() {
|
||||||
const v = $('#view'); cr.day = cr.day || crYmd(new Date());
|
const v = $('#view'); cr.day = cr.day || crYmd(new Date());
|
||||||
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try { cr.wk = cr.wk || crYmd(new Date()); [cr.data, cr.week] = await Promise.all([crApi('/?date=' + cr.day), crApi('/week?date=' + cr.wk)]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Chores</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
try { cr.wk = cr.wk || crYmd(new Date()); [cr.data, cr.week] = await Promise.all([crApi('/?date=' + cr.day), crApi('/week?date=' + cr.wk)]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Chores</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (!location.hash.startsWith('#/chores')) return;
|
if (!location.hash.startsWith('#/chores')) return;
|
||||||
const d = cr.data, me = d.me.id, mine = d.board.filter(i => i.user === me), anyone = d.board.filter(i => i.user === null), others = d.people.filter(p => p.id !== me);
|
const d = cr.data, me = d.me.id, mine = d.board.filter(i => i.user === me), anyone = d.board.filter(i => i.user === null), others = d.people.filter(p => p.id !== me);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const ctFind = (l, address) => { const a = String(address || '').toLowerCase();
|
|||||||
|
|
||||||
async function viewContacts(args = []) {
|
async function viewContacts(args = []) {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap"><h1>Contacts</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Contacts</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try {
|
try {
|
||||||
const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return;
|
const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return;
|
||||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Contacts</h1><span class="pspace grow"></span><button class="btn ghost" id="ctmore" aria-label="More">${icon('more')}</button><button class="btn" id="ctnew">${icon('plus')}New</button></div>
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Contacts</h1><span class="pspace grow"></span><button class="btn ghost" id="ctmore" aria-label="More">${icon('more')}</button><button class="btn" id="ctnew">${icon('plus')}New</button></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
// ---- Family: one start page for the household: the announcement, today's events, chores, the shopping list, shared notes, chats and tasks. Each card loads on its own and
|
// ---- Family: one start page for the household: the announcement, today's events, chores, the shopping list, shared notes, chats and tasks. Each card loads on its own and
|
||||||
// only shows if the person has that part of the hub, so one slow or missing piece never blanks the page.
|
// only shows if the person has that part of the hub, so one slow or missing piece never blanks the page.
|
||||||
const fmCard = (id, title, href, sub) => `<section class="fmcard" id="${id}"><div class="fmh"><h2>${esc(title)}</h2>${href ? `<a href="${href}" class="fmmore">${esc(sub || 'Open')}</a>` : ''}</div><div class="fmb"><p class="empty" style="margin:6px 0">Loading…</p></div></section>`;
|
const fmCard = (id, title, href, sub) => `<section class="fmcard" id="${id}"><div class="fmh"><h2>${esc(title)}</h2>${href ? `<a href="${href}" class="fmmore">${esc(sub || 'Open')}</a>` : ''}</div><div class="fmb"><p class="empty loading" style="margin:6px 0" role="status">Loading…</p></div></section>`;
|
||||||
const fmBody = id => document.querySelector('#' + id + ' .fmb');
|
const fmBody = id => document.querySelector('#' + id + ' .fmb');
|
||||||
const fmErr = (id, e) => { const b = fmBody(id); if (b) b.innerHTML = `<p class="sub" style="margin:6px 0">${esc(e && e.message || 'Could not load this')}</p>`; };
|
const fmErr = (id, e) => { const b = fmBody(id); if (b) b.innerHTML = `<p class="sub" style="margin:6px 0">${esc(e && e.message || 'Could not load this')}</p>`; };
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Uses sf, fmtSize, openSheet, confirmSheet, toast, esc and FORM from app.js.
|
// Uses sf, fmtSize, openSheet, confirmSheet, toast, esc and FORM from app.js.
|
||||||
async function fileHistory(repo, path, name, readOnly) {
|
async function fileHistory(repo, path, name, readOnly) {
|
||||||
const base = `/api2/repos/${encodeURIComponent(repo)}/file`;
|
const base = `/api2/repos/${encodeURIComponent(repo)}/file`;
|
||||||
const el = openSheet('Version history', name, '<p class="empty">Loading…</p>');
|
const el = openSheet('Version history', name, '<p class="empty loading" role="status">Loading…</p>');
|
||||||
el.querySelector('.sheet').classList.add('wide');
|
el.querySelector('.sheet').classList.add('wide');
|
||||||
try {
|
try {
|
||||||
let commits = [], next = '';
|
let commits = [], next = '';
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ async function offerUndo(repo, dir, items, afterRestore) {
|
|||||||
} catch { return false; /* no undo available */ }
|
} catch { return false; /* no undo available */ }
|
||||||
}
|
}
|
||||||
async function recycleBin(repo, dir) {
|
async function recycleBin(repo, dir) {
|
||||||
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty">Loading…</p>');
|
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty loading" role="status">Loading…</p>');
|
||||||
el.querySelector('.sheet').classList.add('wide');
|
el.querySelector('.sheet').classList.add('wide');
|
||||||
try {
|
try {
|
||||||
const list = (await trashList(repo, dir || '/', 100)).sort((a, b) => trashWhen(b.deleted_time) - trashWhen(a.deleted_time));
|
const list = (await trashList(repo, dir || '/', 100)).sort((a, b) => trashWhen(b.deleted_time) - trashWhen(a.deleted_time));
|
||||||
@@ -143,7 +143,7 @@ async function zipDownload(repo, dir, names) {
|
|||||||
// ---- Share links with a password and an expiry, and the ones that already exist
|
// ---- Share links with a password and an expiry, and the ones that already exist
|
||||||
async function shareSheet(repo, path, isDir) {
|
async function shareSheet(repo, path, isDir) {
|
||||||
const p = isDir ? path.replace(/\/?$/, '/') : path, enc = encodeURIComponent, FORMH = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
const p = isDir ? path.replace(/\/?$/, '/') : path, enc = encodeURIComponent, FORMH = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||||
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty">Loading…</p>');
|
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty loading" role="status">Loading…</p>');
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
let links = []; try { links = (await sf.req(`/api/v2.1/share-links/?repo_id=${enc(repo)}&path=${enc(p)}`)) || []; } catch (e) { if (!document.body.contains(el)) return; }
|
let links = []; try { links = (await sf.req(`/api/v2.1/share-links/?repo_id=${enc(repo)}&path=${enc(p)}`)) || []; } catch (e) { if (!document.body.contains(el)) return; }
|
||||||
if (!document.body.contains(el)) return;
|
if (!document.body.contains(el)) return;
|
||||||
@@ -177,7 +177,7 @@ async function shareSheet(repo, path, isDir) {
|
|||||||
const TEXT_EXT = new Set('txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore'.split(' '));
|
const TEXT_EXT = new Set('txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore'.split(' '));
|
||||||
async function textPreview(repo, path, name) {
|
async function textPreview(repo, path, name) {
|
||||||
const dir = path.replace(/\/[^/]*$/, '') || '/', ro = libPerm[repo] === 'r', url = (await fileUrl(repo, path)) + '?hubinline=1';
|
const dir = path.replace(/\/[^/]*$/, '') || '/', ro = libPerm[repo] === 'r', url = (await fileUrl(repo, path)) + '?hubinline=1';
|
||||||
const el = openSheet(name, '', '<p class="empty">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
|
const el = openSheet(name, '', '<p class="empty loading" role="status">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
|
||||||
const dl = `<a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Download</a>`;
|
const dl = `<a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Download</a>`;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url); if (!r.ok) throw new Error('Could not open the file (' + r.status + ')');
|
const r = await fetch(url); if (!r.ok) throw new Error('Could not open the file (' + r.status + ')');
|
||||||
|
|||||||
+4
-4
@@ -42,7 +42,7 @@ function viewFiles([repo, ...parts]) {
|
|||||||
if (!sf.token()) return filesLogin();
|
if (!sf.token()) return filesLogin();
|
||||||
const dir = '/' + parts.join('/');
|
const dir = '/' + parts.join('/');
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
|
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
|
||||||
<div class="libs" id="libs"><p class="empty">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
|
<div class="libs" id="libs"><p class="empty loading" role="status">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
|
||||||
libsP = loadLibs(repo);
|
libsP = loadLibs(repo);
|
||||||
if (repo) loadDir(repo, dir);
|
if (repo) loadDir(repo, dir);
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ async function prefetchDl(repo, path) {
|
|||||||
|
|
||||||
async function loadDir(repo, dir) {
|
async function loadDir(repo, dir) {
|
||||||
const main = $('#fmain'); if (!main) return;
|
const main = $('#fmain'); if (!main) return;
|
||||||
main.innerHTML = '<p class="empty">Loading…</p>';
|
main.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
|
||||||
await libsP.catch(() => {});
|
await libsP.catch(() => {});
|
||||||
if (!document.contains(main)) return;
|
if (!document.contains(main)) return;
|
||||||
const enc = encodeURIComponent, ro = libPerm[repo] === 'r';
|
const enc = encodeURIComponent, ro = libPerm[repo] === 'r';
|
||||||
@@ -243,7 +243,7 @@ async function loadDir(repo, dir) {
|
|||||||
const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); };
|
const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); };
|
||||||
|
|
||||||
main.innerHTML = `<div id="ftop"></div><div class="crumbs">${crumbs}</div>
|
main.innerHTML = `<div id="ftop"></div><div class="crumbs">${crumbs}</div>
|
||||||
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
|
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty loading" role="status">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
|
||||||
const go = p => { location.hash = hash(p); };
|
const go = p => { location.hash = hash(p); };
|
||||||
|
|
||||||
const drawTop = () => {
|
const drawTop = () => {
|
||||||
@@ -396,7 +396,7 @@ async function loadDir(repo, dir) {
|
|||||||
const restore = () => { if (v.scrolled) return; v.scrolled = true; const y = fScroll.get(location.hash); if (y) requestAnimationFrame(() => { const view = $('#view'); if (view) view.scrollTop = y; }); };
|
const restore = () => { if (v.scrolled) return; v.scrolled = true; const y = fScroll.get(location.hash); if (y) requestAnimationFrame(() => { const view = $('#view'); if (view) view.scrollTop = y; }); };
|
||||||
if (!quiet) {
|
if (!quiet) {
|
||||||
if (!v.items.length && saved) { v.items = saved.items; v.stale = 'old'; v.staleWhy = 'Updating…'; redraw(); restore(); } // the saved list at once, the real one right behind it
|
if (!v.items.length && saved) { v.items = saved.items; v.stale = 'old'; v.staleWhy = 'Updating…'; redraw(); restore(); } // the saved list at once, the real one right behind it
|
||||||
else $('#items').innerHTML = '<p class="empty">Loading…</p>';
|
else $('#items').innerHTML = '<p class="empty loading" role="status">Loading…</p>';
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
|
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ setInterval(() => { if (!document.hidden && typeof ME !== 'undefined' && ME && d
|
|||||||
const inboxAgo = t => { const m = Math.round((Date.now() - t) / 6e4); return m < 1 ? 'now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
|
const inboxAgo = t => { const m = Math.round((Date.now() - t) / 6e4); return m < 1 ? 'now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
|
||||||
const INBOX_ICON = { chat: ['chat', '#2aa6a0'], remind: ['bell', '#e0742b'], announce: ['bell', '#5b8def'] };
|
const INBOX_ICON = { chat: ['chat', '#2aa6a0'], remind: ['bell', '#e0742b'], announce: ['bell', '#5b8def'] };
|
||||||
async function inboxOpen() {
|
async function inboxOpen() {
|
||||||
const el = openSheet('Notifications', '', '<div id="inboxl"><p class="empty">Loading…</p></div><div class="bar" id="inboxbar" hidden><button class="btn ghost" id="inboxall" style="flex:1;justify-content:center">Mark all read</button><button class="btn ghost" id="inboxclr" style="flex:1;justify-content:center">Clear all</button></div>');
|
const el = openSheet('Notifications', '', '<div id="inboxl"><p class="empty loading" role="status">Loading…</p></div><div class="bar" id="inboxbar" hidden><button class="btn ghost" id="inboxall" style="flex:1;justify-content:center">Mark all read</button><button class="btn ghost" id="inboxclr" style="flex:1;justify-content:center">Clear all</button></div>');
|
||||||
let items = [];
|
let items = [];
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length;
|
const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function lvPlay(b, at) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function viewFreeBooks(host) { // host: { el, hash } when shown inside the Store tab
|
async function viewFreeBooks(host) { // host: { el, hash } when shown inside the Store tab
|
||||||
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty">Loading…</p></div>';
|
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (!here()) return;
|
if (!here()) return;
|
||||||
const S = { q: '', genre: '', page: 1, items: [], more: false, busy: false, seq: 0 }; let genreList = []; lvApi('/genres').then(r => { genreList = r.genres || []; gchips(); }).catch(() => {});
|
const S = { q: '', genre: '', page: 1, items: [], more: false, busy: false, seq: 0 }; let genreList = []; lvApi('/genres').then(r => { genreList = r.genres || []; gchips(); }).catch(() => {});
|
||||||
@@ -78,7 +78,7 @@ async function viewFreeBooks(host) { // host: { el, hash } when shown inside the
|
|||||||
|
|
||||||
async function lvSheet(id, after, known = new Map()) {
|
async function lvSheet(id, after, known = new Map()) {
|
||||||
const sum = known.get(id) || lvShelf.find(x => x.id === id) || { id, title: 'Audiobook', author: '' };
|
const sum = known.get(id) || lvShelf.find(x => x.id === id) || { id, title: 'Audiobook', author: '' };
|
||||||
const el = openSheet(sum.title, sum.author || '', '<p class="empty">Loading…</p>');
|
const el = openSheet(sum.title, sum.author || '', '<p class="empty loading" role="status">Loading…</p>');
|
||||||
let b; try { b = await lvApi('/book/' + id); } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
let b; try { b = await lvApi('/book/' + id); } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||||
if (!document.body.contains(el)) return;
|
if (!document.body.contains(el)) return;
|
||||||
const p = lvLeft(id), total = lvTotal(b), onShelf = () => lvShelf.some(x => x.id === id);
|
const p = lvLeft(id), total = lvTotal(b), onShelf = () => lvShelf.some(x => x.id === id);
|
||||||
|
|||||||
+2
-2
@@ -165,7 +165,7 @@ async function viewMail(args = []) {
|
|||||||
st = saved; mail.st = saved;
|
st = saved; mail.st = saved;
|
||||||
jfetch('/api/mail/status?light=1').then(fresh => { if (!fresh.connected || fresh.accounts.map(a => a.id).join() !== saved.accounts.map(a => a.id).join()) { mail.st = null; mcClear(); if (location.hash.startsWith('#/mail')) route(); } else { mail.st = { ...fresh, accounts: fresh.accounts.map(a => ({ ...a, unread: (mail.st.accounts.find(x => x.id === a.id) || {}).unread ?? a.unread })) }; mcPut('st', mail.st); } }).catch(() => {});
|
jfetch('/api/mail/status?light=1').then(fresh => { if (!fresh.connected || fresh.accounts.map(a => a.id).join() !== saved.accounts.map(a => a.id).join()) { mail.st = null; mcClear(); if (location.hash.startsWith('#/mail')) route(); } else { mail.st = { ...fresh, accounts: fresh.accounts.map(a => ({ ...a, unread: (mail.st.accounts.find(x => x.id === a.id) || {}).unread ?? a.unread })) }; mcPut('st', mail.st); } }).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try { st = await jfetch('/api/mail/status?light=1'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
try { st = await jfetch('/api/mail/status?light=1'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (st.connected) mcPut('st', { connected: true, accounts: st.accounts, outlook: st.outlook, providers: st.providers });
|
if (st.connected) mcPut('st', { connected: true, accounts: st.accounts, outlook: st.outlook, providers: st.providers });
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,7 @@ async function mailLoadList(reset) {
|
|||||||
mail.items = []; mail.next = 0; mail.more = false;
|
mail.items = []; mail.next = 0; mail.more = false;
|
||||||
const saved = first && mcGet('l:' + folder);
|
const saved = first && mcGet('l:' + folder);
|
||||||
if (saved) { mail.items = saved.items; mail.more = saved.more; mail.next = saved.next; mailDrawList(); } // last time's list, right away
|
if (saved) { mail.items = saved.items; mail.more = saved.more; mail.next = saved.next; mailDrawList(); } // last time's list, right away
|
||||||
else { const rows = $('#mrows'); if (rows) rows.innerHTML = '<p class="empty">Loading…</p>'; }
|
else { const rows = $('#mrows'); if (rows) rows.innerHTML = '<p class="empty loading" role="status">Loading…</p>'; }
|
||||||
}
|
}
|
||||||
const showing = reset && mail.items.length; // a saved list is on screen: the fresh one replaces it instead of being added to it
|
const showing = reset && mail.items.length; // a saved list is on screen: the fresh one replaces it instead of being added to it
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ const mapTrack = (t, mc) => {
|
|||||||
|
|
||||||
async function viewMusic(args) {
|
async function viewMusic(args) {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try {
|
try {
|
||||||
const st = await jfetch('/api/plex/status');
|
const st = await jfetch('/api/plex/status');
|
||||||
if (!location.hash.startsWith('#/music')) return;
|
if (!location.hash.startsWith('#/music')) return;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async function ntLoad() { const r = await ntApi('/'); nt.notes = r.notes; nt.peo
|
|||||||
|
|
||||||
// ================================================================ the list of notes
|
// ================================================================ the list of notes
|
||||||
async function ntHome() {
|
async function ntHome() {
|
||||||
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Notes</h1><p class="empty">Loading…</p></div>';
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Notes</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try { await ntLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Notes</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
try { await ntLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Notes</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (!location.hash.startsWith('#/notes')) return;
|
if (!location.hash.startsWith('#/notes')) return;
|
||||||
const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']];
|
const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']];
|
||||||
@@ -45,7 +45,7 @@ function ntNewSheet() {
|
|||||||
|
|
||||||
// ================================================================ one note or list
|
// ================================================================ one note or list
|
||||||
async function ntEditor(id) {
|
async function ntEditor(id) {
|
||||||
const v = $('#view'); v.innerHTML = '<div class="wrap"><p class="empty">Loading…</p></div>';
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
let n; try { await ntLoad(); n = nt.notes.find(x => x.id === id); if (!n) throw new Error('That note is not available (it may have been deleted, or is not shared with you).'); } catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
|
let n; try { await ntLoad(); n = nt.notes.find(x => x.id === id); if (!n) throw new Error('That note is not available (it may have been deleted, or is not shared with you).'); } catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (!location.hash.startsWith('#/notes/')) return;
|
if (!location.hash.startsWith('#/notes/')) return;
|
||||||
let saveT, busy = false, dirty = false;
|
let saveT, busy = false, dirty = false;
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@ function ofDraw(skeleton) {
|
|||||||
const row = d => { const path = ofPath(d), on = pins.has(ofId(loc.repo, path)), i = shown.indexOf(d);
|
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>
|
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>`; };
|
<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>` : '')
|
box.innerHTML = skeleton ? '<p class="empty loading" role="status">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>` : '')
|
+ (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>` : ''}`
|
+ (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>`);
|
: `<p class="empty">${ofState.docs.length ? 'Nothing matches.' : 'No documents here yet. Create one above, or drop files on this page.'}</p>`);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const pdLeft = e => { const p = pdProg[pdId(e)]; return p && p.pos > 10 && p.dur
|
|||||||
|
|
||||||
async function viewPodcast(args = []) {
|
async function viewPodcast(args = []) {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try {
|
try {
|
||||||
if (args[0] === 'show' && args[1]) return await pdShow(args[1]);
|
if (args[0] === 'show' && args[1]) return await pdShow(args[1]);
|
||||||
return await pdHome();
|
return await pdHome();
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPla
|
|||||||
function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `<small>Now playing</small><b>${esc(n.title)}</b>${n.artist ? '<span> · ' + esc(n.artist) + '</span>' : ''}` : '<small>Live</small>'; }
|
function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `<small>Now playing</small><b>${esc(n.title)}</b>${n.artist ? '<span> · ' + esc(n.artist) + '</span>' : ''}` : '<small>Live</small>'; }
|
||||||
|
|
||||||
async function viewRadio(host) { // host: { el, hash } when shown inside the Store (the station finder); on the Radio tab itself only what was added from the Store is listed
|
async function viewRadio(host) { // host: { el, hash } when shown inside the Store (the station finder); on the Radio tab itself only what was added from the Store is listed
|
||||||
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty">Loading…</p></div>';
|
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
let feat, mine, added;
|
let feat, mine, added;
|
||||||
try { ({ featured: feat, mine, added } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Radio</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
try { ({ featured: feat, mine, added } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Radio</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (!here()) return;
|
if (!here()) return;
|
||||||
@@ -83,7 +83,7 @@ async function viewRadio(host) { // host: { el, hash } when shown inside the Sto
|
|||||||
$('#rdojp').onclick = () => playStation(j);
|
$('#rdojp').onclick = () => playStation(j);
|
||||||
$('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); };
|
$('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); };
|
||||||
$('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); };
|
$('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); };
|
||||||
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty">Loading…</p>'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `<div class="list">${n.recent.map(x => `<div class="row"><span class="grow"><b>${esc(x.title)}</b><small>${esc(x.artist)}</small></span></div>`).join('')}</div>` : '<p class="empty">Nothing to show right now.</p>'; } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; } };
|
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty loading" role="status">Loading…</p>'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `<div class="list">${n.recent.map(x => `<div class="row"><span class="grow"><b>${esc(x.title)}</b><small>${esc(x.artist)}</small></span></div>`).join('')}</div>` : '<p class="empty">Nothing to show right now.</p>'; } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; } };
|
||||||
$('#rdojx').onclick = async () => { try { await rdoApi('/stations/' + encodeURIComponent(j.id), rdoOpt('DELETE')); toast('Removed from Radio'); viewRadio(host); } catch (e) { toast(e.message); } };
|
$('#rdojx').onclick = async () => { try { await rdoApi('/stations/' + encodeURIComponent(j.id), rdoOpt('DELETE')); toast('Removed from Radio'); viewRadio(host); } catch (e) { toast(e.message); } };
|
||||||
rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {});
|
rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -29,7 +29,7 @@ async function viewReader(args = []) {
|
|||||||
if (args[0] === 'open' && args[1]) return rdOpen(args[1]);
|
if (args[0] === 'open' && args[1]) return rdOpen(args[1]);
|
||||||
if (args[0] === 'store') return allowed('store') ? location.replace('#/store/books') : rdStore();
|
if (args[0] === 'store') return allowed('store') ? location.replace('#/store/books') : rdStore();
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap"><h1>Books</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Books</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try { await rdLibrary(); } catch (e) {
|
try { await rdLibrary(); } catch (e) {
|
||||||
if (!location.hash.startsWith('#/read')) return;
|
if (!location.hash.startsWith('#/read')) return;
|
||||||
v.innerHTML = `<div class="wrap"><h1>Books</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rdretry">Try again</button></div></div>`;
|
v.innerHTML = `<div class="wrap"><h1>Books</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rdretry">Try again</button></div></div>`;
|
||||||
@@ -167,7 +167,7 @@ async function rdMobiOpen(u8) {
|
|||||||
|
|
||||||
// ======================================================= Book store: free, DRM-free books from Project Gutenberg, Standard Ebooks, the Internet Archive and any OPDS catalogue
|
// ======================================================= Book store: free, DRM-free books from Project Gutenberg, Standard Ebooks, the Internet Archive and any OPDS catalogue
|
||||||
async function rdStore(host) { // host: { el, hash } when shown inside the Store tab
|
async function rdStore(host) { // host: { el, hash } when shown inside the Store tab
|
||||||
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '<div class="wrap"><h1>Book store</h1><p class="empty">Loading…</p></div>';
|
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '<div class="wrap"><h1>Book store</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
let sources, mine;
|
let sources, mine;
|
||||||
let genreList = []; try { [sources, mine, genreList] = await Promise.all([rdApi('/store/sources'), rdApi('/list'), rdApi('/store/genres').catch(() => [])]); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
let genreList = []; try { [sources, mine, genreList] = await Promise.all([rdApi('/store/sources'), rdApi('/list'), rdApi('/store/genres').catch(() => [])]); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (!here()) return;
|
if (!here()) return;
|
||||||
@@ -350,7 +350,7 @@ class RdReader {
|
|||||||
async deleteNote(n) { this.A.eraseNote?.(n); await this.pushNote({ ...n, del: true }); this.notes = this.notes.filter(x => !x.del); this.$('#rdbm').classList.toggle('on', !!this.isMarked()); this.refreshNotes?.(); }
|
async deleteNote(n) { this.A.eraseNote?.(n); await this.pushNote({ ...n, del: true }); this.notes = this.notes.filter(x => !x.del); this.$('#rdbm').classList.toggle('on', !!this.isMarked()); this.refreshNotes?.(); }
|
||||||
// ---- panels
|
// ---- panels
|
||||||
async showToc() {
|
async showToc() {
|
||||||
const el = openSheet('Contents', '', '<p class="empty">Loading…</p>'); let items = [];
|
const el = openSheet('Contents', '', '<p class="empty loading" role="status">Loading…</p>'); let items = [];
|
||||||
try { items = await this.A.toc(); } catch {}
|
try { items = await this.A.toc(); } catch {}
|
||||||
el.querySelector('.empty').outerHTML = items.length ? `<div class="list rdlist">${items.map((t, i) => `<button class="row" data-i="${i}" style="padding-left:${16 + t.depth * 18}px"><span class="grow"><b>${esc(t.label)}</b></span></button>`).join('')}</div>` : '<p class="empty">This book has no table of contents.</p>';
|
el.querySelector('.empty').outerHTML = items.length ? `<div class="list rdlist">${items.map((t, i) => `<button class="row" data-i="${i}" style="padding-left:${16 + t.depth * 18}px"><span class="grow"><b>${esc(t.label)}</b></span></button>`).join('')}</div>` : '<p class="empty">This book has no table of contents.</p>';
|
||||||
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(items[b.dataset.i].loc); });
|
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(items[b.dataset.i].loc); });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ let rcQuery = '', rcPage = 1;
|
|||||||
|
|
||||||
async function viewRecipes(args = []) {
|
async function viewRecipes(args = []) {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap"><h1>Recipes</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Recipes</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try {
|
try {
|
||||||
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
|
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
|
||||||
if (args[0] === 'pantry') return await viewPantry(args[1]);
|
if (args[0] === 'pantry') return await viewPantry(args[1]);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function famKids(box) {
|
|||||||
box.querySelectorAll('[data-k]').forEach(b => b.onclick = () => famChild(kids.find(u => u.id === b.dataset.k)));
|
box.querySelectorAll('[data-k]').forEach(b => b.onclick = () => famChild(kids.find(u => u.id === b.dataset.k)));
|
||||||
}
|
}
|
||||||
async function famChild(u) {
|
async function famChild(u) {
|
||||||
const el = openSheet(u.name, 'Last 7 days', '<div id="fcb"><p class="empty">Loading…</p></div>'); let a, px;
|
const el = openSheet(u.name, 'Last 7 days', '<div id="fcb"><p class="empty loading" role="status">Loading…</p></div>'); let a, px;
|
||||||
try { [a, px] = await Promise.all([jfetch('/api/kid/activity?user=' + encodeURIComponent(u.id) + '&days=7'), jfetch('/api/kid/plex-lock?user=' + encodeURIComponent(u.id))]); } catch (e) { el.querySelector('#fcb').innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
try { [a, px] = await Promise.all([jfetch('/api/kid/activity?user=' + encodeURIComponent(u.id) + '&days=7'), jfetch('/api/kid/plex-lock?user=' + encodeURIComponent(u.id))]); } catch (e) { el.querySelector('#fcb').innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||||
const h = m => m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min', total = a.days.reduce((n, d) => n + d.minutes, 0), by = {}; a.days.forEach(d => Object.entries(d.areas).forEach(([k, m]) => { by[k] = (by[k] || 0) + m; }));
|
const h = m => m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min', total = a.days.reduce((n, d) => n + d.minutes, 0), by = {}; a.days.forEach(d => Object.entries(d.areas).forEach(([k, m]) => { by[k] = (by[k] || 0) + m; }));
|
||||||
const icn = { play: '🎵', watch: '🎬', listen: '🎧', read: '📖', search: '🔎', signin: '🔑', chore: '✅', add: '➕', blocked: '⛔' }, max = Math.max(1, ...a.days.map(d => d.minutes));
|
const icn = { play: '🎵', watch: '🎬', listen: '🎧', read: '📖', search: '🔎', signin: '🔑', chore: '✅', add: '➕', blocked: '⛔' }, max = Math.max(1, ...a.days.map(d => d.minutes));
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return
|
|||||||
async function sgMount(root, dock) {
|
async function sgMount(root, dock) {
|
||||||
sg.inChat = false; // (a full Signal screen or the floating panel; Chat sets this itself when it shows a Signal conversation)
|
sg.inChat = false; // (a full Signal screen or the floating panel; Chat sets this itself when it shows a Signal conversation)
|
||||||
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `<h1>Signal</h1>${typeof sgInChat === 'function' && sgInChat() ? `<p class="sub" style="margin:0 0 8px"><a href="#/chat" style="color:var(--accent)">‹ Back to Chat</a> · your Signal chats are also listed there</p>` : ''}`;
|
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `<h1>Signal</h1>${typeof sgInChat === 'function' && sgInChat() ? `<p class="sub" style="margin:0 0 8px"><a href="#/chat" style="color:var(--accent)">‹ Back to Chat</a> · your Signal chats are also listed there</p>` : ''}`;
|
||||||
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
|
root.innerHTML = `<div class="${cls}">${title}<p class="empty loading" role="status">Loading…</p></div>`;
|
||||||
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
|
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (sg.root !== root) return; sg.st = st;
|
if (sg.root !== root) return; sg.st = st;
|
||||||
if (!st.reachable) { root.innerHTML = `<div class="${cls}">${title}<div class="card" style="max-width:none"><p style="margin-top:0">The Signal service is not answering. Check that its container (<b>signal</b>) is running, then reload.</p></div></div>`; return; }
|
if (!st.reachable) { root.innerHTML = `<div class="${cls}">${title}<div class="card" style="max-width:none"><p style="margin-top:0">The Signal service is not answering. Check that its container (<b>signal</b>) is running, then reload.</p></div></div>`; return; }
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ function stPickFile() {
|
|||||||
const el = openSheet('Choose a file from Files', '', '<div id="stpk"></div>'); const finish = v => { done = true; closeSheet(); resolve(v); };
|
const el = openSheet('Choose a file from Files', '', '<div id="stpk"></div>'); const finish = v => { done = true; closeSheet(); resolve(v); };
|
||||||
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
|
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
const box = el.querySelector('#stpk'); box.innerHTML = '<p class="empty">Loading…</p>';
|
const box = el.querySelector('#stpk'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
|
||||||
try {
|
try {
|
||||||
if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted).sort((a, b) => a.name.localeCompare(b.name));
|
if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
box.innerHTML = `<div class="list" style="max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span></button>`).join('')}</div><div class="bar"><button class="btn ghost" id="pcx" style="flex:1;justify-content:center">Cancel</button></div>`;
|
box.innerHTML = `<div class="list" style="max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span></button>`).join('')}</div><div class="bar"><button class="btn ghost" id="pcx" style="flex:1;justify-content:center">Cancel</button></div>`;
|
||||||
|
|||||||
@@ -1200,3 +1200,8 @@ body.qdock #view { padding-right: 340px; }
|
|||||||
|
|
||||||
/* My Store: books ticked for "Add to people" */
|
/* My Store: books ticked for "Add to people" */
|
||||||
.vcard.picked { outline: 3px solid var(--accent); outline-offset: 2px; border-radius: 12px; }
|
.vcard.picked { outline: 3px solid var(--accent); outline-offset: 2px; border-radius: 12px; }
|
||||||
|
|
||||||
|
/* "Loading…" placeholders: a soft shimmer so a slow tab reads as working, not stuck (still text for screen readers; static when motion is reduced) */
|
||||||
|
.empty.loading { background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--muted) 16%, transparent) 50%, transparent 100%) 0 0 / 200% 100% no-repeat; animation: loadsweep 1.4s ease-in-out infinite; border-radius: 12px; }
|
||||||
|
@keyframes loadsweep { from { background-position: 150% 0; } to { background-position: -50% 0; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .empty.loading { animation: none; } }
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v138';
|
const CACHE = 'hub-v139';
|
||||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@ let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" op
|
|||||||
|
|
||||||
async function viewTasks() {
|
async function viewTasks() {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap">' + plannerHead('tasks') + '<p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap">' + plannerHead('tasks') + '<p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try {
|
try {
|
||||||
let { lists, icloud } = await jfetch('/api/tasks/lists');
|
let { lists, icloud } = await jfetch('/api/tasks/lists');
|
||||||
if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first)
|
if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first)
|
||||||
@@ -18,7 +18,7 @@ async function viewTasks() {
|
|||||||
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
|
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
|
||||||
<div class="seg"><a href="#" data-l="today" class="${cur === 'today' ? 'on' : ''}">Today</a><a href="#" data-l="upcoming" class="${cur === 'upcoming' ? 'on' : ''}">Upcoming</a>${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
<div class="seg"><a href="#" data-l="today" class="${cur === 'today' ? 'on' : ''}">Today</a><a href="#" data-l="upcoming" class="${cur === 'upcoming' ? 'on' : ''}">Upcoming</a>${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
||||||
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${['all', 'today', 'upcoming'].includes(cur) ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${['all', 'today', 'upcoming'].includes(cur) ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
||||||
<div id="tl"><p class="empty">Loading…</p></div>
|
<div id="tl"><p class="empty loading" role="status">Loading…</p></div>
|
||||||
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
|
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
|
||||||
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
|
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
|
||||||
: icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : '';
|
: icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : '';
|
||||||
@@ -159,7 +159,7 @@ function chooseFolder(title, start, opts = {}) {
|
|||||||
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
|
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
|
||||||
const finish = v => { done = true; closeSheet(); resolve(v); };
|
const finish = v => { done = true; closeSheet(); resolve(v); };
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty">Loading…</p>';
|
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
|
||||||
try {
|
try {
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name));
|
const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLoca
|
|||||||
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(stOk(s.cover))}" onerror="this.remove()"></span>`;
|
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(stOk(s.cover))}" onerror="this.remove()"></span>`;
|
||||||
|
|
||||||
async function viewVbv(args = []) {
|
async function viewVbv(args = []) {
|
||||||
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Verse By Verse</h1><p class="empty">Loading…</p></div>';
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Verse By Verse</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try { await vbLoadProg(); if (args[0] === 'study' && args[1]) return await vbStudy(args[1]); return await vbHome(); }
|
try { await vbLoadProg(); if (args[0] === 'study' && args[1]) return await vbStudy(args[1]); return await vbHome(); }
|
||||||
catch (e) { if (!location.hash.startsWith('#/podcast/vbv')) return; v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/podcast">${icon('back')}Podcast</a><h1>Verse By Verse</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="vbretry">Try again</button><a class="btn ghost" href="https://versebyverseministry.org" target="_blank" rel="noopener">Open the website</a></div></div>`; $('#vbretry').onclick = () => route(); }
|
catch (e) { if (!location.hash.startsWith('#/podcast/vbv')) return; v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/podcast">${icon('back')}Podcast</a><h1>Verse By Verse</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="vbretry">Try again</button><a class="btn ghost" href="https://versebyverseministry.org" target="_blank" rel="noopener">Open the website</a></div></div>`; $('#vbretry').onclick = () => route(); }
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -29,7 +29,7 @@ async function vdNav(m, back) {
|
|||||||
}
|
}
|
||||||
async function viewVideo(args = []) {
|
async function viewVideo(args = []) {
|
||||||
const v = $('#view'), [kind, key] = args;
|
const v = $('#view'), [kind, key] = args;
|
||||||
v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty loading" role="status">Loading…</p></div>';
|
||||||
try {
|
try {
|
||||||
const st = await jfetch('/api/plex/status');
|
const st = await jfetch('/api/plex/status');
|
||||||
if (!location.hash.startsWith('#/video')) return;
|
if (!location.hash.startsWith('#/video')) return;
|
||||||
@@ -118,7 +118,7 @@ async function videoHome(st, libKey) {
|
|||||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
||||||
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
||||||
<div id="vhero"></div><div id="vdeck"></div><div id="vmix" class="seg"></div><div id="vnight"></div><div id="vwlr"></div><div id="vfavr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
<div id="vhero"></div><div id="vdeck"></div><div id="vmix" class="seg"></div><div id="vnight"></div><div id="vwlr"></div><div id="vfavr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
||||||
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty loading" role="status">Loading…</p></div><div id="vmore"></div></div>`;
|
||||||
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
|
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
|
||||||
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
||||||
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
|
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
|
||||||
@@ -586,7 +586,7 @@ async function videoEdit(m) {
|
|||||||
el.querySelector('#sx').onclick = closeSheet;
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
const loaded = {};
|
const loaded = {};
|
||||||
const images = async (tab, what) => { // posters / backgrounds: pick one, or add from an image link
|
const images = async (tab, what) => { // posters / backgrounds: pick one, or add from an image link
|
||||||
const box = el.querySelector('#vtab-' + tab); box.innerHTML = '<p class="empty">Loading…</p>';
|
const box = el.querySelector('#vtab-' + tab); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
|
||||||
try {
|
try {
|
||||||
const mc = await px.get(`/library/metadata/${m.ratingKey}/${what}`), list = mc.Metadata || [];
|
const mc = await px.get(`/library/metadata/${m.ratingKey}/${what}`), list = mc.Metadata || [];
|
||||||
box.innerHTML = `<div class="vimgs ${what}">${list.map((x, i) => `<button type="button" class="vpick ${x.selected ? 'on' : ''}" data-i="${i}" aria-label="Use this image"><img alt="" loading="lazy" src="${/^https?:/.test(x.thumb || '') ? esc(x.thumb) : vdArt(x.thumb, 200, 300)}"></button>`).join('') || '<p class="empty">No images yet.</p>'}</div>
|
box.innerHTML = `<div class="vimgs ${what}">${list.map((x, i) => `<button type="button" class="vpick ${x.selected ? 'on' : ''}" data-i="${i}" aria-label="Use this image"><img alt="" loading="lazy" src="${/^https?:/.test(x.thumb || '') ? esc(x.thumb) : vdArt(x.thumb, 200, 300)}"></button>`).join('') || '<p class="empty">No images yet.</p>'}</div>
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ async function handle(req, res) {
|
|||||||
const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 };
|
const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 };
|
||||||
const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; });
|
const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; });
|
||||||
count(load().tasks);
|
count(load().tasks);
|
||||||
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) count(parseTodos(r.ics)); } catch {} }
|
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) count(parseTodos(r.ics)); } catch (e) { console.warn('[tasks] could not read the iCloud list ' + l.name + ':', e.message); } }
|
||||||
return json(res, 200, out);
|
return json(res, 200, out);
|
||||||
}
|
}
|
||||||
json(res, 404, { error: 'Not found' });
|
json(res, 404, { error: 'Not found' });
|
||||||
@@ -191,7 +191,7 @@ async function handle(req, res) {
|
|||||||
async function allTasks() {
|
async function allTasks() {
|
||||||
const d = load(), out = [];
|
const d = load(), out = [];
|
||||||
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); }
|
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); }
|
||||||
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} }
|
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch (e) { console.warn('[tasks] could not read the iCloud list ' + l.name + ':', e.message); } }
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Runs every tools/*-test.js (or only the names given), one after another, and lists the ones that fail.
|
||||||
|
// A new test file is picked up automatically; `smoke` and `frontend` run first because they fail fastest.
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const fs = require('fs'), path = require('path');
|
||||||
|
const FIRST = ['smoke', 'frontend'];
|
||||||
|
const all = fs.readdirSync(__dirname).filter(f => f.endsWith('-test.js')).map(f => f.slice(0, -'-test.js'.length));
|
||||||
|
const want = process.argv.slice(2);
|
||||||
|
const names = (want.length ? want : all).sort((a, b) => (FIRST.indexOf(a) + 1 || 99) - (FIRST.indexOf(b) + 1 || 99) || a.localeCompare(b));
|
||||||
|
const failed = [];
|
||||||
|
for (const n of names) {
|
||||||
|
if (!all.includes(n)) { console.error('No test named ' + n); process.exit(2); }
|
||||||
|
console.log('\n== ' + n);
|
||||||
|
if (spawnSync(process.execPath, [path.join(__dirname, n + '-test.js')], { stdio: 'inherit' }).status !== 0) failed.push(n);
|
||||||
|
}
|
||||||
|
console.log(failed.length ? '\nFAILED: ' + failed.join(', ') : '\nAll ' + names.length + ' test files passed');
|
||||||
|
process.exit(failed.length ? 1 : 0);
|
||||||
Reference in new issue
Block a user