'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. Per-device things (Seafile token, music queue) stay local.
const SYNCED = new Set(['mail_hints', 'ab_creds', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
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)
}
const allowed = id => !ME || ME.tabs.includes(id);
/** 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 = {
home: ' ',
mail: ' ',
files: ' ',
doc: ' ',
cog: ' ',
chev: ' ',
back: ' ',
edit: ' ',
up: ' ',
newdir: ' ',
file: ' ',
dl: ' ',
audio: ' ',
copy: ' ',
music: ' ',
play: ' ',
pause: ' ',
next: ' ',
prev: ' ',
shuffle: ' ',
more: ' ',
cal: ' ',
search: ' ',
book: ' ',
mic: ' ',
check: ' ',
trash: ' ',
};
const icon = (n, cls = '') => `${ICONS[n]} `;
// ---- Email: providers block iframes and need OAuth apps for API access, so we deep-link to webmail.
const MAIL = [
{ id: 'gmail', name: 'Gmail', color: '#d9453a', inbox: 'https://mail.google.com/mail/u/0/#inbox', compose: 'https://mail.google.com/mail/?view=cm&fs=1' },
{ id: 'outlook', name: 'Outlook.com', color: '#0a64c8', inbox: 'https://outlook.live.com/mail/0/inbox', compose: 'https://outlook.live.com/mail/0/deeplink/compose' },
{ id: 'm365', name: 'Microsoft 365', color: '#d83b01', inbox: 'https://outlook.office.com/mail/inbox', compose: 'https://outlook.office.com/mail/deeplink/compose' },
{ id: 'icloud', name: 'iCloud Mail', color: '#3b97e8', inbox: 'https://www.icloud.com/mail', compose: 'https://www.icloud.com/mail' },
];
// Saved account addresses (browser-only, no passwords) are passed as sign-in hints so the provider opens the right mailbox.
const mailHints = () => store.get('mail_hints', {});
function mailUrl(m, kind) {
const base = m[kind], hint = (mailHints()[m.id] || '').trim();
if (!hint) return base;
const u = new URL(base), q = encodeURIComponent(hint);
if (m.id === 'gmail') { if (u.pathname.startsWith('/mail/u/')) u.pathname = '/mail/'; u.searchParams.set('authuser', hint); return u.toString(); }
if (m.id === 'outlook' || m.id === 'm365') { u.searchParams.set('login_hint', hint); return u.toString(); }
return base; // iCloud has no sign-in hint
}
let cfg = { 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'),
async req(path, opts = {}) {
const headers = { Authorization: 'Token ' + sf.token(), ...(opts.headers || {}) };
const r = await fetch('/sf' + path, { ...opts, headers });
if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); }
if (!r.ok) throw new Error((await r.text().catch(() => '')).slice(0, 200) || '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);
},
};
const toast = (m) => { const t = $('#toast'); t.textContent = m; t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 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'], ['mail', 'Mail'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' };
const PRIMARY = ['home', 'files', 'tasks', '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;
const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label} `;
const brand = `
NE Hub `;
const mine = TABS.filter(t => allowed(t[0]));
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 ` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
} else $('#nav').innerHTML = brand + mine.map(tab).join('');
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const el = openSheet('More', '', ``);
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
let editor;
function route() {
const [, id = 'home', ...rest] = location.hash.split('/');
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');
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
renderNav(name === 'edit' ? 'files' : name);
routes[name](rest.map(decodeURIComponent));
$('#view').scrollTop = 0;
}
function tile(href, color, ic, title, sub, external) {
return `
${icon(ic)} ${esc(title)} ${esc(sub)} `;
}
// ---- Views
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;
$('#view').innerHTML = `${greet}
${allowed('search') ? `
${icon('search')}Search tasks, events, files, music ` : ''}
${t && allowed('music') ? `
Now playing ${esc(t.title)} ${esc(t.artist)} ` : ''}
${recents.length && allowed('files') ? `
Recent files ${recents.map((r, i) => `${icon('file')} ${esc(r.name)} ${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')} `).join('')}
` : ''}
Apps
${allowed('files') ? tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile') : ''}
${allowed('music') ? tile('#/music', '#e5a00d', 'music', 'Music', 'Plex') : ''}
${allowed('tasks') ? tile('#/tasks', '#4f7cff', 'check', 'Tasks', 'To-do lists') : ''}
${allowed('calendar') ? tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda') : ''}
${allowed('office') ? tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice') : ''}
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks') : ''}
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts') : ''}
${allowed('mail') ? `
Mail
${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
` : ''}
`;
const np = $('#hnp'); if (np) np.onclick = openNow;
$('#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 (allowed('tasks')) renderTasksCard($('#tcard'));
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;
}
// ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others)
// Seafile: use the search index when the server has one; if it is missing OR comes back empty, scan file names library by library.
async function searchFiles(term, libs) {
const enc = encodeURIComponent, lower = term.toLowerCase();
let results = [], via = 'index';
try { results = (await sf.req(`/api2/search/?q=${enc(term)}&per_page=20`)).results || []; } catch {}
if (!results.length) { results = await walkSearch(libs, lower); via = 'scan'; }
return { results, via, scanned: libs.filter(l => !l.encrypted).length };
}
// Plex: the server-wide search first; if that finds nothing, filter the artists and albums of the chosen music library directly.
async function searchMusic(term) {
const enc = encodeURIComponent, lower = term.toLowerCase(), items = [];
try {
const mc = await px.get(`/hubs/search?query=${enc(term)}&limit=10`);
for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['artist', 'album', 'track'].includes(m.type)) items.push(m);
} catch {}
if (!items.length) {
const sec = await px.section(), big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
const [artists, albums] = await Promise.all([px.get(`/library/sections/${sec}/all?type=8${big}`), px.get(`/library/sections/${sec}/all?type=9${big}`)]);
for (const a of artists.Metadata || []) if ((a.title || '').toLowerCase().includes(lower)) items.push({ ...a, type: 'artist' });
for (const a of albums.Metadata || []) if (((a.title || '') + ' ' + (a.parentTitle || '')).toLowerCase().includes(lower)) items.push({ ...a, type: 'album' });
}
return items.slice(0, 16);
}
function viewSearch() {
$('#view').innerHTML = ``;
const q = $('#q'); q.focus();
let timer, seq = 0, plexSt = null;
// One line that says what Search covers and what each source returned, with a link to sign in where a source needs it.
const showSources = (r = {}) => {
const el = $('#src'); if (!el) return;
const link = (href, t) => `${t} `, bad = t => `${esc(t)} `;
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)}`;
};
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
showSources();
// Duck.ai and DuckDuckGo refuse to be shown inside other apps, so these hand the question over in a new tab.
const webLinks = () => { const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); };
q.oninput = () => { webLinks(); clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
async function runSearch(term, mySeq) {
const res = $('#res');
if (term.length < 2) { res.innerHTML = ''; showSources(); return; }
res.innerHTML = 'Searching…
';
const enc = encodeURIComponent, lower = term.toLowerCase();
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
const [libs, files, music, ddg, hub] = await Promise.allSettled([
libsP,
libsP.then(l => searchFiles(term, l)),
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
jfetch('/api/ddg?q=' + enc(term)),
jfetch('/api/search?q=' + enc(term)),
]);
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const sec = (title, html) => html ? `${title} ${html}
` : '';
const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
`${icon('files')} ${esc(r.name)} Library `).join('') : '';
const found = files.status === 'fulfilled' ? files.value.results : [];
const fileHtml = found.map(f => {
const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
return f.is_dir ? `${icon('files')} ${esc(f.name)} ${esc(f.repo_name || '')} ${esc(f.fullpath)} `
: `${icon('file')} ${esc(f.name)} ${esc(f.repo_name || '')} ${esc(parent)} `;
}).join('');
let musicHtml = '';
for (const m of music.status === 'fulfilled' ? music.value : []) {
if (m.type === 'artist') musicHtml += `${esc(m.title)} Artist `;
else if (m.type === 'album') musicHtml += `${esc(m.title)} Album · ${esc(m.parentTitle || '')} `;
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `${esc(m.title)} Track · ${esc(m.grandparentTitle || '')} `; }
}
const d = ddg.status === 'fulfilled' ? ddg.value : {};
const card = (d.text || d.answer) ? `
${d.image ? `
` : ''}
From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}
${d.heading ? `
${esc(d.heading)}
` : ''}${d.answer ? `
${esc(d.answer)}
` : ''}
${d.text ? `
${esc(d.text)}
` : ''}${d.url ? `
Read more ` : ''}
` : '';
const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] };
evStore.length = 0;
const taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due);
return `${icon('check')} ${esc(t.title)} ${esc(t.listName)}${du ? ' · ' + esc(du.text) : ''}${t.done ? ' · done' : ''} `; }).join('');
const eventHtml = (h.events || []).map(e => { const i = evStore.push(e) - 1, dt = e.allDay ? new Date(e.start + 'T00:00:00') : new Date(e.start);
return `${esc(e.title)} ${esc(dt.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }))}${e.allDay ? ' · all day' : ' · ' + esc(fmtTime(e.start))} · ${esc(e.cal)} ${e.editable ? icon('chev', 'chev') : ''} `; }).join('');
// what each source did, so an empty screen always has an explanation
showSources({
tasks: hub.status === 'fulfilled' ? { n: (h.tasks || []).length } : { err: hub.reason && hub.reason.message },
events: hub.status === 'fulfilled' ? { n: (h.events || []).length } : { err: hub.reason && hub.reason.message },
files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' },
music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
});
const notes = [...(h.errors || [])].map(e => `${esc(e)}
`).join('');
res.innerHTML = card + sec('Tasks', taskHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes;
if (!taskHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = 'No results. The line above shows what was searched.
' + notes;
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; });
}
}
// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
async function loadRange(start, days) {
const end = new Date(start); end.setDate(end.getDate() + days);
const last = new Date(end.getTime() - 1);
const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) });
const { events, errors } = await jfetch('/api/cal/events?' + q);
const byDay = new Map();
const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); };
for (const e of events) {
if (e.allDay) { // spans start..end-1 (end exclusive)
for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); }
} else put(ymd(new Date(e.start)), e);
}
for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
return { byDay, errors, start };
}
const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); };
const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const evStore = []; // events currently on screen, so a tap can open the right one
const evRow = e => { const i = evStore.push(e) - 1;
return `${esc(e.title)}
${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''} ${e.editable ? icon('chev', 'chev') : ''} `; };
document.addEventListener('click', e => { const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]); });
const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
async function renderAgenda(el, mode) { // Home card (today only)
if (!el) return;
try {
const cals = await jfetch('/api/cal/list');
const { byDay } = await loadAgenda(1);
const list = byDay.get(ymd(new Date())) || [];
if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
if (!document.body.contains(el)) return;
el.innerHTML = `Today ${list.length ? list.slice(0, 6).map(evRow).join('') : '
Nothing on the calendar today
'}
`;
} catch {}
}
const now0 = new Date();
const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
async function viewCalendar() {
const mode = store.get('cal_view', 'month');
const v = $('#view');
v.innerHTML = `Calendar
${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `
${l} `).join('')}
${icon('cal')}Add event
Manage calendars
`;
$('#ne').onclick = () => newEvent(mode === 'month' ? calState.sel : ymd(new Date()));
v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
$('#mc').onclick = manageCalendars;
try {
const cals = await jfetch('/api/cal/list');
if (!cals.length) { $('#cbody').innerHTML = 'No calendars yet. Add a private calendar link to see your events here.
Add a calendar
'; $('#ac').onclick = addCalendar; return; }
if (mode === 'month') await renderMonth(); else await renderAgendaList();
} catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `${esc(e.message)}
`; }
}
async function renderAgendaList() {
const range = store.get('cal_range', 7);
const { byDay, errors } = await loadAgenda(range);
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
const keys = [...byDay.keys()].sort();
$('#cbody').innerHTML = `${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `
${l} `).join('')}
`
+ (keys.map(k => `${esc(dayLabel(k))} ${byDay.get(k).map(evRow).join('')}
`).join('') || 'Nothing coming up
')
+ errors.map(e => `${esc(e)}
`).join('');
$('#cbody').querySelectorAll('.seg a[data-n]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
}
async function renderMonth() {
const { y, m } = calState;
const first = new Date(y, m, 1), gridStart = new Date(y, m, 1 - first.getDay()); // the Sunday on or before the 1st
const { byDay, errors } = await loadRange(gridStart, 42);
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
const today = ymd(new Date());
if (calState.sel < ymd(gridStart) || calState.sel > ymd(new Date(y, m, 1 - first.getDay() + 41))) calState.sel = ymd(first);
const cells = [];
for (let i = 0; i < 42; i++) {
const d = new Date(gridStart); d.setDate(d.getDate() + i);
const k = ymd(d), evs = byDay.get(k) || [], other = d.getMonth() !== m;
cells.push(`
${d.getDate()}
${evs.slice(0, 4).map(e => ` `).join('')}
${evs.slice(0, 3).map(e => `${esc(e.title)} `).join('')}${evs.length > 3 ? `+${evs.length - 3} more ` : ''} `);
}
$('#cbody').innerHTML = `${icon('back')}
${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}
${icon('back')} Today
${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `${d} `).join('')}
${cells.join('')}
${errors.map(e => `${esc(e)}
`).join('')}`;
const showDay = () => {
const k = calState.sel, evs = byDay.get(k) || [];
$('#dayev').innerHTML = `${esc(dayLabel(k))} ${evs.length ? evs.map(evRow).join('') : '
No events
'}
`;
$('#cbody').querySelectorAll('.cday').forEach(c => c.classList.toggle('sel', c.dataset.d === k));
};
showDay();
$('#cbody').querySelectorAll('.cday').forEach(c => c.onclick = () => { calState.sel = c.dataset.d; showDay(); });
const go = (dm) => { const t = new Date(y, m + dm, 1); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
$('#pm').onclick = () => go(-1); $('#nm').onclick = () => go(1);
$('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
}
const deviceTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const pad2 = n => String(n).padStart(2, '0');
const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
function openEvent(e) {
if (!e) return;
if (e.editable) return editEvent(e);
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
const el = openSheet(e.title, e.cal, `When ${esc(when)}
${e.location ? `
Where ${esc(e.location)}
` : ''}
This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.
Close
`);
el.querySelector('#sx').onclick = closeSheet;
}
async function editEvent(e) {
try {
const q = new URLSearchParams({ cal: e.calId, tz: deviceTz() }); if (e.uid) q.set('uid', e.uid); if (e.href) q.set('href', e.href);
const r = await jfetch('/api/cal/event?' + q);
eventForm({ mode: 'edit', calId: e.calId, calName: e.cal, uid: e.uid, href: e.href || r.href, etag: r.etag || e.etag, fields: r.fields, series: e.series });
} catch (err) { toast(err.message); }
}
async function newEvent(day) {
try {
const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
const d = new Date(day + 'T00:00:00'), now = new Date();
if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0);
const end = new Date(d.getTime() + 36e5);
eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
} catch (err) { toast(err.message); }
}
function eventForm(o) {
const f = { ...o.fields }, editing = o.mode === 'edit';
const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']);
const calPick = editing ? `Calendar ${esc(o.calName)}
`
: `Calendar ${o.cals.map(c => `${esc(c.name)}${c.icloud ? ' (iCloud)' : ''} `).join('')} `;
const el = openSheet(editing ? 'Edit event' : 'New event', '', ``);
el.querySelector('.sheet').classList.add('wide');
const draw = () => { // date-only inputs for all-day, date+time otherwise
const t = f.allDay ? 'date' : 'datetime-local', cut = v => f.allDay ? v.slice(0, 10) : v;
el.querySelector('#fdt').innerHTML = `Starts Ends
${!f.allDay && f.tz !== deviceTz() ? `Times are in ${esc(f.tz)}.
` : ''}`;
const s = el.querySelector('#fs'), en = el.querySelector('#fe2');
s.onchange = () => { // moving the start keeps the length
const len = Date.parse(f.allDay ? f.end.slice(0, 10) + 'T00:00' : f.end) - Date.parse(f.allDay ? f.start.slice(0, 10) + 'T00:00' : f.start);
f.start = s.value; if (s.value) { const ns = Date.parse(f.allDay ? s.value + 'T00:00' : s.value); if (!isNaN(ns) && len >= 0) { const d = new Date(ns + len); f.end = f.allDay ? ymd(d) : localStr(d); en.value = f.allDay ? ymd(d) : localStr(d); } }
};
en.onchange = () => { f.end = en.value; };
};
draw();
el.querySelector('#fa').onchange = ev => {
f.start = el.querySelector('#fs').value || f.start; f.end = el.querySelector('#fe2').value || f.end;
f.allDay = ev.target.checked;
if (f.allDay) { f.start = f.start.slice(0, 10); f.end = f.end.slice(0, 10); }
else { f.start = f.start.slice(0, 10) + 'T09:00'; f.end = f.end.slice(0, 10) + 'T10:00'; }
draw();
};
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#ef').onsubmit = async ev => {
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#fe').textContent = '';
const fields = { title: el.querySelector('#ft').value.trim(), location: el.querySelector('#fl').value.trim(), notes: el.querySelector('#fn').value.trim(), allDay: f.allDay,
start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
const cal = editing ? o.calId : el.querySelector('#fc').value;
try {
await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) });
if (!editing) store.set('cal_default', cal);
closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
};
const del = el.querySelector('#fd');
if (del) del.onclick = () => confirmSheet('Delete event?', o.series ? f.title + ' and every repeat of it' : f.title, 'Delete', async () => {
try { await jfetch('/api/cal/event/delete', { method: 'POST', body: JSON.stringify({ cal: o.calId, uid: o.uid, href: o.href, etag: o.etag }) }); toast('Event deleted'); viewCalendar(); }
catch (err) { toast(err.message); }
});
}
function addCalendar() {
const el = openSheet('Add a calendar link', '', `Name
Private calendar link (ICS)
Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. These are read-only: you see the events here but change them in the original app. Anyone with the link can read that calendar, so keep it private.
Cancel Add
`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('form').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
try { await jfetch('/api/cal/add', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#cn').value.trim() || 'Calendar', url: el.querySelector('#cu2').value.trim() }) }); closeSheet(); toast('Calendar added'); viewCalendar(); }
catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
};
}
async function manageCalendars() {
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
const links = cals.filter(c => !c.local && !c.icloud);
const el = openSheet('Calendars', '', `
NE Hub Events you add here
${links.map(c => `
${esc(c.name)} Link (read-only) Remove
`).join('')}
Add a calendar link
iCloud calendars
${ic.connected ? `${ic.calendars.map(c => `${esc(c.name)} `).join('')}
Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.
Disconnect iCloud
`
: `Read and add events in your iCloud calendars. Use an app-specific password (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.
Connect iCloud
`}
See NE Hub events in other apps
Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).
${icon('copy')}Copy link Make a new link
Close
`);
el.querySelector('.sheet').classList.add('wide');
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); });
el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } });
const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
const icf = el.querySelector('#icf');
if (icf) icf.onsubmit = async e => {
e.preventDefault(); const btn = icf.querySelector('.btn'); btn.disabled = true; btn.textContent = 'Connecting…'; el.querySelector('#ice').textContent = '';
try { await jfetch('/api/cal/icloud/connect', { method: 'POST', body: JSON.stringify({ appleId: el.querySelector('#iu').value, password: el.querySelector('#ip').value }) }); toast('iCloud connected'); closeSheet(); viewCalendar(); }
catch (err) { btn.disabled = false; btn.textContent = 'Connect iCloud'; el.querySelector('#ice').textContent = err.message; }
};
el.querySelector('#fc2').onclick = async () => { try { await navigator.clipboard.writeText(feed.url); toast('Link copied'); } catch { el.querySelector('#fu').select(); toast('Press copy on the selected link'); } };
el.querySelector('#fr2').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { await jfetch('/api/cal/feed', { method: 'POST', body: '{}' }); manageCalendars(); });
}
// ---- Tasks: NE Hub lists plus any iCloud reminder lists the account exposes
const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
const taskStore = []; // tasks on screen, so a tap can open the right one
async function viewTasks() {
const v = $('#view');
v.innerHTML = '';
try {
const { lists, icloud } = await jfetch('/api/tasks/lists');
if (!location.hash.startsWith('#/tasks')) return;
let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id;
const list = lists.find(l => l.id === cur);
v.innerHTML = ``;
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
: icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : '';
v.querySelectorAll('.seg a[data-l]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('task_list', a.dataset.l); viewTasks(); });
$('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
$('#qa').onsubmit = async e => {
e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
catch (err) { toast(err.message); }
};
loadTaskList(cur, list);
} catch (e) { v.innerHTML = ``; }
}
async function loadTaskList(cur, list) {
const box = $('#tl'); if (!box) return;
try {
const tasks = await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
if (!$('#tl')) return;
taskStore.length = 0;
const open = tasks.filter(t => !t.done).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title));
const done = tasks.filter(t => t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
const row = t => { const i = taskStore.push(t) - 1, du = dueInfo(t.due);
return `${icon('check')}
${esc(t.title)} ${du || t.notes ? `${du ? `${esc(du.text)} ` : ''}${du && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])} ` : ''}
`; };
const showDone = store.get('task_show_done', false);
box.innerHTML = (open.length ? `${open.map(row).join('')}
` : 'Nothing to do. Add a task above.
')
+ (done.length ? `${showDone ? 'Hide' : 'Show'} completed (${done.length})
${showDone ? `${done.map(row).join('')}
` : ''}` : '');
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
const t = taskStore[+b.dataset.t]; b.disabled = true;
try { await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, done: !t.done }) }); loadTaskList(cur, list); }
catch (err) { b.disabled = false; toast(err.message); }
});
box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => taskSheet(taskStore[+b.dataset.e], cur, list));
} catch (e) { box.innerHTML = `${esc(e.message)}
`; }
}
function taskSheet(t, cur, list) {
const el = openSheet('Task', list.name, `
Due date Clear
Notes ${esc(t.notes)}
Completed
Cancel Save
Delete task
`);
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
el.querySelector('#tf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked } }) }); closeSheet(); loadTaskList(cur, list); }
catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
};
el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title, 'Delete', async () => {
try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); loadTaskList(cur, list); }
catch (err) { toast(err.message); }
});
}
function newListSheet(lists, list) {
const mine = lists.filter(l => l.local && l.id !== 'local:inbox');
const el = openSheet('Lists', '', `${mine.length ? `${mine.map(l => `
${esc(l.name)} Delete
`).join('')}
` : ''}
New list
Close Add list
`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#lf').onsubmit = async e => { e.preventDefault(); try { const r = await jfetch('/api/tasks/lists', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#ln').value }) }); store.set('task_list', r.id); closeSheet(); viewTasks(); } catch (err) { el.querySelector('#le').textContent = err.message; } };
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); }));
}
// Home: a card only when something is overdue or due today
async function renderTasksCard(el) {
if (!el) return;
try {
const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = s.overdue + s.today;
if (!n || !document.body.contains(el)) return;
el.innerHTML = `Tasks ${icon('check')}${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention ${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''} ${icon('chev', 'chev')} `;
} catch {}
}
function viewMail() {
$('#view').innerHTML = `Mail
Opens each provider's own webmail in a new tab. Nothing is stored here.
Inboxes
Compose `;
}
const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join('');
function viewOffice(args = []) {
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
$('#view').innerHTML = `Office Create, open and edit documents without leaving this tab. Everything is saved in Seafile.
${icon('doc')} Document Word (.docx)
${icon('doc')} Spreadsheet Excel (.xlsx)
${icon('doc')} Presentation PowerPoint (.pptx)
${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}
`;
$('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l));
if (!sf.token()) return;
(async () => {
try {
const loc = await officeLocation(false), el = $('#olib'); if (!el) return;
if (!loc) {
el.textContent = 'Choose where your documents are saved in Seafile.';
$('#odocs').innerHTML = 'Choose location
';
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } });
return;
}
el.innerHTML = `Saved in ${esc(locLabel(loc))} · change `;
$('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); };
const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`);
const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60);
if (!location.hash.startsWith('#/office') || !$('#odocs')) return;
$('#odocs').innerHTML = docs.length ? `Documents ${docs.map((f, i) => {
const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b';
return `${icon('file')} ${esc(f.name)} ${esc(f.parent_dir || '/')} · ${fmtDate(f.mtime)} `; }).join('')}
`
: 'No documents here yet. Create one above.
';
$('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => {
const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name;
addRecent(loc.repo, path, f.name);
if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
});
} catch (e) { const el = $('#olib'); if (el) el.innerHTML = `${esc(e.message)} `; }
})();
}
const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / '));
// The saved Office location, if it still exists (the library may have been deleted).
async function officeLocation(ask) {
const loc = store.get('office_loc');
if (loc) {
let repos;
try { repos = await sf.req('/api2/repos/'); } catch { return loc; } // a network hiccup must not forget the choice
const r = repos.find(x => x.id === loc.repo);
if (r) {
try { await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}`); return { ...loc, repoName: r.name }; }
catch (e) { if (!/not found|404|exist/i.test(e.message)) return { ...loc, repoName: r.name }; }
}
store.del('office_loc'); // library or folder is really gone
}
if (!ask) return null;
const l = await chooseFolder('Save documents in…'); if (l) store.set('office_loc', l); return l;
}
// Pick a library and folder in Seafile (same structure as the Files tab). Resolves null if cancelled.
function chooseFolder(title, start) {
return new Promise(resolve => {
let repo = start ? start.repo : null, repoName = start ? start.repoName : '', dir = start ? start.dir : '/', done = false;
const el = openSheet(title, '', '
');
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
const finish = v => { done = true; closeSheet(); resolve(v); };
const draw = async () => {
const box = el.querySelector('#cf'); box.innerHTML = 'Loading…
';
try {
if (!repo) {
const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name));
box.innerHTML = `Choose a library
${repos.map(r => `
${icon('files')}${esc(r.name)} ${icon('chev', 'chev')} `).join('') || '
No libraries. Create one in Files.
'}
Cancel
`;
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; repoName = repos.find(r => r.id === repo).name; dir = '/'; draw(); });
} else {
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).filter(x => x.type === 'dir').sort((a, b) => a.name.localeCompare(b.name));
box.innerHTML = `${esc(locLabel({ repoName, dir }))}
${icon('back')}${dir === '/' ? 'All libraries' : 'Up'}
${subs.map(x => `${icon('files')}${esc(x.name)} ${icon('chev', 'chev')} `).join('')}
Cancel Save here
`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = joinP(dir, b.dataset.d); draw(); });
box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); };
box.querySelector('#cs').onclick = () => finish({ repo, repoName, dir });
}
box.querySelector('#cx').onclick = () => finish(null);
} catch (e) { box.innerHTML = `${esc(e.message)}
`; }
};
draw();
});
}
// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
async function createFromTemplate(repo, dir, name, x) {
const enc = encodeURIComponent;
if (!['docx', 'xlsx', 'pptx'].includes(x)) {
await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) });
return name;
}
const tpl = await fetch('/templates/blank.' + x); if (!tpl.ok) throw new Error('Template missing');
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tpl.blob(), name);
const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Could not create ' + name);
return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
}
async function newOfficeDoc(x, label) {
if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
let loc;
try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
if (!loc) return;
const el = openSheet('New ' + label, '', `File name
Saved in Change
Cancel Create
`);
const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
showLoc();
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#nl').onclick = async () => {
// Sheets don't stack, so close this one, choose, then reopen with the new location.
const picked = await chooseFolder('Save in…', loc);
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label);
};
setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
el.querySelector('form').onsubmit = async e => {
e.preventDefault();
const n = el.querySelector('#nn').value.trim(); if (!n) return;
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
try {
const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name);
addRecent(loc.repo, path, name); closeSheet();
if (cfg.editor) location.hash = officeHash(loc.repo, path);
else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
} catch (err) { btn.disabled = false; el.querySelector('#ne').textContent = err.message; }
};
}
// ---- Audiobooks: embedded site. Sign-in details saved in Settings (this browser only) can be copied with one tap,
// because a page can't type into another site's login form.
const abCreds = () => store.get('ab_creds', { u: '', p: '' });
// Podcast Admin (Verse by Verse): the site refuses to be framed (X-Frame-Options), so this is a launcher.
function viewVerse() {
const url = cfg.verse, host = new URL(url).host;
$('#view').innerHTML = `Podcast Admin ${esc(host + new URL(url).pathname)}
This site doesn't allow being shown inside another app, so it opens in its own tab.
`;
}
// Frames that must keep running (audio) live outside #view: created once, then only shown or hidden.
function showFrame(key, url, title, allow) {
const wrap = $('#audiowrap');
let f = wrap.querySelector(`iframe[data-k="${key}"]`);
if (!f) { f = document.createElement('iframe'); f.dataset.k = key; f.src = url; f.title = title; f.allow = allow; wrap.appendChild(f); }
wrap.querySelectorAll('iframe').forEach(x => x.classList.toggle('act', x === f));
wrap.className = 'on'; document.body.classList.add('onaudio');
}
// Podcast: opens in a frame when it has a secure (https) address; a plain-http address cannot be framed by a secure page.
// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is
// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN).
function viewPodcast() {
const url = cfg.podcast, u = new URL(url), canFrame = u.protocol === 'https:';
$('#view').innerHTML = `Podcast ${esc(u.host)}
${canFrame ? '
If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.
'
: '
This is a plain http address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.
'}
`;
if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
}
function viewAudio() {
const url = cfg.audiobooks, c = abCreds();
$('#view').innerHTML = `Audio
${esc(new URL(url).host)}
`;
showFrame('audio', url, 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write'); // created once, so playback carries on across tabs
const copy = async (v, what) => { try { await navigator.clipboard.writeText(v); toast(what + ' copied. Paste it into the sign-in box.'); } catch { toast('Could not copy'); } };
const cu = $('#cu'), cp = $('#cp');
if (cu) cu.onclick = () => copy(c.u, 'Username');
if (cp) cp.onclick = () => copy(c.p, 'Password');
}
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
const jfetch = async (path, opts) => {
const r = await fetch(path, opts); const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
return j;
};
const px = {
secs: null,
async get(path) {
const r = await fetch('/plex' + path, { headers: { Accept: 'application/json' } });
if (r.status === 401) throw Object.assign(new Error('Plex session expired'), { auth: true });
if (!r.ok) throw new Error('Plex error ' + r.status);
return (await r.json()).MediaContainer || {};
},
art: (thumb, w = 300) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
// All music libraries on the server; the chosen one is remembered per server.
async sections() {
if (px.secs) return px.secs;
const d = (await px.get('/library/sections')).Directory || [];
px.secs = d.filter(x => x.type === 'artist').map(x => ({ key: x.key, title: x.title }));
if (!px.secs.length) { px.secs = null; throw new Error('No music library found on this Plex server'); }
return px.secs;
},
async section() {
const secs = await px.sections(), want = store.get('plex_lib', {})[px.serverKey];
return (secs.find(x => x.key === want) || secs[0]).key;
},
serverKey: '',
};
const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
const mapTrack = (t, mc) => {
const part = t.Media?.[0]?.Part?.[0];
return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '',
album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 };
};
async function viewMusic(args) {
const v = $('#view');
v.innerHTML = '';
try {
const st = await jfetch('/api/plex/status');
if (!location.hash.startsWith('#/music')) return;
if (!st.signedIn) return plexSignIn();
if (!st.server) return plexPickServer();
await musicRoute(args, st);
} catch (e) {
if (e.auth) return plexSignIn();
v.innerHTML = `Music ${esc(e.message)}
Change server
`;
$('#rs').onclick = plexPickServer;
}
}
function plexSignIn(msg = '') {
$('#view').innerHTML = `Music Play your Plex music library here.
You'll sign in on plex.tv in a new tab. This app never sees your Plex password.
Sign in with Plex
${esc(msg)}
`;
$('#pl').onclick = async () => {
const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
try {
const pin = await jfetch('/api/plex/pin', { method: 'POST' });
if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `Tap to open Plex sign-in `;
for (let i = 0; i < 150; i++) {
await new Promise(r => setTimeout(r, 2000));
if (!location.hash.startsWith('#/music')) return;
const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
if (r.ok) { try { w?.close(); } catch {} return route(); }
}
$('#pe').textContent = 'Timed out waiting for Plex. Try again.';
} catch (e) { $('#pe').textContent = e.message; }
};
}
async function plexPickServer() {
const v = $('#view');
v.innerHTML = 'Music Finding your Plex servers…
';
try {
const servers = await jfetch('/api/plex/servers');
if (!servers.length) { v.innerHTML = 'Music No Plex servers found on this account.
'; return; }
v.innerHTML = `Music Choose a server
${servers.map(s => `
${icon('music')}
${esc(s.name)} ${s.owned ? 'Yours' : 'Shared with you'} ${icon('chev', 'chev')} `).join('')}
`;
v.querySelectorAll('button.row').forEach(b => b.onclick = async () => {
b.disabled = true; $('#pe').textContent = '';
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.secs = null; route(); }
catch (e) {
b.disabled = false; $('#pe').textContent = e.message;
// Offer a hand-typed address (e.g. the server's LAN address) when automatic discovery fails.
$('#pm').innerHTML = `Know the server's address? Enter it (for example http://192.168.1.10:32400 ).
Try this address
`;
$('#pf2').onsubmit = async ev => {
ev.preventDefault(); $('#pe').textContent = '';
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id, uri: $('#pu').value.trim() }) }); px.secs = null; route(); }
catch (e2) { $('#pe').textContent = e2.message; }
};
}
});
} catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = ``; }
}
async function musicRoute([kind = 'albums', key], st) {
px.serverKey = st.server;
const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
const tabs = ['albums', 'artists', 'playlists', 'recent'];
const seg = ``;
const card = (href, thumb, title, sub, round) => `
${esc(title)} ${esc(sub)} `;
const listing = (items, html) => {
v.innerHTML = `Music ${esc(st.server)}${secs.length > 1 ? ` · ${esc(cur.title)} ▾ ` : ''}
${seg}
${html || '
Nothing here
'}
`;
const pl2 = $('#plib');
if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
$('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
};
const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); }
if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio');
return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); }
if (kind === 'artist') {
const mc = await px.get(`/library/metadata/${key}/children`);
v.innerHTML = `${esc(mc.title2 || mc.parentTitle || 'Artist')}
Albums ${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}
`;
return;
}
if (kind === 'album' || kind === 'playlist') {
const album = kind === 'album';
const [mc, meta] = await Promise.all([px.get(album ? `/library/metadata/${key}/children` : `/playlists/${key}/items`),
px.get(album ? `/library/metadata/${key}` : `/playlists/${key}`)]);
const info = (meta.Metadata || [])[0] || {};
const tracks = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean);
const title = info.title || mc.title2 || '', sub = album ? (info.parentTitle || mc.title1 || '') : tracks.length + ' tracks';
const thumb = info.thumb || info.composite || mc.thumb;
v.innerHTML = `
${icon('play')}Play ${icon('shuffle')}Shuffle
${tracks.map((t, i) => `
${i + 1}
${esc(t.title)} ${album ? '' : `${esc(t.artist)} `}${fmtDur(t.dur)}
${icon('more')}
`).join('') || '
No playable tracks
'}
`;
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m]));
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0);
markPlaying();
return;
}
location.hash = '#/music/albums';
}
// ---- Player: one element that lives outside the views, so music keeps playing as you move around.
const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; };
const pl = { q: [], i: -1, a: new Audio() };
const cur = () => pl.q[pl.i];
function playQueue(q, i = 0) { pl.q = q; pl.i = i; loadCur(); }
function loadCur() {
const t = cur(); if (!t) return;
pl.a.src = t.src; pl.a.play().catch(() => toast('Tap play to start'));
if ('mediaSession' in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
renderMini(); syncUI(); markPlaying(); saveState();
}
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); toast('Playing next'); };
const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); toast('Added to queue'); };
function trackSheet(t) {
actionSheet(t.title, t.artist, [
{ label: 'Play next', icon: 'next', run: () => playNextTrack(t) },
{ label: 'Add to queue', icon: 'files', run: () => queueTrack(t) },
{ label: 'Play now', icon: 'play', run: () => playQueue([t], 0) },
]);
}
// Restore the last queue and position on launch (paused, so nothing starts by itself).
function restorePlayer() {
const st = store.get('plex_state'); if (!st || !st.q || !st.q[st.i]) return;
pl.q = st.q; pl.i = st.i; pl.a.src = pl.q[pl.i].src;
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
renderMini(); syncUI();
}
function openQueue() {
const upcoming = pl.q.map((t, i) => ({ t, i }));
const el = openSheet('Up next', pl.q.length + ' tracks', `${upcoming.map(({ t, i }) => `
${i === pl.i ? '▶' : i + 1} ${esc(t.title)} ${esc(t.artist)}
${i === pl.i ? '' : `${icon('trash')} `}
`).join('')}
Close
`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(); closeSheet(); });
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); });
}
const next = () => { if (pl.i < pl.q.length - 1) { pl.i++; loadCur(); } else { pl.a.pause(); } };
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else { pl.i--; loadCur(); } };
const toggle = () => (pl.a.paused ? pl.a.play() : pl.a.pause());
pl.a.addEventListener('ended', next);
let lastSave = 0;
pl.a.addEventListener('timeupdate', () => { if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); } });
addEventListener('pagehide', saveState);
['play', 'pause', 'timeupdate', 'loadedmetadata'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
pl.a.addEventListener('error', () => toast('Could not play this track'));
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
}
function markPlaying() {
const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
}
function renderMini() {
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
el.hidden = false;
el.innerHTML = `
${esc(t.title)} ${esc(t.artist)}
${icon('next')} `;
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mo').onclick = openNow;
}
function syncUI() {
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
const ic = icon(a.paused ? 'play' : 'pause');
const mt = $('#mt'); if (mt && mt.dataset.s !== String(a.paused)) { mt.innerHTML = ic; mt.dataset.s = a.paused; }
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
const nt = $('#npt'); if (nt && nt.dataset.s !== String(a.paused)) { nt.innerHTML = ic; nt.dataset.s = a.paused; }
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
}
function openNow() {
const t = cur(); if (!t) return;
const el = openSheet('', '', `${esc(t.title)}
${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}
0:00 0:00
${icon('prev')}
${icon('next')}
Queue · ${pl.q.length - pl.i - 1} up next
`);
const sk = el.querySelector('#npk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#npq').onclick = openQueue;
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
syncUI();
}
// ---- People (admin): who can sign in, which tabs each may use, one-time invite codes
let peopleTabs = [];
async function loadPeople() {
const box = $('#people'); if (!box) return;
try {
const r = await jfetch('/api/users'); peopleTabs = r.tabs;
if (!$('#people')) return;
$('#people').innerHTML = r.users.map(u => `${esc(u.name.slice(0, 1).toUpperCase())}
${esc(u.name)}${u.id === ME.id ? ' (you)' : ''}${u.role === 'admin' ? ' · Admin' : ''}${u.disabled ? ' · Off' : ''}
${u.passkeys ? u.passkeys + ' passkey' + (u.passkeys === 1 ? '' : 's') : 'No passkey yet (waiting for the invite code)'} · ${u.role === 'admin' ? 'Everything' : u.tabsChosen.map(t => (peopleTabs.find(x => x.id === t) || {}).label || t).join(', ') || 'Home and Settings only'} ${icon('chev', 'chev')} `).join('');
$('#people').querySelectorAll('button[data-p]').forEach(b => b.onclick = () => personSheet(r.users.find(u => u.id === b.dataset.p)));
} catch (e) { box.innerHTML = `${esc(e.message)}
`; }
}
const tabBoxes = (chosen, disabled) => `${peopleTabs.map(t => `${esc(t.label)}
`).join('')}
`;
const pickedTabs = el => [...el.querySelectorAll('input[data-tab]:checked')].map(i => i.dataset.tab);
function codeSheet(name, code, minutes) {
const el = openSheet('Invite code for ' + name, '', `On ${esc(name)}'s device, open ${esc(location.origin)} , tap New device? Use an invite code on the sign-in page, and enter:
${esc(code)}
It works once and expires in ${minutes} minutes. They will then create their own passkey.
${icon('copy')}Copy code Done
`);
el.querySelector('#sx').onclick = () => { closeSheet(); loadPeople(); };
el.querySelector('#cc').onclick = async () => { try { await navigator.clipboard.writeText(code); toast('Code copied'); } catch { toast('Could not copy'); } };
}
async function addPersonSheet() {
if (!peopleTabs.length) { try { peopleTabs = (await jfetch('/api/users')).tabs; } catch (e) { return toast(e.message); } }
const el = openSheet('Add a person', '', `Name
What can they use? ${tabBoxes(['tasks', 'calendar', 'search'])}
They always get Home and Settings, and their own private tasks, events and settings. You can change this later.
Cancel Create
`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#pf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
try { const r = await jfetch('/api/users', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#pn').value, tabs: pickedTabs(el) }) }); codeSheet(r.user.name, r.code, r.minutes); }
catch (err) { btn.disabled = false; el.querySelector('#pe').textContent = err.message; }
};
}
function personSheet(u) {
const self = u.id === ME.id, admin = u.role === 'admin';
const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `Name
${admin ? 'Admins can use everything.
' : `What can they use? ${tabBoxes(u.tabsChosen)}`}
Close Save
${icon('copy')}Invite code for a new device Also the way back in if they lose their device
${icon('back')}Sign out everywhere
${self ? '' : `${icon('more')}${u.disabled ? 'Turn back on' : 'Turn off'} ${u.disabled ? 'Let them sign in again' : 'Signs them out and blocks sign-in; their data is kept'} `}
${icon('check')}${admin ? 'Remove admin' : 'Make admin'}
${self ? '' : `${icon('trash')}Delete person and their data `}
`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg); closeSheet(); loadPeople(); } catch (err) { toast(err.message); } };
const put = body => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'PUT', body: JSON.stringify(body) });
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el) }) }), 'Saved'); };
el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } };
el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere');
const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off');
el.querySelector('#u-role').onclick = () => act(() => put({ role: admin ? 'user' : 'admin' }), admin ? 'No longer an admin' : 'Now an admin');
const del = el.querySelector('#u-del'); if (del) del.onclick = () => confirmSheet('Delete ' + u.name + '?', 'Their tasks, events, settings and sign-ins are permanently deleted.', 'Delete', () => act(() => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE' }), 'Deleted'));
}
function viewSettings() {
const signed = !!sf.token();
const isAdmin = ME && ME.role === 'admin';
const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : '';
$('#view').innerHTML = `Settings
${ME ? `
Signed in as ${esc(ME.name)} ${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}
` : ''}
${isAdmin ? `
People
Add a person
Storage ` : ''}
Passkeys
Add a passkey Invite a device Sign out of NE Hub
Connections
Seafile ${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}
Plex Checking… Sign out
OnlyOffice ${esc(cfg.onlyoffice)}
${signed ? '
Sign out of Seafile
' : ''}
Mail accounts Your address for each account, so its inbox and new-message buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.
${MAIL.filter(m => m.id !== 'icloud').map(m => `
${esc(m.name)}
`).join('')}
iCloud Mail can't take a hint; use Keychain autofill or stay signed in.
Install iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.
`;
// only show what this person may use
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
document.querySelectorAll('input[data-id]').forEach(i => i.oninput = () => {
const h = mailHints(); const v = i.value.trim(); if (v) h[i.dataset.id] = v; else delete h[i.dataset.id]; store.set('mail_hints', h);
});
const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
if ($('#ab-u')) { $('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb; }
if (allowed('music')) jfetch('/api/plex/status').then(st => {
const el = $('#plexst'); if (!el) return;
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server : 'Signed in · no server chosen') : 'Signed out';
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
if (isAdmin) jfetch('/api/system').then(i => {
const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable;
const row = (t, d, bad) => `${t} ${d}
`;
const where = !i.source ? '' : i.source.kind === 'folder' ? ' · host folder ' + esc(i.source.name) : i.source.kind === 'named' ? ' · volume "' + esc(i.source.name) + '"' : '';
$('#stor').innerHTML =
row(ok ? 'Data is saved on a volume' : 'Data is NOT being kept safely', anon ? 'This is a temporary Docker volume (' + esc(i.source.name) + '…), NOT your host folder. It is deleted when the container is recreated, and your folder stays empty. Check that your stack mounts the host folder at ' + esc(i.dir) + '.'
: i.mounted === false ? 'The data folder is not a mounted volume, so passkeys, calendars and sign-ins are lost when the container is recreated. Mount a volume or a host folder at ' + esc(i.dir) + '.'
: !i.writable ? 'The data folder is not writable by the app.' : esc(i.dir) + ' (mounted' + where + ')', !ok)
+ row('What is stored', `${i.passkeys} passkey${i.passkeys === 1 ? '' : 's'} · ${i.events} NE Hub event${i.events === 1 ? '' : 's'} · ${i.calendarLinks} calendar link${i.calendarLinks === 1 ? '' : 's'} · ${i.settings || 0} saved setting${i.settings === 1 ? '' : 's'}${i.icloud ? ' · iCloud connected' : ''}`)
+ row('Session secret', i.secretFromEnv ? 'Set in the stack (sign-ins survive restarts)' : 'Generated and kept in the data folder', false)
+ row('Running since', new Date(i.startedAt).toLocaleString(), false);
}).catch(() => { const e = $('#stor'); if (e) e.innerHTML = 'Could not read storage status
'; });
const loadPks = async () => {
try {
const pks = await (await fetch('/api/auth/passkeys')).json();
$('#pks').innerHTML = pks.map(k => `${esc(k.name)} Added ${new Date(k.created).toLocaleDateString()}${k.lastUsed ? ' · used ' + new Date(k.lastUsed).toLocaleDateString() : ''}
Remove
`).join('') || 'No passkeys
';
$('#pks').querySelectorAll('button[data-id]').forEach(b => b.onclick = () => confirmSheet('Remove passkey?', pks.length === 1 ? 'This is your only passkey. You would need the server setup code to enrol a new one.' : 'It will no longer sign in to NE Hub.', 'Remove', async () => {
await authPost('/api/auth/passkeys', { id: b.dataset.id }, 'DELETE'); loadPks();
}));
} catch (e) { $('#pks').innerHTML = `${esc(e.message)}
`; }
};
loadPks();
$('#addpk').onclick = () => textSheet('Add a passkey', 'Name this device', 'Create', async name => {
try { await passkeyRegister('', name); toast('Passkey added'); loadPks(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Cancelled' : e.message); }
});
$('#invpk').onclick = async () => {
try {
const { code, minutes } = await authPost('/api/auth/invite');
const el = openSheet('Invite a device', 'On the new device, open this app, tap "New device? Use an invite code", and enter:', `${esc(code)}
Works once, and expires in ${minutes} minutes.
Done
`);
el.querySelector('#sx').onclick = closeSheet;
} catch (e) { toast(e.message); }
};
$('#hubout').onclick = async () => { await authPost('/api/auth/logout'); location.replace('/login'); };
const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
}
// ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret)
async function viewEdit([repo, ...rawParts], opts = {}) {
const parts = rawParts.filter(Boolean);
const path = '/' + parts.join('/'), name = parts[parts.length - 1] || '';
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
const v = $('#view');
v.innerHTML = `
`;
if (!sf.token()) return (location.hash = '#/files');
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
try {
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) });
const conf = await r.json();
if (!r.ok) return fail(conf.error || 'Could not open document');
if (!window.DocsAPI) await new Promise((ok, no) => {
const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js';
s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s);
});
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
$('#oomsg').remove();
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
editor = new DocsAPI.DocEditor('oo', conf);
} catch (e) { fail(e.message); }
}
// ---- Files
function viewFiles([repo, ...parts]) {
if (!sf.token()) return filesLogin();
const dir = '/' + parts.join('/');
$('#view').innerHTML = ``;
loadLibs(repo);
if (repo) loadDir(repo, dir);
}
function filesLogin(msg = '', needOtp = false) {
$('#view').innerHTML = ``;
$('#lf').onsubmit = async e => {
e.preventDefault();
const f = new FormData(e.target);
try { await sf.login(f.get('u'), f.get('p'), f.get('o')); route(); }
catch (err) { filesLogin(err.message, !!err.otp || needOtp); }
};
}
async function loadLibs(active) {
try {
const repos = (await sf.req('/api2/repos/')).sort((a, b) => a.name.localeCompare(b.name));
$('#libs').innerHTML = `${icon('newdir')}New library
Libraries `;
$('#nl').onclick = () => textSheet('New library', 'Library name', 'Create', async name => {
try { const r = await sf.req('/api2/repos/', { method: 'POST', body: new URLSearchParams({ name }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); location.hash = '#/files/' + encodeURIComponent(r.repo_id); }
catch (e) { toast(e.message); }
});
if (!active) return;
const r = repos.find(x => x.id === active);
if (r && r.encrypted) $('#fmain').innerHTML = 'This library is encrypted. Unlock it in the Seafile web UI first.
';
} catch (e) { $('#libs').innerHTML = `${esc(e.message)}
`; if (!sf.token()) setTimeout(route, 800); }
}
async function loadDir(repo, dir) {
const main = $('#fmain');
const enc = encodeURIComponent;
const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join('');
const segs = dir.split('/').filter(Boolean);
const crumbs = [`Library `].concat(segs.map((s, i) =>
`/ ${esc(s)} `)).join('');
main.innerHTML = `
${crumbs}
`;
const create = async (path, op, extra = {}, kind = 'file') => {
try { await sf.req(`/api2/repos/${enc(repo)}/${kind}/?p=${enc(path)}`, { method: 'POST', body: new URLSearchParams({ operation: op, ...extra }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); loadDir(repo, dir); }
catch (e) { toast(e.message); }
};
$('#mk').onclick = () => actionSheet('Create new', '', [
{ label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir')) },
...[['Document', 'docx'], ['Spreadsheet', 'xlsx'], ['Presentation', 'pptx'], ['Text file', 'txt']].map(([label, x]) => ({ label, icon: 'doc',
run: () => textSheet('New ' + label.toLowerCase(), 'File name', 'Create', async n => {
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
try {
const name = await createFromTemplate(repo, dir, want, x);
loadDir(repo, dir);
if (x !== 'txt') openFile(repo, joinP(dir, name), name, dir);
} catch (e) { toast(e.message); }
}) })),
]);
$('#up').onchange = async e => {
const files = [...e.target.files]; if (!files.length) return;
try {
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
const u = new URL(link, cfg.seafile);
for (const f of files) {
toast('Uploading ' + f.name + '…');
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', f);
const r = await fetch('/sf' + u.pathname + '?ret-json=1', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload failed: ' + f.name);
}
toast('Uploaded'); loadDir(repo, dir);
} catch (err) { toast(err.message); }
};
try {
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
$('#items').innerHTML = items.length ? `${items.map((it, i) => it.type === 'dir'
? `
`
: `
${icon('file')} ${esc(it.name)} ${fmtSize(it.size)} · ${fmtDate(it.mtime)} ${icon('more')}
`).join('')}
`
: 'This folder is empty
';
const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
$('#items').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => itemSheet(items[b.dataset.i]));
$('#items').querySelectorAll('button[data-open]').forEach(b => b.onclick = () => openFile(repo, joinP(dir, items[b.dataset.open].name), items[b.dataset.open].name, dir));
} catch (e) { $('#items').innerHTML = `${esc(e.message)}
`; }
}
const joinP = (d, n) => (d === '/' ? '' : d) + '/' + n;
// ---- Bottom sheet (dialog on tablet/desktop). Body content is built by the caller from trusted/escaped HTML.
function openSheet(title, sub, bodyHtml) {
closeSheet();
const el = document.createElement('div');
el.id = 'sheet';
el.innerHTML = `
${esc(title)} ${sub ? `
${esc(sub)}
` : ''}${bodyHtml}
`;
document.body.appendChild(el);
el.querySelector('.scrim').onclick = closeSheet;
requestAnimationFrame(() => el.classList.add('on'));
return el;
}
function closeSheet() { document.getElementById('sheet')?.remove(); }
addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });
function actionSheet(title, sub, actions) {
const el = openSheet(title, sub, `${actions.map((a, i) => `
${icon(a.icon)}${esc(a.label)} `).join('')}
Cancel
`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); actions[b.dataset.i].run(); });
}
function textSheet(title, label, okLabel, onOk, initial = '') {
const el = openSheet(title, '', `${esc(label)}
Cancel
${esc(okLabel)}
`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('form').onsubmit = e => { e.preventDefault(); const v = el.querySelector('#tv').value.trim(); if (v) { closeSheet(); onOk(v); } };
setTimeout(() => { const t = el.querySelector('#tv'); t.focus(); const d = initial.lastIndexOf('.'); t.setSelectionRange(0, d > 0 ? d : initial.length); }, 50);
}
function confirmSheet(title, sub, okLabel, onOk) {
const el = openSheet(title, sub, `Cancel
${esc(okLabel)}
`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#so').onclick = () => { closeSheet(); onOk(); };
}
// ---- File actions
const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac' };
const previewKind = n => Object.keys(PREVIEW).find(k => PREVIEW[k].split(' ').includes(ext(n)));
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
const encP = p => p.split('/').map(encodeURIComponent).join('/');
const fileUrl = async (repo, path) => { const u = new URL(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), cfg.seafile); return '/sf' + u.pathname; };
// Recently opened files, shown on Home (kept in this browser only).
function addRecent(repo, path, name) {
const r = store.get('recents', []).filter(x => !(x.repo === repo && x.path === path));
r.unshift({ repo, path, name, t: Date.now() }); store.set('recents', r.slice(0, 12));
}
// Default tap on a file: edit documents, preview media, otherwise show the actions.
function openFile(repo, path, name, dir) {
if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); }
else if (previewKind(name)) previewFile(repo, path, name);
else fileSheet(repo, path, name, dir);
}
async function previewFile(repo, path, name) {
addRecent(repo, path, name);
try {
const url = (await fileUrl(repo, path)) + '?hubinline=1', kind = previewKind(name);
const media = kind === 'img' ? ` `
: kind === 'pdf' ? ``
: kind === 'video' ? ` `
: ` `;
const el = openSheet(name, '', `${media}`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
} catch (e) { toast(e.message); }
}
async function shareLink(repo, path, isDir) {
const p = isDir ? path.replace(/\/?$/, '/') : path;
try {
let link;
try { link = (await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORM, body: new URLSearchParams({ repo_id: repo, path: p }) })).link; }
catch { link = (await sf.req(`/api/v2.1/share-links/?repo_id=${encodeURIComponent(repo)}&path=${encodeURIComponent(p)}`))[0]?.link; }
if (!link) throw new Error('Could not create a share link');
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || '', `
${icon('copy')}Copy ${navigator.share ? 'Share… ' : ''}
`);
el.querySelector('#cp1').onclick = async () => { try { await navigator.clipboard.writeText(link); toast('Link copied'); closeSheet(); } catch { el.querySelector('#sl').select(); toast('Press copy on the selected link'); } };
const sh = el.querySelector('#sh1'); if (sh) sh.onclick = () => navigator.share({ url: link }).catch(() => {});
} catch (e) { toast(e.message); }
}
function renameItem(repo, path, name, dir, isDir) {
textSheet('Rename', 'New name', 'Rename', async n => {
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${isDir ? 'dir' : 'file'}/?p=${encodeURIComponent(path)}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'rename', newname: n }) }); toast('Renamed'); loadDir(repo, dir); }
catch (e) { toast(e.message); }
}, name);
}
// Folder picker for Move (same library).
function moveItem(repo, name, dir) {
let at = dir;
const el = openSheet('Move to…', name, '
');
const draw = async () => {
const box = el.querySelector('#pf');
box.innerHTML = 'Loading…
';
try {
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(at)}`)).filter(x => x.type === 'dir' && joinP(at, x.name) !== joinP(dir, name));
box.innerHTML = `${esc(at)}
${at !== '/' ? `${icon('back')}Up ` : ''}
${subs.map(x => `${icon('files')}${esc(x.name)} ${icon('chev', 'chev')} `).join('')}
Cancel Move here
`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { at = joinP(at, b.dataset.d); draw(); });
const up = box.querySelector('[data-up]'); if (up) up.onclick = () => { at = at.slice(0, at.lastIndexOf('/')) || '/'; draw(); };
box.querySelector('#sx').onclick = closeSheet;
box.querySelector('#mv').onclick = async () => {
try { await sf.req('/api/v2.1/repos/sync-batch-move-item/', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ src_repo_id: repo, src_parent_dir: dir, dst_repo_id: repo, dst_parent_dir: at, src_dirents: [name] }) });
closeSheet(); toast('Moved'); loadDir(repo, dir); } catch (e) { toast(e.message); }
};
} catch (e) { box.innerHTML = `${esc(e.message)}
`; }
};
draw();
}
function fileSheet(repo, path, name, dir) {
const enc = encodeURIComponent, acts = [];
if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) });
if (cfg.editor && EDIT_EXT.has(ext(name))) acts.push({ label: 'Edit here', icon: 'edit', run: () => { addRecent(repo, path, name); location.hash = '#/edit/' + enc(repo) + path.split('/').filter(Boolean).map(s => '/' + enc(s)).join(''); } });
if (OFFICE_EXT.has(ext(name))) acts.push({ label: 'Open in Seafile', icon: 'doc', run: () => window.open(`${cfg.seafile}/lib/${enc(repo)}/file${path.split('/').map(enc).join('/')}`, '_blank', 'noopener') });
acts.push({ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, false) });
acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
acts.push({ label: 'Move', icon: 'files', run: () => moveItem(repo, name, dir) });
acts.push({ label: 'Download', icon: 'dl', run: async () => {
try { const url = await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(path)}&reuse=1`); window.open(url, '_blank', 'noopener'); }
catch (e) { toast(e.message); } } });
acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () =>
confirmSheet('Delete file?', name, 'Delete', async () => {
try { await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(path)}`, { method: 'DELETE' }); toast('Deleted'); loadDir(repo, dir); }
catch (e) { toast(e.message); } }) });
actionSheet(name, '', acts);
}
function dirSheet(repo, path, name, dir) {
actionSheet(name, 'Folder', [
{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) },
{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) },
{ label: 'Move', icon: 'files', run: () => moveItem(repo, name, dir) },
{ label: 'Delete', icon: 'trash', danger: true, run: () => confirmSheet('Delete folder?', name + ' and everything in it', 'Delete', async () => {
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(path)}`, { method: 'DELETE' }); toast('Deleted'); loadDir(repo, dir); }
catch (e) { toast(e.message); } }) },
]);
}
// ---- Boot
(async () => {
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
await loadMe();
await loadSettings();
addEventListener('hashchange', route);
restorePlayer();
route();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
})();