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 legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several
|
||||
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
|
||||
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; }
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -61,7 +61,7 @@ const abLoadProg = async () => { try { for (const [id, p] of Object.entries(awai
|
||||
|
||||
async function viewBooks(args = []) {
|
||||
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 {
|
||||
const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]);
|
||||
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('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
||||
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);
|
||||
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));
|
||||
|
||||
@@ -63,7 +63,7 @@ function bbPaint(root) { // highlights, notes and picked verses, on top of the t
|
||||
}
|
||||
|
||||
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; }
|
||||
if (!location.hash.startsWith('#/bible')) return;
|
||||
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>`
|
||||
+ (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)}
|
||||
<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);
|
||||
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(); });
|
||||
|
||||
+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')); }
|
||||
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>
|
||||
<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); };
|
||||
|
||||
@@ -36,7 +36,7 @@ function crPickGoal(uid) {
|
||||
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
|
||||
async function viewChores() {
|
||||
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; }
|
||||
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);
|
||||
|
||||
@@ -13,7 +13,7 @@ const ctFind = (l, address) => { const a = String(address || '').toLowerCase();
|
||||
|
||||
async function viewContacts(args = []) {
|
||||
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 {
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'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
|
||||
// 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 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.
|
||||
async function fileHistory(repo, path, name, readOnly) {
|
||||
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');
|
||||
try {
|
||||
let commits = [], next = '';
|
||||
|
||||
@@ -83,7 +83,7 @@ async function offerUndo(repo, dir, items, afterRestore) {
|
||||
} catch { return false; /* no undo available */ }
|
||||
}
|
||||
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');
|
||||
try {
|
||||
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
|
||||
async function shareSheet(repo, path, isDir) {
|
||||
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 () => {
|
||||
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;
|
||||
@@ -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(' '));
|
||||
async function textPreview(repo, path, name) {
|
||||
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>`;
|
||||
try {
|
||||
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();
|
||||
const dir = '/' + parts.join('/');
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
|
||||
<div class="libs" id="libs"><p class="empty">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
|
||||
<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);
|
||||
if (repo) loadDir(repo, dir);
|
||||
}
|
||||
@@ -229,7 +229,7 @@ async function prefetchDl(repo, path) {
|
||||
|
||||
async function loadDir(repo, dir) {
|
||||
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(() => {});
|
||||
if (!document.contains(main)) return;
|
||||
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)); };
|
||||
|
||||
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 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; }); };
|
||||
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
|
||||
else $('#items').innerHTML = '<p class="empty">Loading…</p>';
|
||||
else $('#items').innerHTML = '<p class="empty loading" role="status">Loading…</p>';
|
||||
}
|
||||
try {
|
||||
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 INBOX_ICON = { chat: ['chat', '#2aa6a0'], remind: ['bell', '#e0742b'], announce: ['bell', '#5b8def'] };
|
||||
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 = [];
|
||||
const draw = () => {
|
||||
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
|
||||
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; }
|
||||
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(() => {});
|
||||
@@ -78,7 +78,7 @@ async function viewFreeBooks(host) { // host: { el, hash } when shown inside the
|
||||
|
||||
async function lvSheet(id, after, known = new Map()) {
|
||||
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; }
|
||||
if (!document.body.contains(el)) return;
|
||||
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;
|
||||
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 {
|
||||
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; }
|
||||
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;
|
||||
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
|
||||
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
|
||||
try {
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ const mapTrack = (t, mc) => {
|
||||
|
||||
async function viewMusic(args) {
|
||||
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 {
|
||||
const st = await jfetch('/api/plex/status');
|
||||
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
|
||||
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; }
|
||||
if (!location.hash.startsWith('#/notes')) return;
|
||||
const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']];
|
||||
@@ -45,7 +45,7 @@ function ntNewSheet() {
|
||||
|
||||
// ================================================================ one note or list
|
||||
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; }
|
||||
if (!location.hash.startsWith('#/notes/')) return;
|
||||
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);
|
||||
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>` : '')
|
||||
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>` : '')
|
||||
+ (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>`);
|
||||
|
||||
@@ -34,7 +34,7 @@ const pdLeft = e => { const p = pdProg[pdId(e)]; return p && p.pos > 10 && p.dur
|
||||
|
||||
async function viewPodcast(args = []) {
|
||||
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 {
|
||||
if (args[0] === 'show' && args[1]) return await pdShow(args[1]);
|
||||
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>'; }
|
||||
|
||||
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;
|
||||
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;
|
||||
@@ -83,7 +83,7 @@ async function viewRadio(host) { // host: { el, hash } when shown inside the Sto
|
||||
$('#rdojp').onclick = () => playStation(j);
|
||||
$('#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); };
|
||||
$('#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); } };
|
||||
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] === 'store') return allowed('store') ? location.replace('#/store/books') : rdStore();
|
||||
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) {
|
||||
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>`;
|
||||
@@ -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
|
||||
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 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;
|
||||
@@ -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?.(); }
|
||||
// ---- panels
|
||||
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 {}
|
||||
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); });
|
||||
|
||||
@@ -9,7 +9,7 @@ let rcQuery = '', rcPage = 1;
|
||||
|
||||
async function viewRecipes(args = []) {
|
||||
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 {
|
||||
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(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)));
|
||||
}
|
||||
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; }
|
||||
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));
|
||||
|
||||
@@ -64,7 +64,7 @@ function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return
|
||||
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.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; }
|
||||
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; }
|
||||
|
||||
@@ -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); };
|
||||
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
|
||||
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 {
|
||||
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>`;
|
||||
|
||||
@@ -1200,3 +1200,8 @@ body.qdock #view { padding-right: 340px; }
|
||||
|
||||
/* My Store: books ticked for "Add to people" */
|
||||
.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.
|
||||
const CACHE = 'hub-v138';
|
||||
const CACHE = 'hub-v139';
|
||||
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 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() {
|
||||
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 {
|
||||
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)
|
||||
@@ -18,7 +18,7 @@ async function viewTasks() {
|
||||
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>
|
||||
<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>`;
|
||||
$('#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.' : '';
|
||||
@@ -159,7 +159,7 @@ function chooseFolder(title, start, opts = {}) {
|
||||
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
|
||||
const finish = v => { done = true; closeSheet(); resolve(v); };
|
||||
const draw = async () => {
|
||||
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty">Loading…</p>';
|
||||
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
|
||||
try {
|
||||
if (!repo) {
|
||||
const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
+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>`;
|
||||
|
||||
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(); }
|
||||
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 = []) {
|
||||
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 {
|
||||
const st = await jfetch('/api/plex/status');
|
||||
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>
|
||||
<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 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
|
||||
// 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;
|
||||
@@ -586,7 +586,7 @@ async function videoEdit(m) {
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
const loaded = {};
|
||||
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 {
|
||||
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>
|
||||
|
||||
@@ -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 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);
|
||||
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);
|
||||
}
|
||||
json(res, 404, { error: 'Not found' });
|
||||
@@ -191,7 +191,7 @@ async function handle(req, res) {
|
||||
async function allTasks() {
|
||||
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 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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