Files
hub/public/app.js
T
nmemmertandClaude Sonnet 5.5 ee2678d9ed
build-container / build (push) Successful in 1m44s
Podcast tab: show podcast.necloud.us inside the app
Audio and Podcast now share kept-alive frames that stay loaded while hidden.
A plain-http address still falls back to a launcher (a secure page cannot
frame http content).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:18:42 -04:00

1262 lines
96 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const store = {
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} },
del(k) { try { localStorage.removeItem(k); } catch {} },
};
const ICONS = {
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
files: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
doc: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5M10 13h6M10 17h6"/>',
cog: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
chev: '<path d="M9 6l6 6-6 6"/>',
back: '<path d="M15 6l-6 6 6 6"/>',
edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/>',
up: '<path d="M12 16V4M7 9l5-5 5 5M5 20h14"/>',
newdir: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2zM12 10v6M9 13h6"/>',
file: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5"/>',
dl: '<path d="M12 4v12M7 11l5 5 5-5M5 20h14"/>',
audio: '<path d="M4 14v-2a8 8 0 0116 0v2"/><rect x="3" y="14" width="4" height="7" rx="1.5"/><rect x="17" y="14" width="4" height="7" rx="1.5"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
prev: '<path d="M18 5L8 12l10 7zM6 5v14"/>',
shuffle: '<path d="M3 7h3c4 0 5 10 9 10h6M3 17h3c1.5 0 2.5-1.5 3.5-3M15 7h6M18 4l3 3-3 3M18 14l3 3-3 3"/>',
more: '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>',
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
};
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
// ---- 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'], ['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' };
const PRIMARY = ['home', 'files', '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]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span></a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
if (phone.matches) {
const more = !PRIMARY.includes(active);
$('#nav').innerHTML = TABS.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
`<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>`;
$('#more').onclick = e => { e.preventDefault(); openMore(); };
} else $('#nav').innerHTML = brand + TABS.map(tab).join('');
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0])).map(([id, label]) => `
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${icon('chev', 'chev')}</a>`).join('')}</div>`);
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, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
let editor;
function route() {
const [, id = 'home', ...rest] = location.hash.split('/');
const name = routes[id] ? id : 'home';
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 `<a class="tile" href="${esc(href)}" ${external ? 'target="_blank" rel="noopener"' : ''}>
<span class="ic" style="background:${color}">${icon(ic)}</span><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
}
// ---- Views
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 = `<div class="wrap"><h1>${greet}</h1>
<a class="searchbar" href="#/search">${icon('search')}<span>Search files, music, libraries</span></a>
${t ? `<h2>Now playing</h2><button class="row card2" id="hnp"><img class="art" style="width:48px;height:48px;border-radius:8px" alt="" src="${px.art(t.thumb, 100)}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>` : ''}
<div id="agenda"></div>
${recents.length ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
<h2>Apps</h2><div class="grid">
${tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile')}
${tile('#/music', '#e5a00d', 'music', 'Music', 'Plex')}
${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')}
${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')}
${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')}
${tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts')}
${tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true)}
${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}
</div>
<h2>Mail</h2><div class="grid">
${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
</div></div>`;
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 (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)
function viewSearch() {
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Files, libraries, artists, albums, tracks" autocomplete="off" autocapitalize="off">
<div id="res"></div></div>`;
const q = $('#q'); q.focus();
let timer, seq = 0;
q.oninput = () => { clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
async function runSearch(term, mySeq) {
const res = $('#res');
if (term.length < 2) { res.innerHTML = ''; return; }
res.innerHTML = '<p class="empty">Searching…</p>';
const enc = encodeURIComponent;
const [files, libs, music] = await Promise.allSettled([
sf.token() ? sf.req(`/api2/search/?q=${enc(term)}&per_page=20`) : Promise.reject(new Error('signed out')),
sf.token() ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')),
jfetch('/api/plex/status').then(st => st.server ? px.get(`/hubs/search?query=${enc(term)}&limit=8`) : Promise.reject(new Error('no server'))),
]);
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
const lower = term.toLowerCase();
const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
`<a class="row" href="#/files/${enc(r.id)}"><span class="ic" style="background:#2f9e6e">${icon('files')}</span><span class="grow"><b>${esc(r.name)}</b><small>Library</small></span></a>`).join('') : '';
let found = files.status === 'fulfilled' ? files.value.results || [] : null;
if (!found && libs.status === 'fulfilled') found = await walkSearch(libs.value, lower); // free Seafile has no search index; scan names instead
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const fileHtml = found ? found.map(f => {
const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
return f.is_dir ? `<a class="row" href="#/files/${enc(f.repo_id)}${f.fullpath.split('/').filter(Boolean).map(x => '/' + enc(x)).join('')}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(f.fullpath)}</small></span></a>`
: `<button class="row" data-f="${esc(JSON.stringify([f.repo_id, f.fullpath, f.name, parent]))}"><span class="ic" style="background:#6a7280">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(parent)}</small></span></button>`;
}).join('') : '';
let musicHtml = '';
if (music.status === 'fulfilled') {
for (const hub of music.value.Hub || []) for (const m of hub.Metadata || []) {
if (m.type === 'artist') musicHtml += `<a class="row" href="#/music/artist/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:50%" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Artist</small></span></a>`;
else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`;
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; }
}
}
const note = !found && sf.token() ? '<p class="sub">Could not search Seafile files right now.</p>' : '';
res.innerHTML = sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + note || '<p class="empty">No results</p>';
if (!libHtml && !fileHtml && !musicHtml) res.innerHTML = '<p class="empty">No results</p>' + note;
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, d] = JSON.parse(b.dataset.f); openFile(r, p, n, d); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
}
}
// ---- 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 `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b>
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
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 = `<h2>Today</h2><div class="list">${list.length ? list.slice(0, 6).map(evRow).join('') : '<div class="row"><small>Nothing on the calendar today</small></div>'}</div>`;
} 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 = `<div class="wrap"><h1>Calendar</h1>
<div class="seg">${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" data-v="${k}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>
<div class="bar" style="margin:0 0 8px"><button class="btn" id="ne">${icon('cal')}Add event</button></div>
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
$('#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 = '<p class="empty">No calendars yet. Add a private calendar link to see your events here.</p><div class="bar" style="justify-content:center"><button class="btn" id="ac">Add a calendar</button></div>'; $('#ac').onclick = addCalendar; return; }
if (mode === 'month') await renderMonth(); else await renderAgendaList();
} catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
}
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 = `<div class="seg">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<a href="#" data-n="${n}" class="${n === range ? 'on' : ''}">${l}</a>`).join('')}</div>`
+ (keys.map(k => `<h2>${esc(dayLabel(k))}</h2><div class="list">${byDay.get(k).map(evRow).join('')}</div>`).join('') || '<p class="empty">Nothing coming up</p>')
+ errors.map(e => `<p class="err">${esc(e)}</p>`).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(`<button class="cday${other ? ' dim' : ''}${k === today ? ' today' : ''}${k === calState.sel ? ' sel' : ''}" data-d="${k}" aria-label="${esc(d.toDateString())}, ${evs.length} events">
<span class="dn">${d.getDate()}</span>
<span class="dots">${evs.slice(0, 4).map(e => `<i style="background:${esc(e.color)}"></i>`).join('')}</span>
<span class="chips">${evs.slice(0, 3).map(e => `<em style="--c:${esc(e.color)}">${esc(e.title)}</em>`).join('')}${evs.length > 3 ? `<small>+${evs.length - 3} more</small>` : ''}</span></button>`);
}
$('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="pm" aria-label="Previous month">${icon('back')}</button>
<h2 style="margin:0;flex:1;text-align:center;font-size:17px;text-transform:none;letter-spacing:0;color:var(--text)">${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}</h2>
<button class="ibtn" id="nm" aria-label="Next month" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tm" style="min-height:36px">Today</button></div>
<div class="cgrid wk">${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `<span>${d}</span>`).join('')}</div>
<div class="cgrid">${cells.join('')}</div>
<div id="dayev"></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`;
const showDay = () => {
const k = calState.sel, evs = byDay.get(k) || [];
$('#dayev').innerHTML = `<h2>${esc(dayLabel(k))}</h2><div class="list">${evs.length ? evs.map(evRow).join('') : '<div class="row"><small>No events</small></div>'}</div>`;
$('#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, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div>
${e.location ? `<div class="row"><span class="grow"><b>Where</b><small style="white-space:normal">${esc(e.location)}</small></span></div>` : ''}</div>
<p class="sub">This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
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 ? `<div class="row" style="background:var(--panel2);border-radius:12px"><span class="grow"><small>Calendar</small><b>${esc(o.calName)}</b></span></div>`
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : ''}</option>`).join('')}</select>`;
const el = openSheet(editing ? 'Edit event' : 'New event', '', `<form id="ef"><input type="text" id="ft" placeholder="Title" value="${esc(f.title)}" autocomplete="off" required style="margin-top:6px">
<label class="chk"><input type="checkbox" id="fa" ${f.allDay ? 'checked' : ''}> All day</label>
<div id="fdt"></div>
<label for="fr">Repeat</label><select id="fr">${opts.map(([v, l]) => `<option value="${v}" ${v === f.repeat ? 'selected' : ''}>${l}</option>`).join('')}</select>
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
${calPick}
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${editing ? 'Save' : 'Add'}</button></div>
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
<div class="err" id="fe"></div></form>`);
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 = `<label for="fs">Starts</label><input type="${t}" id="fs" value="${cut(f.start)}" required><label for="fe2">Ends</label><input type="${t}" id="fe2" value="${cut(f.end)}" required>
${!f.allDay && f.tz !== deviceTz() ? `<p class="sub" style="margin:6px 0 0">Times are in ${esc(f.tz)}.</p>` : ''}`;
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', '', `<form><label>Name</label><input type="text" id="cn" placeholder="Personal" autocomplete="off">
<label>Private calendar link (ICS)</label><input type="url" id="cu2" placeholder="https://… or webcal://…" autocomplete="off" autocapitalize="off" required>
<p class="sub" style="margin:10px 0 0">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.</p>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Add</button></div><div class="err" id="ce"></div></form>`);
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', '', `<div class="list" style="background:var(--panel2);border:0">
<div class="row"><span class="dot" style="background:#4f7cff"></span><span class="grow"><b>NE Hub</b><small>Events you add here</small></span></div>
${links.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('')}
</div>
<div class="bar"><button class="btn ghost" id="al" style="flex:1;justify-content:center">Add a calendar link</button></div>
<h2 style="margin-top:14px">iCloud calendars</h2>
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<label class="row" style="cursor:pointer"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></label>`).join('')}</div>
<p class="sub">Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.</p><div class="bar"><button class="btn danger" id="icx" style="flex:1;justify-content:center">Disconnect iCloud</button></div>`
: `<p class="sub">Read and add events in your iCloud calendars. Use an <b>app-specific password</b> (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.</p>
<form id="icf"><input type="text" id="iu" placeholder="Apple ID (email)" autocomplete="off" autocapitalize="off" required><input type="password" id="ip" placeholder="App-specific password" autocomplete="off" style="margin-top:8px" required>
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Connect iCloud</button></div><div class="err" id="ice"></div></form>`}
<h2 style="margin-top:14px">See NE Hub events in other apps</h2>
<p class="sub">Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).</p>
<input type="text" readonly id="fu" value="${esc(feed.url)}" onfocus="this.select()">
<div class="bar"><button class="btn ghost" id="fc2" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" id="fr2" style="flex:1;justify-content:center">Make a new link</button></div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
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(); });
}
function viewMail() {
$('#view').innerHTML = `<div class="wrap"><h1>Mail</h1>
<p class="sub">Opens each provider's own webmail in a new tab. Nothing is stored here.</p>
<h2>Inboxes</h2><div class="list">${MAIL.map(m => `
<a class="row" href="${esc(mailUrl(m, 'inbox'))}" target="_blank" rel="noopener">
<span class="ic" style="background:${m.color}">${icon('mail')}</span>
<span class="grow"><b>${esc(m.name)}</b><small>Inbox</small></span>${icon('chev', 'chev')}</a>`).join('')}</div>
<h2>Compose</h2><div class="list">${MAIL.map(m => `
<a class="row" href="${esc(mailUrl(m, 'compose'))}" target="_blank" rel="noopener">
<span class="ic" style="background:${m.color}">${icon('edit')}</span>
<span class="grow"><b>New message</b><small>from ${esc(m.name)}</small></span>${icon('chev', 'chev')}</a>`).join('')}</div></div>`;
}
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 = `<div class="wrap"><h1>Office</h1><p class="sub">Create, open and edit documents without leaving this tab. Everything is saved in Seafile.</p>
<div class="grid">
<button class="tile" data-x="docx" data-l="document"><span class="ic" style="background:#3d6bf0">${icon('doc')}</span><b>Document</b><small>Word (.docx)</small></button>
<button class="tile" data-x="xlsx" data-l="spreadsheet"><span class="ic" style="background:#2f9e6e">${icon('doc')}</span><b>Spreadsheet</b><small>Excel (.xlsx)</small></button>
<button class="tile" data-x="pptx" data-l="presentation"><span class="ic" style="background:#e0742b">${icon('doc')}</span><b>Presentation</b><small>PowerPoint (.pptx)</small></button>
</div>
<p class="sub" id="olib" style="margin-top:12px">${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
<div id="odocs"></div>
<div class="bar"><a class="btn ghost" href="#/files">Browse all files</a>${sf.token() ? '' : '<a class="btn" href="#/files">Sign in</a>'}</div></div>`;
$('#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 = '<div class="bar"><button class="btn" id="chl2">Choose location</button></div>';
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } });
return;
}
el.innerHTML = `Saved in <b>${esc(locLabel(loc))}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>`;
$('#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 ? `<h2>Documents</h2><div class="list">${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 `<button class="row" data-i="${i}"><span class="ic" style="background:${color}">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.parent_dir || '/')} · ${fmtDate(f.mtime)}</small></span></button>`; }).join('')}</div>`
: '<p class="empty">No documents here yet. Create one above.</p>';
$('#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 = `<span class="err">${esc(e.message)}</span>`; }
})();
}
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, '', '<div id="cf"></div>');
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
const finish = v => { done = true; closeSheet(); resolve(v); };
const draw = async () => {
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty">Loading…</p>';
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 = `<p class="sub">Choose a library</p><div class="list" style="background:var(--panel2);border:0;max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No libraries. Create one in Files.</p>'}</div>
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button></div>`;
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 = `<p class="sub" style="word-break:break-all">${esc(locLabel({ repoName, dir }))}</p><div class="list" style="background:var(--panel2);border:0;max-height:45dvh;overflow:auto">
<button class="row" data-up="1">${icon('back')}<span class="grow"><b>${dir === '/' ? 'All libraries' : 'Up'}</b></span></button>
${subs.map(x => `<button class="row" data-d="${esc(x.name)}">${icon('files')}<span class="grow"><b>${esc(x.name)}</b></span>${icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="cs" style="flex:1;justify-content:center">Save here</button></div>`;
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 = `<p class="err">${esc(e.message)}</p>`; }
};
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, '', `<form><label for="nn">File name</label><input type="text" id="nn" value="Untitled" autocomplete="off" autocapitalize="off" required>
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`);
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 = `<div class="wrap"><h1>Podcast Admin</h1><p class="sub">${esc(host + new URL(url).pathname)}</p>
<div class="card" style="max-width:none"><p style="margin-top:0">This site doesn't allow being shown inside another app, so it opens in its own tab.</p>
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">${icon('book')}Open admin</a></div></div></div>`;
}
// 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 = `<div class="wrap"><h1>Podcast</h1><p class="sub">${esc(u.host)}</p>
<div class="bar" style="margin-bottom:0"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a></div>
${canFrame ? '<p class="sub" style="margin:8px 0 0">If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.</p>'
: '<div class="card" style="max-width:none;margin-top:12px"><p style="margin:0">This is a plain <b>http</b> address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.</p></div>'}</div>`;
if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
}
function viewAudio() {
const url = cfg.audiobooks, c = abCreds();
$('#view').innerHTML = `<div class="wrap"><h1>Audio</h1>
<p class="sub">${esc(new URL(url).host)}</p>
<div class="bar" style="margin-bottom:0"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a>
${c.u ? `<button class="btn ghost" id="cu">${icon('copy')}Username</button>` : ''}
${c.p ? `<button class="btn ghost" id="cp">${icon('copy')}Password</button>` : ''}
${c.u || c.p ? '' : '<a class="btn ghost" href="#/settings">Save sign-in</a>'}</div></div>`;
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 = '<div class="wrap"><h1>Music</h1><p class="empty">Loading…</p></div>';
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 = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rs">Change server</button></div></div>`;
$('#rs').onclick = plexPickServer;
}
}
function plexSignIn(msg = '') {
$('#view').innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Play your Plex music library here.</p>
<div class="card"><p style="margin-top:0">You'll sign in on plex.tv in a new tab. This app never sees your Plex password.</p>
<div class="bar"><button class="btn" id="pl">Sign in with Plex</button></div><div class="err" id="pe">${esc(msg)}</div></div></div>`;
$('#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 = `<a href="${esc(pin.authUrl)}" target="_blank" rel="noopener" style="color:var(--accent)">Tap to open Plex sign-in</a>`;
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 = '<div class="wrap"><h1>Music</h1><p class="empty">Finding your Plex servers…</p></div>';
try {
const servers = await jfetch('/api/plex/servers');
if (!servers.length) { v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="err">No Plex servers found on this account.</p></div>'; return; }
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Choose a server</p><div class="list">${servers.map(s => `
<button class="row" data-id="${esc(s.id)}"><span class="ic" style="background:#e5a00d">${icon('music')}</span>
<span class="grow"><b>${esc(s.name)}</b><small>${s.owned ? 'Yours' : 'Shared with you'}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="pe"></div><div id="pm"></div></div>`;
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 = `<p class="sub">Know the server's address? Enter it (for example <b>http://192.168.1.10:32400</b>).</p>
<form id="pf2"><input type="url" id="pu" placeholder="http://192.168.1.10:32400" autocomplete="off" autocapitalize="off" required>
<div class="bar"><button class="btn">Try this address</button></div></form>`;
$('#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 = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p></div>`; }
}
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 = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
const listing = (items, html) => {
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}</p>${seg}<input type="search" id="mq" placeholder="Filter" autocomplete="off">
<div class="agrid" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
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 = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
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 = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button></div>
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
<button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
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 <audio> 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', `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
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 = `<div class="mini"><div class="bar2"><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button>
<button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button></div>`;
$('#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('', '', `<div class="np"><img class="art" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0">${esc(t.title)}</h3>
<p class="sub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
<div class="bar" style="justify-content:center"><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button></div></div>`);
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();
}
function viewSettings() {
const signed = !!sf.token();
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<h2>Connections</h2><div class="list">
<div class="row"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
<div class="row"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
</div>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}
<h2>Mail accounts</h2><p class="sub">Your address for each account, so its inbox and new-message buttons open the right mailbox. Stored only in this browser. Passwords are never saved here.</p>
<div class="card" style="max-width:none">${MAIL.filter(m => m.id !== 'icloud').map(m => `<label for="h-${m.id}">${esc(m.name)}</label>
<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}">`).join('')}
<p class="sub" style="margin-top:12px">iCloud Mail can't take a hint; use Keychain autofill or stay signed in.</p></div>
<h2>Audiobooks sign-in</h2><p class="sub">Saved only in this browser, in plain text, so anyone with access to this device can read it. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.</p>
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
<label for="ab-p">Password</label>
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div>
<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
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 });
$('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb;
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'; });
const loadPks = async () => {
try {
const pks = await (await fetch('/api/auth/passkeys')).json();
$('#pks').innerHTML = pks.map(k => `<div class="row"><span class="grow"><b>${esc(k.name)}</b><small>Added ${new Date(k.created).toLocaleDateString()}${k.lastUsed ? ' · used ' + new Date(k.lastUsed).toLocaleDateString() : ''}</small></span>
<button class="btn danger" data-id="${esc(k.id)}" style="min-height:36px">Remove</button></div>`).join('') || '<div class="row"><small>No passkeys</small></div>';
$('#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 = `<div class="row"><small class="err">${esc(e.message)}</small></div>`; }
};
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:', `<p style="font:700 34px ui-monospace,monospace;letter-spacing:.08em;text-align:center;margin:16px 0">${esc(code)}</p>
<p class="sub" style="text-align:center">Works once, and expires in ${minutes} minutes.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
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 = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b></div>
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
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 = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
<div class="libs" id="libs"><p class="empty">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
loadLibs(repo);
if (repo) loadDir(repo, dir);
}
function filesLogin(msg = '', needOtp = false) {
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><p class="sub">Sign in to ${esc(new URL(cfg.seafile).host)}</p>
<form class="card" id="lf"><label>Email / username</label><input type="text" name="u" autocomplete="username" autocapitalize="off" required>
<label>Password</label><input type="password" name="p" autocomplete="current-password" required>
${needOtp ? '<label>2FA code</label><input type="text" name="o" inputmode="numeric" autocomplete="one-time-code">' : ''}
<div class="bar"><button class="btn" type="submit">Sign in</button></div><div class="err">${esc(msg)}</div></form></div>`;
$('#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 = `<div class="bar" style="margin-top:0"><button class="btn ghost" id="nl">${icon('newdir')}New library</button></div><h2 style="margin-top:8px">Libraries</h2><div class="list">${repos.map(r => `
<a class="row" href="#/files/${encodeURIComponent(r.id)}" ${r.id === active ? 'style="background:var(--panel2)"' : ''}>
<span class="ic" style="background:#2f9e6e">${icon('files')}</span>
<span class="grow"><b>${esc(r.name)}</b><small>${r.encrypted ? 'Encrypted · ' : ''}${esc(r.type === 'repo' ? 'Mine' : r.owner_name || r.owner || 'Shared')}</small></span>${icon('chev', 'chev')}</a>`).join('') || '<p class="empty">No libraries</p>'}</div>`;
$('#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 = '<p class="empty">This library is encrypted. Unlock it in the Seafile web UI first.</p>';
} catch (e) { $('#libs').innerHTML = `<p class="err">${esc(e.message)}</p>`; 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 = [`<button onclick="location.hash='#/files/${enc(repo)}'">Library</button>`].concat(segs.map((s, i) =>
`<span>/</span><button onclick="location.hash='${hash('/' + segs.slice(0, i + 1).join('/'))}'">${esc(s)}</button>`)).join('');
main.innerHTML = `<div class="bar">
<a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a>
<label class="btn ghost" style="margin:0;color:var(--text)">${icon('up')}Upload<input id="up" type="file" multiple hidden></label>
<button class="btn ghost" id="mk">${icon('newdir')}New…</button></div>
<div class="crumbs">${crumbs}</div><div id="items"><p class="empty">Loading…</p></div>`;
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 ? `<div class="list">${items.map((it, i) => it.type === 'dir'
? `<div class="row it"><a class="rmain" href="${hash(joinP(dir, it.name))}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(it.name)}</b><small>${fmtDate(it.mtime)}</small></span></a><button class="ibtn" data-i="${i}" aria-label="Actions for ${esc(it.name)}">${icon('more')}</button></div>`
: `<div class="row it"><button class="rmain" data-open="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(it.name)}</b><small>${fmtSize(it.size)} · ${fmtDate(it.mtime)}</small></span></button><button class="ibtn" data-i="${i}" aria-label="Actions for ${esc(it.name)}">${icon('more')}</button></div>`).join('')}</div>`
: '<p class="empty">This folder is empty</p>';
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 = `<p class="err">${esc(e.message)}</p>`; }
}
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 = `<div class="scrim"></div><div class="sheet" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<div class="grip"></div><h3>${esc(title)}</h3>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}${bodyHtml}</div>`;
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, `<div class="list">${actions.map((a, i) => `
<button class="row ${a.danger ? 'dng' : ''}" data-i="${i}">${icon(a.icon)}<span class="grow"><b>${esc(a.label)}</b></span></button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button></div>`);
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, '', `<form><label>${esc(label)}</label><input type="text" id="tv" autocapitalize="off" autocomplete="off" required value="${esc(initial)}">
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button>
<button class="btn" style="flex:1;justify-content:center">${esc(okLabel)}</button></div></form>`);
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, `<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button>
<button class="btn" id="so" style="flex:1;justify-content:center;background:var(--danger);color:#fff">${esc(okLabel)}</button></div>`);
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' ? `<img src="${url}" alt="${esc(name)}" style="max-width:100%;max-height:70dvh;border-radius:12px;display:block;margin:8px auto">`
: kind === 'pdf' ? `<iframe src="${url}" title="${esc(name)}" style="width:100%;height:70dvh;border:0;border-radius:12px;background:#fff"></iframe>`
: kind === 'video' ? `<video src="${url}" controls playsinline style="width:100%;max-height:70dvh;border-radius:12px;background:#000"></video>`
: `<audio src="${url}" controls style="width:100%;margin:12px 0"></audio>`;
const el = openSheet(name, '', `${media}<div class="bar"><a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Open</a><button class="btn ghost" id="sx">Close</button></div>`);
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() || '', `<input type="text" readonly id="sl" value="${esc(link)}" onfocus="this.select()">
<div class="bar"><button class="btn" id="cp1" style="flex:1;justify-content:center">${icon('copy')}Copy</button>${navigator.share ? '<button class="btn ghost" id="sh1" style="flex:1;justify-content:center">Share…</button>' : ''}</div>`);
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, '<div id="pf"></div>');
const draw = async () => {
const box = el.querySelector('#pf');
box.innerHTML = '<p class="empty">Loading…</p>';
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 = `<p class="sub" style="word-break:break-all">${esc(at)}</p><div class="list" style="background:var(--panel2);border:0;max-height:40dvh;overflow:auto">
${at !== '/' ? `<button class="row" data-up="1">${icon('back')}<span class="grow"><b>Up</b></span></button>` : ''}
${subs.map(x => `<button class="row" data-d="${esc(x.name)}">${icon('files')}<span class="grow"><b>${esc(x.name)}</b></span>${icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="mv" style="flex:1;justify-content:center"${at === dir ? ' disabled' : ''}>Move here</button></div>`;
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 = `<p class="err">${esc(e.message)}</p>`; }
};
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 {}
addEventListener('hashchange', route);
restorePlayer();
route();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
})();