'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']);
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)');
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)
/** 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: '',
lyrics: '',
chat: '',
clip: '',
send: '',
menu: '',
home: '',
mail: '',
files: '',
doc: '',
cog: '',
chev: '',
back: '',
edit: '',
up: '',
newdir: '',
file: '',
dl: '',
audio: '',
sort: '',
refresh: '',
full: '',
ext: '',
copy: '',
music: '',
rew: '',
fwd: '',
vol: '',
mute: '',
pip: '',
play: '',
pause: '',
next: '',
spin: '',
x: '',
prev: '',
repeat: '',
shuffle: '',
more: '',
cal: '',
search: '',
book: '',
mic: '',
plus: '',
link: '',
check: '',
trash: '',
};
const icon = (n, cls = '') => ``;
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'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', 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; };
/** 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']], ['', ['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 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 ? `${n > 99 ? '99+' : n}` : '';
const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label}${id === 'signal' ? badge(sg.unread) : id === 'mail' ? badge(mailUnread) : ''}`;
const brand = `
NE Hub `;
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('') +
(rest.length ? `${icon('more')}More${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
} else {
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `${g.label ? `${esc(g.label)}` : ''}
` : '') + g.tabs.map(tab).join('')).join('') + ``;
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
}
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const rest = navTabs().filter(t => !PRIMARY.includes(t[0]));
const rowOf = ([id, label]) => `${icon(TAB_ICON[id])}${label}${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `${id === 'signal' ? sg.unread : mailUnread}` : ''}${icon('chev', 'chev')}`;
const el = openSheet('More', '', navGroups(rest).map(g => `${g.label ? `${esc(g.label)}
` : ''}${g.tabs.map(rowOf).join('')}
`).join(''));
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: () => location.replace('#/links'), podcast: viewPodcast, 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 = ``;
$('#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] === 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
renderNav(name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : name);
drawNav();
routes[name](rest.map(decodeURIComponent));
sgDockSync(name);
$('#view').scrollTop = 0;
}
function tile(href, color, ic, title, sub, external) {
return `
${icon(ic)}${esc(title)}${esc(sub)}`;
}
// ---- 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'),
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'),
};
return navGroups().filter(g => g.tabs.some(t => T[t[0]])).map(g => `${esc(g.label || 'Apps')}
${g.tabs.map(t => T[t[0]] ? T[t[0]]() : '').join('')}
`).join('');
}
function viewHome() {
const h = new Date().getHours();
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 = `
${allowed('search') ? `
${icon('search')}Search tasks, events, files, music` : ''}
${allowed('music') ? '
' : ''}
${allowed('mail') && cfg.mail ? '
' : ''}
${recents.length && allowed('files') ? `
Recent files
${recents.map((r, i) => ``).join('')}
` : ''}
${homeApps()}
`;
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;
}