Menu: Listen and Read entries, a bottom bar you choose, an Apps sheet with search and recents. Bible tab with the Berean Standard Bible (search, highlights, notes, synced place)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
15 files changed
+393
-27
No files matched your search
+71
-20
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
||||
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
|
||||
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']);
|
||||
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
|
||||
const outbox = new Map(); let outTimer = null, warned = false;
|
||||
function flushSettings() {
|
||||
outTimer = null;
|
||||
@@ -37,7 +37,9 @@ async function loadMe() {
|
||||
}
|
||||
// Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up.
|
||||
const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)');
|
||||
const allowed = id => id === 'planner' ? (allowed('calendar') || allowed('tasks')) : id === 'links' ? (!ME || ME.tabs.includes('links') || ME.tabs.includes('verse')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
|
||||
// Related tabs share one menu entry (a row of buttons at the top of the page switches between them); each still needs its own permission.
|
||||
const MERGED = { listen: ['Listen', ['music', 'radio', 'podcast', 'audio']], reading: ['Read', ['bible', 'read']] };
|
||||
const allowed = id => MERGED[id] ? MERGED[id][1].some(allowed) : id === 'planner' ? (allowed('calendar') || allowed('tasks')) : id === 'links' ? (!ME || ME.tabs.includes('links') || ME.tabs.includes('verse')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
|
||||
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
|
||||
async function loadSettings() {
|
||||
try {
|
||||
@@ -101,6 +103,7 @@ const ICONS = {
|
||||
bookmark: '<path d="M7 4h10v16l-5-4-5 4z"/>',
|
||||
radio: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="M7 8l10-4M8 14h5M16 14h.01M16 17h.01"/>',
|
||||
heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.6-7 10-7 10z"/>',
|
||||
bible: '<path d="M5 5a2 2 0 012-2h12v15H7a2 2 0 00-2 2z"/><path d="M5 20a2 2 0 002 2h12v-4M12 7v7M9.5 9.5h5"/>',
|
||||
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
|
||||
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
|
||||
};
|
||||
@@ -157,19 +160,30 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
// ---- Navigation
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
|
||||
const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
|
||||
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
|
||||
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1];
|
||||
if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; }
|
||||
if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; };
|
||||
/** Every page one by one (what search and the command palette list), with the merged entries split back out. */
|
||||
const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0]));
|
||||
const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1];
|
||||
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
|
||||
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'video', 'audio', 'podcast', 'radio', 'read']], ['', ['settings']]];
|
||||
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['listen', 'reading', 'video']], ['', ['settings']]];
|
||||
const navGroups = (tabs = navTabs()) => {
|
||||
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
|
||||
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
|
||||
return out.filter(g => g.tabs.length);
|
||||
};
|
||||
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
|
||||
const PRIMARY = ['home', 'files', 'planner', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
||||
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
|
||||
// The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More".
|
||||
const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail'];
|
||||
const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); };
|
||||
const navRecent = () => { try { const l = JSON.parse(localStorage.getItem(nk('nav_recent'))); return Array.isArray(l) ? l : []; } catch { return []; } };
|
||||
const navTouch = id => { if (!id || id === 'home' || id === 'settings') return; try { localStorage.setItem(nk('nav_recent'), JSON.stringify([id, ...navRecent().filter(x => x !== id)].slice(0, 8))); } catch {} };
|
||||
const phone = matchMedia('(max-width: 767px)');
|
||||
let activeTab = 'home';
|
||||
|
||||
@@ -182,10 +196,10 @@ function renderNav(active = activeTab) {
|
||||
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
||||
const mine = navTabs();
|
||||
if (phone.matches) {
|
||||
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
|
||||
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
|
||||
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
|
||||
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
|
||||
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
|
||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
|
||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
|
||||
} else {
|
||||
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
||||
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
|
||||
@@ -193,14 +207,48 @@ function renderNav(active = activeTab) {
|
||||
}
|
||||
phone.addEventListener('change', () => renderNav());
|
||||
|
||||
function openMore() {
|
||||
const rest = navTabs().filter(t => !PRIMARY.includes(t[0]));
|
||||
const rowOf = ([id, label]) => `<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `<i class="nbadge" style="position:static">${id === 'signal' ? sg.unread : mailUnread}</i>` : ''}${icon('chev', 'chev')}</a>`;
|
||||
const el = openSheet('More', '', navGroups(rest).map(g => `${g.label ? `<h2 style="margin:14px 0 6px">${esc(g.label)}</h2>` : ''}<div class="list" ${g.label ? '' : 'style="margin-top:10px"'}>${g.tabs.map(rowOf).join('')}</div>`).join(''));
|
||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||
/** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */
|
||||
function openLauncher() {
|
||||
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
|
||||
const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`;
|
||||
const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div>
|
||||
<div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`);
|
||||
el.querySelector('.sheet').classList.add('launcher');
|
||||
const box = el.querySelector('#lnb'), draw = q => {
|
||||
q = q.trim().toLowerCase();
|
||||
if (q) { const hits = leafTabs().filter(t => t[1].toLowerCase().includes(q)).map(([id, l]) => [id, l]); box.innerHTML = hits.length ? `<div class="lgrid">${hits.map(tile).join('')}</div>` : '<p class="empty">No app matches.</p>'; }
|
||||
else {
|
||||
const rec = navRecent().map(id => tabs.find(t => t[0] === id)).filter(Boolean).slice(0, 4);
|
||||
box.innerHTML = (rec.length ? `<h2>Recent</h2><div class="lgrid">${rec.map(tile).join('')}</div>` : '') + navGroups(tabs).map(g => `${g.label ? `<h2>${esc(g.label)}</h2>` : ''}<div class="lgrid" ${g.label ? '' : 'style="margin-top:14px"'}>${g.tabs.map(tile).join('')}</div>`).join('');
|
||||
}
|
||||
box.querySelectorAll('a.lt').forEach(a => a.addEventListener('click', closeSheet));
|
||||
};
|
||||
draw(''); el.querySelector('#lnq').oninput = e => draw(e.target.value); setTimeout(() => { if (document.activeElement && document.activeElement.id === 'lnq') document.activeElement.blur(); }, 0); // (no keyboard popping up until you tap the box)
|
||||
el.querySelector('#lnpin').onclick = () => { closeSheet(); openPinsSheet(); };
|
||||
}
|
||||
/** Choose which entries sit on the phone's bottom bar (up to five, in the order you tap them). */
|
||||
function openPinsSheet() {
|
||||
const tabs = navTabs(); let pins = navPins();
|
||||
const el = openSheet('Bottom bar', 'Tap up to five apps for the bar, in the order you want them. Everything else stays under More.', '<div class="list" id="pnl"></div><div class="bar"><button class="btn ghost" id="pnr" style="flex:1;justify-content:center">Reset</button><button class="btn" id="pns" style="flex:1;justify-content:center">Save</button></div>');
|
||||
const draw = () => { el.querySelector('#pnl').innerHTML = tabs.map(([id, label]) => { const at = pins.indexOf(id); return `<button class="row" data-id="${id}"><span class="ic" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}</span><span class="grow"><b>${esc(label)}</b></span><span class="pinno${at >= 0 ? ' on' : ''}">${at >= 0 ? at + 1 : ''}</span></button>`; }).join('');
|
||||
el.querySelectorAll('#pnl .row').forEach(b => b.onclick = () => { const id = b.dataset.id; if (pins.includes(id)) pins = pins.filter(x => x !== id); else if (pins.length >= 5) return toast('The bar has room for five'); else pins.push(id); draw(); }); };
|
||||
draw();
|
||||
el.querySelector('#pnr').onclick = () => { pins = DEFAULT_PINS.filter(id => tabs.some(t => t[0] === id)); draw(); };
|
||||
el.querySelector('#pns').onclick = () => { store.set('nav_pins', pins.length ? pins : DEFAULT_PINS); closeSheet(); renderNav(); toast('Bottom bar saved'); };
|
||||
}
|
||||
/** Where a "Listen" or "Read" tap goes: the part you used last. */
|
||||
const lastSub = g => { let l = null; try { l = JSON.parse(localStorage.getItem(nk('nav_last_' + g))); } catch {} return MERGED[g][1].includes(l) && allowed(l) ? l : MERGED[g][1].find(allowed); };
|
||||
/** The row of buttons under the top bar that switches between the parts of Listen and Read. */
|
||||
function drawSubnav(name) {
|
||||
const el = $('#subnav'); if (!el) return; const g = leafOf(name), items = g ? MERGED[g][1].filter(allowed) : [];
|
||||
if (!g || items.length < 2) { el.hidden = true; el.innerHTML = ''; return; }
|
||||
try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {}
|
||||
el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false;
|
||||
}
|
||||
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
|
||||
let curRoute = '';
|
||||
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||
let editor;
|
||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
||||
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||
@@ -228,7 +276,7 @@ function drawNav() {
|
||||
$('#nvf').onclick = () => history.forward();
|
||||
}
|
||||
$('#nvb').disabled = !canBack; $('#nvf').disabled = !canFwd;
|
||||
const t = TABS.find(x => x[0] === activeTab); $('#nvt').textContent = t ? t[1] : '';
|
||||
const t = TABS.find(x => x[0] === (curRoute || activeTab)); $('#nvt').textContent = t ? t[1] : '';
|
||||
el.hidden = false;
|
||||
}
|
||||
addEventListener('popstate', e => { if (e.state && e.state.i != null) { navIdx = e.state.i; drawNav(); } });
|
||||
@@ -244,7 +292,8 @@ function route() {
|
||||
document.body.classList.remove('inedit', 'onaudio');
|
||||
sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own)
|
||||
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
|
||||
renderNav(name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : name);
|
||||
curRoute = name; navTouch(navIdOf(name)); drawSubnav(name);
|
||||
renderNav(navIdOf(name));
|
||||
drawNav();
|
||||
routes[name](rest.map(decodeURIComponent));
|
||||
sgDockSync(name);
|
||||
@@ -273,7 +322,9 @@ function homeApps() {
|
||||
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'),
|
||||
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
|
||||
};
|
||||
return navGroups().filter(g => g.tabs.some(t => T[t[0]])).map(g => `<h2>${esc(g.label || 'Apps')}</h2><div class="grid">${g.tabs.map(t => T[t[0]] ? T[t[0]]() : '').join('')}</div>`).join('');
|
||||
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
|
||||
const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video']]];
|
||||
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
|
||||
}
|
||||
|
||||
function viewHome() {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
// ---- Bible: the Berean Standard Bible (public domain, https://berean.bible/). The text is one file the browser loads once (public/vendor/bible/bsb.json) and keeps,
|
||||
// so reading is instant and works offline. Your place, highlights and notes are kept on the server, so they follow you between devices.
|
||||
const bbApi = (p, opts) => jfetch('/api/bible' + p, opts);
|
||||
const bbOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||
const BB_COLORS = { yellow: '#ffd43b', green: '#69db7c', blue: '#74c0fc', pink: '#f783ac' };
|
||||
const BB_ABBR = { gen: 'GEN', ge: 'GEN', gn: 'GEN', ex: 'EXO', exo: 'EXO', exod: 'EXO', lev: 'LEV', lv: 'LEV', num: 'NUM', nm: 'NUM', nu: 'NUM', deut: 'DEU', dt: 'DEU', deu: 'DEU', josh: 'JOS', jos: 'JOS', judg: 'JDG', jdg: 'JDG', jg: 'JDG', ru: 'RUT', '1sam': '1SA', '1sa': '1SA', '2sam': '2SA', '2sa': '2SA', '1ki': '1KI', '1kgs': '1KI', '1kings': '1KI', '2ki': '2KI', '2kgs': '2KI', '2kings': '2KI', '1chr': '1CH', '1ch': '1CH', '2chr': '2CH', '2ch': '2CH',
|
||||
neh: 'NEH', est: 'EST', esth: 'EST', ps: 'PSA', psa: 'PSA', pss: 'PSA', psalm: 'PSA', psalms: 'PSA', prov: 'PRO', pr: 'PRO', pro: 'PRO', eccl: 'ECC', ecc: 'ECC', qoh: 'ECC', song: 'SNG', sos: 'SNG', sng: 'SNG', songofsongs: 'SNG', isa: 'ISA', is: 'ISA', jer: 'JER', lam: 'LAM', ezek: 'EZK', eze: 'EZK', ezk: 'EZK', dan: 'DAN', dn: 'DAN', hos: 'HOS', joel: 'JOL', jol: 'JOL', amos: 'AMO', amo: 'AMO', obad: 'OBA', oba: 'OBA', jonah: 'JON', jon: 'JON', mic: 'MIC', nah: 'NAM', nam: 'NAM', hab: 'HAB', zeph: 'ZEP', zep: 'ZEP', hag: 'HAG', zech: 'ZEC', zec: 'ZEC', mal: 'MAL',
|
||||
matt: 'MAT', mt: 'MAT', mat: 'MAT', mark: 'MRK', mk: 'MRK', mrk: 'MRK', luke: 'LUK', lk: 'LUK', luk: 'LUK', john: 'JHN', jn: 'JHN', jhn: 'JHN', ac: 'ACT', act: 'ACT', rom: 'ROM', ro: 'ROM', rm: 'ROM', '1cor': '1CO', '1co': '1CO', '2cor': '2CO', '2co': '2CO', gal: 'GAL', ga: 'GAL', eph: 'EPH', phil: 'PHP', php: 'PHP', pp: 'PHP', col: 'COL', '1thess': '1TH', '1th': '1TH', '2thess': '2TH', '2th': '2TH', '1tim': '1TI', '1ti': '1TI', '2tim': '2TI', '2ti': '2TI', tit: 'TIT', philem: 'PHM', phm: 'PHM', heb: 'HEB', jas: 'JAS', jm: 'JAS', '1pet': '1PE', '1pe': '1PE', '2pet': '2PE', '2pe': '2PE', '1john': '1JN', '1jn': '1JN', '2john': '2JN', '2jn': '2JN', '3john': '3JN', '3jn': '3JN', jud: 'JUD', rev: 'REV', re: 'REV', revelation: 'REV' };
|
||||
let bb = null, bbLoading = null, bbSt = { pos: null, marks: [] }, bbSel = new Set(), bbCur = null, bbPosT = 0;
|
||||
const bbPrefs = () => { try { return { size: 19, serif: true, ...JSON.parse(localStorage.getItem('bb-prefs') || '{}') }; } catch { return { size: 19, serif: true }; } };
|
||||
const bbSetPrefs = p => { try { localStorage.setItem('bb-prefs', JSON.stringify(p)); } catch {} };
|
||||
const bbUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
const bbBook = id => bb.books.find(b => b.id === id);
|
||||
const bbRef = (b, c, v, ve) => `${bbBook(b).name} ${c}${v ? ':' + v + (ve && ve > v ? '-' + ve : '') : ''}`;
|
||||
|
||||
function bbLoad() {
|
||||
if (bb) return Promise.resolve(bb);
|
||||
return bbLoading || (bbLoading = fetch('/vendor/bible/bsb.json').then(r => { if (!r.ok) throw new Error('Could not load the Bible text'); return r.json(); }).then(d => { bb = d; return d; }).catch(e => { bbLoading = null; throw e; }));
|
||||
}
|
||||
/** "John 3:16", "jn 3", "1 cor 13:4-7", "Psalm 23": a book (and chapter, and verse) typed in words. */
|
||||
function bbParseRef(q) {
|
||||
const m = /^\s*((?:[1-3]\s*)?[a-z][a-z .]*?)\s*(\d+)?\s*(?:[:.]\s*(\d+)(?:\s*[-–]\s*(\d+))?)?\s*$/i.exec(q.replace(/\bchapter\b|\bverse\b/gi, ' ')); if (!m) return null;
|
||||
const key = m[1].toLowerCase().replace(/[^a-z0-9]/g, ''); if (key.length < 2) return null;
|
||||
let id = BB_ABBR[key]; if (!id) { const hit = bb.books.find(b => b.name.toLowerCase().replace(/[^a-z0-9]/g, '') === key) || (key.length >= 3 ? bb.books.find(b => b.name.toLowerCase().replace(/[^a-z0-9]/g, '').startsWith(key)) : null); id = hit && hit.id; }
|
||||
const book = id && bbBook(id); if (!book) return null; const c = Math.min(book.chapters.length, Math.max(1, +m[2] || 1)), v = m[3] ? +m[3] : 0;
|
||||
return { b: book.id, c, v, ve: m[4] ? +m[4] : v };
|
||||
}
|
||||
|
||||
// ---- drawing a chapter
|
||||
function bbVerseHtml(n, parts, notes, showNo) {
|
||||
const poem = parts.some(Array.isArray), vn = `<sup class="vn">${n}</sup>`, mark = p => `<sup class="fn" data-v="${n}" data-i="${p - 1}" role="button" aria-label="Footnote">${String.fromCharCode(96 + Math.min(p, 26))}</sup>`;
|
||||
if (!poem) return (showNo ? vn : '') + parts.map(p => typeof p === 'string' ? (p === '\n' ? '<br>' : esc(p)) : mark(p)).join('');
|
||||
const lines = []; // poetry: one line per part; a footnote marker stays at the end of its line
|
||||
for (const p of parts) {
|
||||
if (Array.isArray(p)) lines.push({ l: p[0], h: esc(p[1]) });
|
||||
else if (p === '\n') continue;
|
||||
else { if (!lines.length) lines.push({ l: 1, h: '' }); lines[lines.length - 1].h += typeof p === 'string' ? esc(p) : mark(p); }
|
||||
}
|
||||
return lines.map((x, i) => `<span class="pl l${x.l}">${i === 0 && showNo ? vn : ''}${x.h}</span>`).join('');
|
||||
}
|
||||
function bbChapterHtml(book, c) {
|
||||
let html = '', para = ''; const flush = () => { if (para.trim()) html += `<p class="bp">${para}</p>`; para = ''; };
|
||||
for (const b of book.chapters[c - 1]) {
|
||||
if (b[0] === 'h') { flush(); html += `<h3 class="bh">${esc(b[1])}</h3>`; }
|
||||
else if (b[0] === 'p') flush();
|
||||
else if (b[0] === 's') { flush(); html += `<p class="bsub">${esc(b[1])}</p>`; }
|
||||
else if (b[0] === 'v') {
|
||||
if (b[2].some(Array.isArray)) { flush(); html += `<div class="bpoem vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
|
||||
else para += `<span class="vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `;
|
||||
}
|
||||
}
|
||||
flush(); return html;
|
||||
}
|
||||
const bbMarksHere = (b, c) => bbSt.marks.filter(m => m.b === b && m.c === c && !m.del);
|
||||
function bbPaint(root) { // highlights, notes and picked verses, on top of the text
|
||||
const here = bbMarksHere(bbCur.b, bbCur.c);
|
||||
root.querySelectorAll('.vs').forEach(el => {
|
||||
const v = +el.dataset.v, hl = here.find(m => m.type === 'hl' && m.v === v), nt = here.some(m => m.type === 'note' && v >= m.v && v <= m.ve);
|
||||
el.className = el.className.replace(/\b(hl-\w+|hasnote|pick)\b/g, '').replace(/\s+/g, ' ').trim() + (hl ? ' hl-' + hl.color : '') + (nt ? ' hasnote' : '') + (bbSel.has(v) ? ' pick' : '');
|
||||
});
|
||||
bbDrawBar();
|
||||
}
|
||||
|
||||
async function viewBible(args = []) {
|
||||
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty">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 {}
|
||||
if (args[0] === 'marks') return bbMarks();
|
||||
let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0;
|
||||
if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); }
|
||||
const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set();
|
||||
const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b);
|
||||
const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1];
|
||||
v.innerHTML = `<div class="wrap bbwrap"><div class="bbbar"><button class="btn ghost" id="bbpick" aria-label="Choose a book and chapter">${icon('bible')}<b>${esc(name)} ${c}</b></button><span class="grow"></span>
|
||||
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
|
||||
<article id="bbtext" class="bbtext ${P.serif ? 'serif' : ''}" style="font-size:${P.size}px">${bbChapterHtml(book, c)}</article>
|
||||
<div class="bbnav">${prev ? `<a class="btn ghost" href="${href(prev)}">${icon('back')}${esc(bbBook(prev[0]).name)} ${prev[1]}</a>` : '<span></span>'}${next ? `<a class="btn ghost" href="${href(next)}">${esc(bbBook(next[0]).name)} ${next[1]}${icon('chev')}</a>` : '<span></span>'}</div>
|
||||
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
|
||||
const root = $('#bbtext'); bbPaint(root);
|
||||
if (vs) { const el = root.querySelector(`.vs[data-v="${vs}"]`); if (el) { setTimeout(() => el.scrollIntoView({ block: 'center' }), 50); el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 2200); } }
|
||||
root.addEventListener('click', e => {
|
||||
const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); return; }
|
||||
const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root);
|
||||
});
|
||||
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbaa').onclick = () => bbTextSheet(root);
|
||||
let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true });
|
||||
root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } });
|
||||
bbCur.next = next && href(next); bbCur.prev = prev && href(prev);
|
||||
const here = { b, c, v: 0, t: Date.now() }; try { localStorage.setItem(nk('bb-last'), JSON.stringify(here)); } catch {}
|
||||
clearTimeout(bbPosT); bbPosT = setTimeout(() => { fetch('/api/bible/pos', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(here) }).catch(() => {}); bbSt.pos = here; }, 1000);
|
||||
}
|
||||
addEventListener('keydown', e => { if (!location.hash.startsWith('#/bible') || !bbCur || /^(INPUT|TEXTAREA|SELECT)$/.test((e.target.tagName || '')) || document.getElementById('sheet')) return; if (e.key === 'ArrowRight' && bbCur.next) location.hash = bbCur.next; else if (e.key === 'ArrowLeft' && bbCur.prev) location.hash = bbCur.prev; });
|
||||
const bbNote = (b, c, v, i) => { const blk = bbBook(b).chapters[c - 1].find(x => x[0] === 'v' && x[1] === v); return blk && blk[3] ? blk[3][i] : ''; };
|
||||
|
||||
// ---- the verses you tapped: highlight, note, copy
|
||||
function bbDrawBar() {
|
||||
const bar = $('#bbsel'); if (!bar) return; if (!bbSel.size) { bar.hidden = true; return; }
|
||||
const vs = [...bbSel].sort((a, b) => a - b), hasHl = bbMarksHere(bbCur.b, bbCur.c).some(m => m.type === 'hl' && bbSel.has(m.v));
|
||||
bar.hidden = false; bar.innerHTML = `<span class="bbref">${esc(bbRef(bbCur.b, bbCur.c, vs[0], vs[vs.length - 1]))}${vs.length > 1 && vs[vs.length - 1] - vs[0] + 1 !== vs.length ? ' …' : ''}</span>` +
|
||||
Object.entries(BB_COLORS).map(([c, hex]) => `<button class="rdcol" data-c="${c}" style="background:${hex}" aria-label="Highlight ${c}"></button>`).join('') +
|
||||
`${hasHl ? `<button class="rdsb" data-a="clear">${icon('x')}Clear</button>` : ''}<button class="rdsb" data-a="note">${icon('edit')}Note</button><button class="rdsb" data-a="copy">${icon('copy')}Copy</button>`;
|
||||
bar.querySelectorAll('.rdcol').forEach(b => b.onclick = () => bbHighlight(b.dataset.c)); bar.querySelector('[data-a=copy]').onclick = bbCopy; bar.querySelector('[data-a=note]').onclick = () => bbNoteDialog(null);
|
||||
const cl = bar.querySelector('[data-a=clear]'); if (cl) cl.onclick = () => bbHighlight(null);
|
||||
}
|
||||
const bbVerseText = (b, c, v) => { const blk = bbBook(b).chapters[c - 1].find(x => x[0] === 'v' && x[1] === v); return blk ? blk[2].map(p => typeof p === 'string' ? (p === '\n' ? ' ' : p) : typeof p === 'number' ? '' : p[1]).join(' ').replace(/\s+/g, ' ').trim() : ''; };
|
||||
async function bbSaveMarks(list) { try { bbSt.marks = await bbApi('/marks', bbOpt('POST', list)); } catch (e) { toast('Could not save: ' + e.message); } }
|
||||
async function bbHighlight(color) {
|
||||
const { b, c } = bbCur, now = Date.now(), out = [];
|
||||
for (const v of bbSel) { const old = bbSt.marks.find(m => m.b === b && m.c === c && m.type === 'hl' && m.v === v && !m.del);
|
||||
if (color === null) { if (old) out.push({ ...old, del: true, t: now }); } else out.push({ id: old ? old.id : bbUid(), t: now, type: 'hl', b, c, v, ve: v, color, text: bbVerseText(b, c, v) }); }
|
||||
bbSel = new Set(); if (out.length) { for (const m of out) { const i = bbSt.marks.findIndex(x => x.id === m.id); if (i >= 0) bbSt.marks[i] = m; else bbSt.marks.push(m); } bbPaint($('#bbtext')); await bbSaveMarks(out); bbPaint($('#bbtext')); } else bbPaint($('#bbtext'));
|
||||
}
|
||||
function bbCopy() {
|
||||
const { b, c } = bbCur, vs = [...bbSel].sort((a, b) => a - b); const text = vs.map(v => (vs.length > 1 ? v + ' ' : '') + bbVerseText(b, c, v)).join(' ');
|
||||
navigator.clipboard?.writeText(`${text} — ${bbRef(b, c, vs[0], vs[vs.length - 1])} (BSB)`).then(() => toast('Copied'), () => toast('Could not copy')); bbSel = new Set(); bbPaint($('#bbtext'));
|
||||
}
|
||||
function bbNoteDialog(existing) { // a note on the picked verses, or edit one that exists
|
||||
const m = existing, vs = m ? null : [...bbSel].sort((a, b) => a - b), b = m ? m.b : bbCur.b, c = m ? m.c : bbCur.c, v = m ? m.v : vs[0], ve = m ? m.ve : vs[vs.length - 1];
|
||||
const el = openSheet(bbRef(b, c, v, ve), '', `<blockquote class="rdq">${esc([...Array(ve - v + 1)].map((_, i) => bbVerseText(b, c, v + i)).join(' ').slice(0, 400))}</blockquote><label>Note</label><textarea id="bbnt" rows="5" maxlength="5000" placeholder="Write a note">${esc(m ? m.note : '')}</textarea>
|
||||
<div class="bar">${m ? '<button class="btn danger" id="bbnd" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" id="bbns" style="flex:1;justify-content:center">Save</button></div>`);
|
||||
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('#bbns').onclick = async () => { const note = el.querySelector('#bbnt').value.trim(); closeSheet(); const mk = { id: m ? m.id : bbUid(), t: Date.now(), type: 'note', b, c, v, ve, note, color: 'yellow', text: bbVerseText(b, c, v), del: !note };
|
||||
const i = bbSt.marks.findIndex(x => x.id === mk.id); if (i >= 0) bbSt.marks[i] = mk; else bbSt.marks.push(mk); bbSel = new Set(); await bbSaveMarks([mk]); if ($('#bbtext')) bbPaint($('#bbtext')); else if (location.hash.startsWith('#/bible/marks')) bbMarks(); };
|
||||
if (m) el.querySelector('#bbnd').onclick = async () => { closeSheet(); const mk = { ...m, del: true, t: Date.now() }; bbSt.marks = bbSt.marks.filter(x => x.id !== m.id); await bbSaveMarks([mk]); if (location.hash.startsWith('#/bible/marks')) bbMarks(); };
|
||||
}
|
||||
|
||||
// ---- choosing a place, searching, your highlights and notes, text size
|
||||
function bbPicker() {
|
||||
let testament = bb.books.findIndex(b => b.id === bbCur.b) >= 39 ? 1 : 0, chosen = null;
|
||||
const el = openSheet('Choose a book', '', '<div id="bpk"></div>'), box = el.querySelector('#bpk');
|
||||
const draw = () => {
|
||||
if (chosen) { const book = bbBook(chosen); box.innerHTML = `<p class="sub" style="margin:0 0 10px"><button class="btn ghost" id="bpb" style="min-height:36px">${icon('back')}Books</button> <b>${esc(book.name)}</b> · pick a chapter</p><div class="bgrid">${book.chapters.map((_, i) => `<a href="#/bible/${chosen}/${i + 1}" class="${chosen === bbCur.b && i + 1 === bbCur.c ? 'on' : ''}">${i + 1}</a>`).join('')}</div>`; box.querySelector('#bpb').onclick = () => { chosen = null; draw(); }; box.querySelectorAll('.bgrid a').forEach(a => a.addEventListener('click', closeSheet)); return; }
|
||||
const list = bb.books.slice(testament ? 39 : 0, testament ? 66 : 39);
|
||||
box.innerHTML = `<div class="seg" style="margin-top:0"><a href="#" data-t="0" class="${testament ? '' : 'on'}">Old Testament</a><a href="#" data-t="1" class="${testament ? 'on' : ''}">New Testament</a></div><div class="bbooks">${list.map(b => `<button data-b="${b.id}" class="${b.id === bbCur.b ? 'on' : ''}">${esc(b.name)}</button>`).join('')}</div>`;
|
||||
box.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); testament = +a.dataset.t; draw(); }); box.querySelectorAll('.bbooks button').forEach(b => b.onclick = () => { chosen = b.dataset.b; if (bbBook(chosen).chapters.length === 1) { closeSheet(); location.hash = '#/bible/' + chosen + '/1'; return; } draw(); });
|
||||
};
|
||||
draw();
|
||||
}
|
||||
function bbSearch() {
|
||||
const el = openSheet('Search the Bible', 'A word or phrase, or a place such as John 3:16', '<form><input type="search" id="bbq" placeholder="love, or Romans 8, or 1 Cor 13:4-7" autocomplete="off" autocapitalize="off"></form><div id="bbres" class="list rdlist" style="margin-top:10px"></div>');
|
||||
const inp = el.querySelector('#bbq'), res = el.querySelector('#bbres');
|
||||
const run = () => {
|
||||
const q = inp.value.trim(); if (q.length < 2) { res.innerHTML = ''; return; }
|
||||
const ref = bbParseRef(q), words = q.toLowerCase().split(/\s+/).filter(Boolean), hits = []; let total = 0;
|
||||
for (const book of bb.books) book.chapters.forEach((ch, ci) => ch.forEach(blk => { if (blk[0] !== 'v') return; const t = bbVerseText(book.id, ci + 1, blk[1]), low = t.toLowerCase(); if (words.every(w => low.includes(w))) { total++; if (hits.length < 120) hits.push({ b: book.id, c: ci + 1, v: blk[1], t }); } }));
|
||||
const mark = t => { let h = esc(t); for (const w of words) h = h.replace(new RegExp('(' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig'), '<mark>$1</mark>'); return h; };
|
||||
res.innerHTML = (ref ? `<a class="row" href="#/bible/${ref.b}/${ref.c}${ref.v ? '/' + ref.v : ''}"><span class="ic" style="background:#8b5a2b">${icon('bible')}</span><span class="grow"><b>Go to ${esc(bbRef(ref.b, ref.c, ref.v, ref.ve))}</b></span>${icon('chev', 'chev')}</a>` : '') +
|
||||
(hits.length ? hits.map(h => `<a class="row" href="#/bible/${h.b}/${h.c}/${h.v}"><span class="grow"><b style="white-space:normal;font-weight:400">${mark(h.t)}</b><small>${esc(bbRef(h.b, h.c, h.v))}</small></span></a>`).join('') + (total > hits.length ? `<p class="sub" style="padding:10px 16px;margin:0">Showing ${hits.length} of ${total}. Add a word to narrow it down.</p>` : `<p class="sub" style="padding:10px 16px;margin:0">${total} verse${total === 1 ? '' : 's'}</p>`) : ref ? '' : '<p class="empty">Nothing found.</p>');
|
||||
res.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||
};
|
||||
let t; inp.oninput = () => { clearTimeout(t); t = setTimeout(run, 250); }; el.querySelector('form').onsubmit = e => { e.preventDefault(); clearTimeout(t); run(); }; inp.focus();
|
||||
}
|
||||
async function bbMarks() {
|
||||
const order = id => bb.books.findIndex(b => b.id === id), list = bbSt.marks.filter(m => !m.del).sort((a, b) => order(a.b) - order(b.b) || a.c - b.c || a.v - b.v);
|
||||
$('#view').innerHTML = `<div class="wrap bbwrap"><div class="ptop"><a class="btn ghost" href="#/bible">${icon('back')}Bible</a><h1 style="margin:0">My highlights and notes</h1></div>${list.length ? `<div class="list">${list.map(m => `<div class="row bbm" data-id="${esc(m.id)}"><button class="grow bbmj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0"><b>${m.type === 'hl' ? `<span class="rddot" style="background:${BB_COLORS[m.color] || BB_COLORS.yellow}"></span>` : icon('edit')} ${esc(bbRef(m.b, m.c, m.v, m.ve))}</b><small style="white-space:normal;display:block">${esc(m.text)}</small>${m.note ? `<span style="white-space:pre-wrap;display:block;margin-top:3px">${esc(m.note)}</span>` : ''}</button>
|
||||
${m.type === 'note' ? `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>` : ''}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>` : '<p class="empty">Nothing yet. Tap verses while reading to highlight them or add a note.</p>'}</div>`;
|
||||
document.querySelectorAll('.bbm').forEach(r => { const m = list.find(x => x.id === r.dataset.id);
|
||||
r.querySelector('.bbmj').onclick = () => { location.hash = `#/bible/${m.b}/${m.c}/${m.v}`; };
|
||||
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => bbNoteDialog(m);
|
||||
r.querySelector('[data-d]').onclick = async () => { const mk = { ...m, del: true, t: Date.now() }; bbSt.marks = bbSt.marks.filter(x => x.id !== m.id); await bbSaveMarks([mk]); bbMarks(); }; });
|
||||
}
|
||||
function bbTextSheet(root) {
|
||||
const P = bbPrefs(), el = openSheet('Text', '', `<label>Size</label><div class="rdstep"><button type="button" class="btn ghost" data-s="-1">A−</button><span id="bbsv">${P.size}</span><button type="button" class="btn ghost" data-s="1">A+</button></div>
|
||||
<label>Font</label><div class="rdseg"><button type="button" data-f="1" class="${P.serif ? 'on' : ''}">Serif</button><button type="button" data-f="0" class="${P.serif ? '' : 'on'}">Sans-serif</button></div><div class="bar"><button class="btn" id="bbok" style="flex:1;justify-content:center">Done</button></div>`);
|
||||
const apply = () => { bbSetPrefs(P); root.style.fontSize = P.size + 'px'; root.classList.toggle('serif', P.serif); };
|
||||
el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { P.size = Math.min(34, Math.max(14, P.size + +b.dataset.s)); el.querySelector('#bbsv').textContent = P.size; apply(); });
|
||||
el.querySelectorAll('[data-f]').forEach(b => b.onclick = () => { P.serif = b.dataset.f === '1'; el.querySelectorAll('[data-f]').forEach(x => x.classList.toggle('on', x === b)); apply(); }); el.querySelector('#bbok').onclick = closeSheet;
|
||||
}
|
||||
+2
-1
@@ -49,7 +49,7 @@
|
||||
<body>
|
||||
<div id="shell">
|
||||
<nav id="nav" aria-label="Main"></nav>
|
||||
<div id="main"><div id="navbar" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
|
||||
<div id="main"><div id="navbar" hidden></div><div id="subnav" class="seg" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="/webauthn.js"></script>
|
||||
@@ -75,6 +75,7 @@
|
||||
<script src="/audiobooks-view.js"></script>
|
||||
<script src="/librivox-view.js"></script>
|
||||
<script src="/radio-view.js"></script>
|
||||
<script src="/bible-view.js"></script>
|
||||
<script src="/save-to-files.js"></script>
|
||||
<script src="/vbv-view.js"></script>
|
||||
<script src="/lyrics-view.js"></script>
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ function openPalette() {
|
||||
let items = [], sel = 0, seq = 0, timer, live = [];
|
||||
const go = h => () => { location.hash = h; };
|
||||
const base = () => {
|
||||
const out = navTabs().map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === id)) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
|
||||
const out = navTabs().flatMap(t => MERGED[t[0]] ? MERGED[t[0]][1].filter(allowed).map(id => [id, tabLabel(id)]) : [t]).map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === (leafOf(id) || id))) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
|
||||
if (phone.matches) out.push({ label: 'Edit the bottom bar', hint: 'View', ic: 'menu', run: () => openPinsSheet(), kw: 'menu tabs pins more' });
|
||||
if (allowed('tasks') && allowed('calendar')) out.push({ label: 'Go to Tasks', hint: 'Planner', ic: 'check', run: go('#/tasks'), kw: 'todo' }, { label: 'Go to Calendar', hint: 'Planner', ic: 'cal', run: go('#/calendar'), kw: 'agenda events' });
|
||||
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
|
||||
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
|
||||
|
||||
@@ -902,3 +902,40 @@ body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-to
|
||||
.rdoplay .art { width: 44px; height: 44px; border-radius: 10px; background: var(--panel2); flex: none; overflow: hidden; display: block; } .rdoplay .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.rdorow .ibtn { margin-right: 6px; }
|
||||
.rdonow { min-height: 40px; } .rdonow small { display: block; color: var(--muted); } .rdonow b { font-size: 17px; }
|
||||
|
||||
/* Menu: Listen/Read sub-buttons, the Apps sheet behind More, choosing the bottom bar */
|
||||
#subnav { display: flex; gap: 6px; overflow-x: auto; margin: 0; padding: 6px calc(var(--sar) + 12px) 2px calc(var(--sal) + 12px); scrollbar-width: none; flex: none; }
|
||||
#subnav::-webkit-scrollbar { display: none; } #subnav[hidden], body.editing #subnav { display: none; }
|
||||
#subnav a { min-height: 36px; padding: 6px 14px; font-size: 14px; }
|
||||
@media (min-width: 768px) { #subnav { padding-left: 24px; } }
|
||||
#sheet .sheet.launcher { height: 92dvh; max-height: 92dvh; display: flex; flex-direction: column; }
|
||||
#sheet .sheet.launcher #lnb { flex: 1; overflow-y: auto; margin: 4px -4px 0; padding: 0 4px; }
|
||||
#sheet .sheet.launcher #lnq { width: 100%; margin-top: 4px; }
|
||||
#sheet .sheet.launcher h2 { margin: 16px 0 10px; }
|
||||
.lgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px 6px; }
|
||||
.lt { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; color: inherit; min-width: 0; }
|
||||
.lti { position: relative; width: 58px; height: 58px; border-radius: 17px; display: grid; place-items: center; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
|
||||
.lti svg { width: 27px; height: 27px; } .lt:active .lti { transform: scale(.94); }
|
||||
.ltl { font-size: 12px; line-height: 1.2; max-width: 100%; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.pinno { width: 26px; height: 26px; border-radius: 50%; border: 2px solid var(--line); display: grid; place-items: center; font-weight: 700; font-size: 13px; flex: none; } .pinno.on { background: var(--accent); border-color: var(--accent); color: var(--accent-t); }
|
||||
|
||||
/* Bible */
|
||||
.bbwrap { max-width: 42rem; }
|
||||
.bbbar { display: flex; align-items: center; gap: 6px; position: sticky; top: -8px; z-index: 3; background: var(--bg); padding: 8px 0; margin-top: -8px; }
|
||||
.bbbar .btn b { margin-left: 6px; }
|
||||
.bbtext { line-height: 1.75; padding-bottom: 8px; } .bbtext.serif { font-family: Georgia, "Times New Roman", serif; }
|
||||
.bbtext .bh { font-size: .82em; font-family: system-ui, sans-serif; text-transform: none; letter-spacing: 0; margin: 1.5em 0 .4em; color: var(--muted); }
|
||||
.bbtext .bsub { font-style: italic; color: var(--muted); margin: .2em 0 .8em; } .bbtext .bp { margin: 0 0 .9em; }
|
||||
.bbtext .bpoem { margin: 0 0 .5em; } .bbtext .pl { display: block; padding-left: 1.2em; text-indent: -1.2em; } .bbtext .pl.l2 { padding-left: 2.4em; }
|
||||
.bbtext .vn { font-size: .6em; font-weight: 700; color: var(--accent); margin-right: .25em; vertical-align: super; font-family: system-ui, sans-serif; }
|
||||
.bbtext .fn { font-size: .6em; font-weight: 700; color: var(--accent); margin: 0 .1em; cursor: pointer; vertical-align: super; font-family: system-ui, sans-serif; }
|
||||
.bbtext .vs { border-radius: 3px; -webkit-box-decoration-break: clone; box-decoration-break: clone; cursor: pointer; }
|
||||
.bbtext .vs.pick { background: rgba(61,107,240,.25); } .bbtext .vs.flash { background: rgba(232,89,12,.28); } .bbtext .vs.hasnote { border-bottom: 2px dotted var(--accent); }
|
||||
.bbtext .hl-yellow { background: #ffd43b66; } .bbtext .hl-green { background: #69db7c66; } .bbtext .hl-blue { background: #74c0fc66; } .bbtext .hl-pink { background: #f783ac66; }
|
||||
.bbnav { display: flex; justify-content: space-between; gap: 8px; margin-top: 24px; }
|
||||
.bbsel.rdsel { position: fixed; bottom: calc(env(safe-area-inset-bottom) + 84px); flex-wrap: wrap; justify-content: center; border-radius: 20px; z-index: 15; }
|
||||
.bbref { font-weight: 600; font-size: 13px; white-space: nowrap; width: 100%; text-align: center; }
|
||||
.bbooks { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 4px; max-height: 58dvh; overflow: auto; }
|
||||
.bbooks button { min-height: 44px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; text-align: left; } .bbooks button.on { background: var(--accent); color: var(--accent-t); }
|
||||
.bgrid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; max-height: 58dvh; overflow: auto; }
|
||||
.bgrid a { display: grid; place-items: center; height: 46px; border-radius: 10px; background: var(--panel2); color: inherit; font-weight: 600; } .bgrid a.on { background: var(--accent); color: var(--accent-t); }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v44';
|
||||
const CACHE = 'hub-v45';
|
||||
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'];
|
||||
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"id":"BSB","name":"Berean Standard Bible","license":"Public domain (dedicated by Bible Hub / Berean Bible, April 30, 2023)","source":"https://berean.bible/","books":[{"id":"GEN","name":"Genesis","chapters":[[["h","The Creation"],["v",1,["In the beginning God created the heavens and the earth."]],["p"],["v",2,["Now the earth was formless and void, and darkness was over the surface of the deep. And the Spirit of God was hovering over the surface of the waters."]],["h","The First Day"],["p"],["v",3,["And God said, “Let there be light,”",1,"and there was light."],["Cited in 2 Corinthians 4:6"]],["v",4,["And God saw that the light was good, and He separated the light from the darkness."]],["v",5,["God called the light “day,” and the darkness He called “night.”","\n","And there was evening, and there was morning—the first day.",1],["Literally day one"]],["h","The Second Day"],["p"],["v",6,["And God said, “Let there be an expanse",1,"between the waters, to separate the waters from the waters.”"],["Or a canopy or a firmament or a vault; also in verses 7, 8, 14, 15, 17, and 20"]],["v",7,["So God made the expanse and separated the waters beneath it from the waters above. And it was so."]],["v",8,["God called the expanse “sky.”","\n","And there was evening, and there was morning—the second day."]],["h","The Third Day"],["p"],["v",9,["And God said, “Let the waters under the sky be gathered into one place, so that the dry land may appear.” And it was so."]],["v",10,["God called the dry land “earth,” and the gathering of waters He called “seas.” And God saw that it was good."]],["p"],["v",11,["Then God said, “Let the earth bring forth vegetation: seed-bearing plants and fruit trees, each bearing fruit with seed according to its kind.” And it was so."]],["v",12,["The earth produced vegetation: seed-bearing plants according to their kinds and trees bearing fruit with seed according to their kinds. And God saw that it was good."]],["p"],["v",13,["And there was evening, and there was morning—the third day."]],["h","The Fourth Day"],["p"],["v",14,["And God said, “Let there be lights in the expanse of the sky to distinguish between the day and the night, and let them be signs to mark the seasons and days and years."]],["v",15,["And let them serve as lights in the expanse of the sky to shine upon the earth.” And it was so."]],["p"],["v",16,["God made two great lights: the greater light to rule the day and the lesser light to rule the night. And He made the stars as well."]],["p"],["v",17,["God set these lights in the expanse of the sky to shine upon the earth,"]],["v",18,["to preside over the day and the night, and to separate the light from the darkness. And God saw that it was good."]],["p"],["v",19,["And there was evening, and there was morning—the fourth day."]],["h","The Fifth Day"],["p"],["v",20,["And God said, “Let the waters teem with living creatures, and let birds fly above the earth in the open expanse of the sky.”"]],["v",21,["So God created the great sea creatures and every living thing that moves, with which the waters teemed according to their kinds, and every winged bird after its kind. And God saw that it was good."]],["p"],["v",22,["Then God blessed them and said, “Be fruitful and multiply and fill the waters of the seas, and let birds multiply on the earth.”"]],["p"],["v",23,["And there was evening, and there was morning—the fifth day."]],["h","The Sixth Day"],["p"],["v",24,["And God said, “Let the earth bring forth living creatures according to their kinds: livestock, land crawlers, and beasts of the earth according to their kinds.” And it was so."]],["v",25,["God made the beasts of the earth according to their kinds, the livestock according to their kinds, and everything that crawls upon the earth according to its kind. And God saw that it was good."]],["p"],["v",26,["Then God said, “Let Us make man in Our image, after Our likeness, to rule over the fish of the sea and the birds of the air, over the livestock, and over all the earth itself",1,"and every creature that crawls upon it.”"],["MT; Syriac and over all the beasts of the earth"]],["p"],["v",27,[[1,"So God created man in His own image;"],[2,"in the image of God He created him;"],"\n",[2,"male and female He created them."],1],["Cited in Matthew 19:4 and Mark 10:6"]],["p"],["v",28,["God blessed them and said to them, “Be fruitful and multiply, and fill the earth and subdue it; rule over the fish of the sea and the birds of the air and every creature that crawls upon the earth.”"]],["p"],["v",29,["Then God said, “Behold, I have given you every seed-bearing plant on the face of all the earth, and every tree whose fruit contains seed. They will be yours for food."]],["v",30,["And to every beast of the earth and every bird of the air and every creature that crawls upon the earth—everything that has the breath of life in it—I have given every green plant for food.” And it was soLine truncated
|
||||
Reference in new issue
Block a user