Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
378 lines
31 KiB
JavaScript
378 lines
31 KiB
JavaScript
'use strict';
|
|
// If the server says the passkey session is gone, go back to the sign-in page.
|
|
const _fetch = window.fetch.bind(window);
|
|
window.fetch = async (...a) => {
|
|
const r = await _fetch(...a);
|
|
if (r.status === 401 && r.headers.get('x-hub-auth') === 'required') location.replace('/login');
|
|
return r;
|
|
};
|
|
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', 'nav_pins']);
|
|
const outbox = new Map(); let outTimer = null, warned = false;
|
|
function flushSettings() {
|
|
outTimer = null;
|
|
for (const [key, value] of [...outbox]) {
|
|
outbox.delete(key);
|
|
fetch('/api/settings', { method: 'PUT', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }) })
|
|
.then(r => { if (!r.ok) throw new Error('save failed'); })
|
|
.catch(() => { if (!warned) { warned = true; toast('Could not save your settings to the server. They are kept on this device for now.'); } });
|
|
}
|
|
}
|
|
const queueSetting = (k, v) => { if (!SYNCED.has(k)) return; outbox.set(k, v); clearTimeout(outTimer); outTimer = setTimeout(flushSettings, 400); };
|
|
addEventListener('pagehide', () => { if (outbox.size) flushSettings(); });
|
|
// Several people can use the same browser, so everything kept in the browser is filed under the signed-in person.
|
|
let ME = null;
|
|
const nk = k => (ME ? ME.id + ':' + k : k);
|
|
const store = {
|
|
get(k, d) { try { let v = localStorage.getItem(nk(k)); if (v === null && ME && ME.role === 'admin') v = localStorage.getItem(k); /* kept from before accounts existed */ return JSON.parse(v) ?? d; } catch { return d; } },
|
|
set(k, v) { try { localStorage.setItem(nk(k), JSON.stringify(v)); } catch {} queueSetting(k, v); },
|
|
del(k) { try { localStorage.removeItem(nk(k)); if (ME && ME.role === 'admin') localStorage.removeItem(k); } catch {} queueSetting(k, null); },
|
|
};
|
|
async function loadMe() {
|
|
try { const r = await fetch('/api/me'); if (r.ok) { ME = await r.json(); try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} } }
|
|
catch { try { ME = JSON.parse(localStorage.getItem('me_cache')); } catch {} } // offline: the last known access (the server still decides)
|
|
}
|
|
// 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)');
|
|
// 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 {
|
|
const r = await fetch('/api/settings'); if (!r.ok) return;
|
|
const { values } = await r.json();
|
|
for (const k of SYNCED) {
|
|
if (k in values) { try { localStorage.setItem(nk(k), JSON.stringify(values[k])); } catch {} }
|
|
else { const local = store.get(k); if (local !== undefined && local !== null) queueSetting(k, local); }
|
|
}
|
|
} catch {} // offline: keep using what this browser has
|
|
}
|
|
|
|
const ICONS = {
|
|
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
|
|
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
|
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
|
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
|
send: '<path d="M4 12l16-8-6 16-3-7z"/>',
|
|
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
|
|
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
|
|
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
|
|
files: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
|
|
doc: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5M10 13h6M10 17h6"/>',
|
|
cog: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
|
|
chev: '<path d="M9 6l6 6-6 6"/>',
|
|
back: '<path d="M15 6l-6 6 6 6"/>',
|
|
edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/>',
|
|
up: '<path d="M12 16V4M7 9l5-5 5 5M5 20h14"/>',
|
|
newdir: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2zM12 10v6M9 13h6"/>',
|
|
file: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5"/>',
|
|
dl: '<path d="M12 4v12M7 11l5 5 5-5M5 20h14"/>',
|
|
audio: '<path d="M4 14v-2a8 8 0 0116 0v2"/><rect x="3" y="14" width="4" height="7" rx="1.5"/><rect x="17" y="14" width="4" height="7" rx="1.5"/>',
|
|
sort: '<path d="M7 4v16M4 17l3 3 3-3M17 20V4M14 7l3-3 3 3"/>',
|
|
refresh: '<path d="M20 12a8 8 0 11-2.3-5.7M20 4v5h-5"/>',
|
|
full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
|
ext: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"/>',
|
|
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
|
|
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
|
|
rew: '<path d="M12 5V2L7 6l5 4V7a6 6 0 11-6 6"/>',
|
|
fwd: '<path d="M12 5V2l5 4-5 4V7a6 6 0 106 6"/>',
|
|
vol: '<path d="M4 9v6h4l5 4V5L8 9H4zM16.5 8.5a5 5 0 010 7M19 6a8.5 8.5 0 010 12"/>',
|
|
mute: '<path d="M4 9v6h4l5 4V5L8 9H4zM17 9l4 6M21 9l-4 6"/>',
|
|
pip: '<rect x="3" y="5" width="18" height="14" rx="2"/><rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor"/>',
|
|
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
|
|
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
|
|
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
|
|
spin: '<path d="M12 3a9 9 0 109 9" class="spin"/>',
|
|
x: '<path d="M6 6l12 12M18 6L6 18"/>',
|
|
prev: '<path d="M18 5L8 12l10 7zM6 5v14"/>',
|
|
repeat: '<path d="M4 11V9a3 3 0 013-3h12M16 3l3 3-3 3M20 13v2a3 3 0 01-3 3H5M8 21l-3-3 3-3"/>',
|
|
shuffle: '<path d="M3 7h3c4 0 5 10 9 10h6M3 17h3c1.5 0 2.5-1.5 3.5-3M15 7h6M18 4l3 3-3 3M18 14l3 3-3 3"/>',
|
|
more: '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>',
|
|
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
|
|
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
|
|
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
|
|
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
|
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
link: '<path d="M10 14a4 4 0 005.7 0l3-3a4 4 0 00-5.7-5.7l-1 1M14 10a4 4 0 00-5.7 0l-3 3a4 4 0 005.7 5.7l1-1"/>',
|
|
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
|
|
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
|
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"/>',
|
|
};
|
|
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
|
|
|
let cfg = { mail: '', podcast: 'https://podcast.necloud.us/', verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
|
|
|
|
// ---- Seafile client (via same-origin proxy)
|
|
const sf = {
|
|
token: () => store.get('sf_token'),
|
|
/** A call to Seafile through the hub. Reads are retried; a slow or unreachable Seafile gives a clear message; an expired sign-in asks for the
|
|
* password again in place (sfReauth) and then carries on, so you do not lose where you were. */
|
|
async req(path, opts = {}, tries = 0) {
|
|
const { _reauthed, timeout, ...rest } = opts, method = (rest.method || 'GET').toUpperCase(), idem = method === 'GET' || method === 'HEAD';
|
|
const headers = { Authorization: 'Token ' + sf.token(), ...(rest.headers || {}) };
|
|
let r;
|
|
try { r = await fetch('/sf' + path, { ...rest, headers, signal: rest.signal || AbortSignal.timeout(timeout || 45000) }); }
|
|
catch (e) {
|
|
if (idem && tries < 2 && e.name !== 'AbortError' && navigator.onLine) { await sleep(600 * (tries + 1)); return sf.req(path, opts, tries + 1); }
|
|
throw new Error(!navigator.onLine ? 'You are offline' : e.name === 'TimeoutError' ? 'Seafile took too long to answer. Try again in a moment.' : 'Could not reach the server');
|
|
}
|
|
if (r.status === 401 && !_reauthed) { await sfReauth(); return sf.req(path, { ...opts, _reauthed: true }); }
|
|
if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); }
|
|
if (!r.ok) {
|
|
let m = (await r.text().catch(() => '')).slice(0, 200); try { const j = JSON.parse(m); m = j.error || j.error_msg || j.detail || m; } catch {}
|
|
throw new Error(m || (r.status === 504 ? 'Seafile took too long to answer' : r.status >= 500 ? 'Seafile had a problem (' + r.status + ')' : 'Request failed (' + r.status + ')'));
|
|
}
|
|
const t = await r.text();
|
|
try { return JSON.parse(t); } catch { return t; }
|
|
},
|
|
async login(username, password, otp) {
|
|
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
if (otp) headers['X-SEAFILE-OTP'] = otp;
|
|
const r = await fetch('/sf/api2/auth-token/', { method: 'POST', headers, body: new URLSearchParams({ username, password }) });
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
if (r.headers.get('x-seafile-otp') === 'required') throw Object.assign(new Error('Enter your 2FA code'), { otp: true });
|
|
throw new Error((j.non_field_errors || [j.detail || j.error || 'Sign in failed']).join(' '));
|
|
}
|
|
store.set('sf_token', j.token);
|
|
},
|
|
};
|
|
|
|
/** A short message at the bottom; with `action` ({ label, run }) it has a button (Undo, say) and stays a little longer. */
|
|
const toast = (m, action) => {
|
|
const t = $('#toast'); t.textContent = m; t.classList.toggle('act', !!action);
|
|
if (action) { const b = document.createElement('button'); b.textContent = action.label; b.onclick = () => { t.classList.remove('on'); action.run(); }; t.appendChild(b); }
|
|
t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), action ? 9000 : 2600);
|
|
};
|
|
const ext = n => (n.split('.').pop() || '').toLowerCase();
|
|
const EDIT_EXT = new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt']);
|
|
const OFFICE_EXT =new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt', 'pdf']);
|
|
const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + ' KB' : b < 1073741824 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1073741824).toFixed(1) + ' GB';
|
|
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'], ['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', ['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 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';
|
|
|
|
function renderNav(active = activeTab) {
|
|
activeTab = active;
|
|
if (typeof sgDockBadge === 'function') sgDockBadge();
|
|
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
|
|
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
|
|
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
|
|
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
|
const mine = navTabs();
|
|
if (phone.matches) {
|
|
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(); 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 });
|
|
}
|
|
}
|
|
phone.addEventListener('change', () => renderNav());
|
|
|
|
/** 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, 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 {}; } };
|
|
function applyNav() { const p = navPref(); document.body.classList.toggle('nav-mini', !!p.mini); document.body.classList.toggle('nav-hidden', !!p.hidden); }
|
|
function setNav(patch) { try { localStorage.setItem('nav_pref', JSON.stringify({ ...navPref(), ...patch })); } catch {} applyNav(); }
|
|
applyNav();
|
|
addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) { e.preventDefault(); setNav({ hidden: !navPref().hidden }); } });
|
|
// In-app Back / Forward (an installed app has no browser buttons). Each history entry remembers its position so we know what is possible.
|
|
let navIdx = 0, navMax = 0, navStarted = false;
|
|
const parentHash = () => { const p = location.hash.split('/'); return p.length > 3 ? p.slice(0, -1).join('/') : ''; }; // e.g. #/files/lib/a/b -> #/files/lib/a
|
|
function trackNav() {
|
|
const s = history.state;
|
|
if (s && s.i != null) navIdx = s.i;
|
|
else { navIdx = navStarted ? navIdx + 1 : 0; navMax = Math.max(navMax, navIdx); if (navStarted) navMax = navIdx; try { history.replaceState({ ...(s || {}), i: navIdx }, ''); } catch {} }
|
|
navStarted = true; navMax = Math.max(navMax, navIdx);
|
|
}
|
|
function drawNav() {
|
|
const el = $('#navbar'); if (!el) return;
|
|
const canBack = navIdx > 0 || !!parentHash(), canFwd = navIdx < navMax;
|
|
if (!el.dataset.on) {
|
|
el.dataset.on = 1;
|
|
el.innerHTML = `<button class="ibtn navtoggle" id="nvm" aria-label="Show or hide the menu" title="Show or hide the menu (Ctrl/Cmd+B)">${icon('menu')}</button><button class="ibtn" id="nvb" aria-label="Back" title="Back">${icon('back')}</button><button class="ibtn" id="nvf" aria-label="Forward" title="Forward">${icon('chev')}</button><span id="nvt"></span>`;
|
|
$('#nvm').onclick = () => setNav({ hidden: !navPref().hidden });
|
|
$('#nvb').onclick = () => { if (navIdx > 0) history.back(); else if (parentHash()) location.hash = parentHash(); };
|
|
$('#nvf').onclick = () => history.forward();
|
|
}
|
|
$('#nvb').disabled = !canBack; $('#nvf').disabled = !canFwd;
|
|
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(); } });
|
|
function route() {
|
|
trackNav();
|
|
const [, id = 'home', ...rest] = location.hash.split('/');
|
|
const routes = routesOf();
|
|
let name = routes[id] ? id : 'home';
|
|
if (name === 'edit' ? !(allowed('files') || allowed('office')) : !allowed(name)) { name = 'home'; if (location.hash !== '#/home') history.replaceState(null, '', '#/home'); } // not one of their tabs
|
|
try { editor?.destroyEditor(); } catch {}
|
|
editor = null;
|
|
document.body.classList.toggle('editing', name === 'edit');
|
|
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
|
|
curRoute = name; navTouch(navIdOf(name)); drawSubnav(name);
|
|
renderNav(navIdOf(name));
|
|
drawNav();
|
|
routes[name](rest.map(decodeURIComponent));
|
|
sgDockSync(name);
|
|
$('#view').scrollTop = 0;
|
|
}
|
|
|
|
function tile(href, color, ic, title, sub, external) {
|
|
return `<a class="tile" href="${esc(href)}" ${external ? 'target="_blank" rel="noopener"' : ''}>
|
|
<span class="ic" style="background:${color}">${icon(ic)}</span><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
|
|
}
|
|
|
|
// ---- Views
|
|
/** The Home app tiles, grouped by kind like the menu. */
|
|
function homeApps() {
|
|
const T = {
|
|
files: () => tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile'),
|
|
music: () => tile('#/music', '#e5a00d', 'music', 'Music', 'Plex'),
|
|
video: () => tile('#/video', '#c0392b', 'film', 'Movies & TV', 'Plex'),
|
|
planner: () => tile('#/planner', '#d9453a', 'cal', 'Planner', allowed('calendar') && allowed('tasks') ? 'Calendar & tasks' : allowed('tasks') ? 'To-do lists' : 'Agenda'),
|
|
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
|
|
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
|
|
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
|
radio: () => tile('#/radio', '#7b4fd6', 'radio', 'Radio', 'The Journey FM & more'),
|
|
read: () => tile('#/read', '#2b8a5e', 'book', 'Books', 'EPUB, PDF, DOCX'),
|
|
signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'),
|
|
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'),
|
|
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
|
|
};
|
|
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() {
|
|
const h = new Date().getHours();
|
|
const evening = h >= 17 || h < 5; // after work the media comes first; during the day the agenda and tasks do
|
|
const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
|
|
const recents = store.get('recents', []).slice(0, 5);
|
|
const t = typeof cur === 'function' ? cur() : null;
|
|
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
|
$('#view').innerHTML = `<div class="wrap home">
|
|
<header class="homehead"><div><h1>${greet}</h1><p class="sub">${today}</p></div>
|
|
<div class="homeact"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
|
|
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
|
|
${allowed('music') ? '<div id="npslot"></div>' : ''}
|
|
${evening ? '<div id="resume"></div>' : ''}
|
|
<div class="homecols">
|
|
<div class="hcol"><div id="tcard"></div><div id="agenda"></div></div>
|
|
<div class="hcol">${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}
|
|
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}</div>
|
|
</div>
|
|
${evening ? '' : '<div id="resume"></div>'}
|
|
<div class="homeapps">${homeApps()}</div></div>`;
|
|
if (typeof homeResume === 'function') homeResume($('#resume'));
|
|
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
|
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
|
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
|
});
|
|
if ($('#qadd')) { $('#qadd').onclick = () => quickAdd(); $('#qpal').onclick = () => openPalette(); }
|
|
if (allowed('tasks')) renderTasksCard($('#tcard'));
|
|
if ($('#mcard')) renderMailCard($('#mcard'));
|
|
if (allowed('calendar') && typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today');
|
|
}
|
|
|
|
// Name search by listing each library recursively (cached for the session).
|
|
const treeCache = new Map();
|
|
async function walkSearch(repos, term) {
|
|
const out = [];
|
|
for (const r of repos.filter(x => !x.encrypted).slice(0, 12)) {
|
|
try {
|
|
let t = treeCache.get(r.id);
|
|
if (!t || Date.now() - t.at > 120e3) { t = { at: Date.now(), items: await sf.req(`/api2/repos/${encodeURIComponent(r.id)}/dir/?p=/&recursive=1`) }; treeCache.set(r.id, t); }
|
|
for (const it of t.items) if (it.name.toLowerCase().includes(term)) {
|
|
const parent = (it.parent_dir || '/').replace(/\/?$/, '/');
|
|
out.push({ repo_id: r.id, repo_name: r.name, name: it.name, fullpath: parent + it.name, is_dir: it.type === 'dir', size: it.size });
|
|
if (out.length >= 40) return out;
|
|
}
|
|
} catch {}
|
|
}
|
|
return out;
|
|
}
|