diff --git a/README.md b/README.md
index bb216b7..e0bb9d7 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,15 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
not connected for about a month; the hub stays connected, so this only matters if the stack is switched off.
- **Links**: your own bookmarks page. Add a name, address and optional group; filter as you type; opens in a new tab. Saved with your settings on the server, private to you, same on every device. Only http(s) addresses are accepted.
- **Security and backups**: every response carries `nosniff`, a `frame-ancestors 'self'` policy and a same-origin referrer policy; writes that come from another website are refused; JSON files are written atomically (temp file, then rename). Admins get **Settings -> Storage -> Download a backup** (a .tar.gz of `/data`; keep it private). `/healthz` answers without signing in and the container has a health check. In-app OnlyOffice editing now requires `ONLYOFFICE_JWT_SECRET` (or `ALLOW_UNSIGNED_OO=1` for testing). `npm test` runs every test script; CI also runs `npm audit`.
+- **Command palette and quick add**: press **Ctrl/Cmd-K** anywhere (or the Command palette button on Home) to jump to any page, create things, open a saved link or find a task/event. Typing a sentence offers **Add task**, and understands a date at the end ("Call mom tomorrow", "Pay rent in 3 days", "Dentist fri", "2026-12-25"). **Quick add** on Home does the same plus new event and new link. Search also finds your links.
+- **Tasks**: repeating tasks (daily, weekdays, weekly, monthly, yearly; finishing one moves it to its next date) and one level of subtasks, in NE Hub lists (iCloud reminder lists stay as Apple stores them).
+- **Calendar**: Month, Week, Day and Agenda. Week and Day show a time grid with overlapping events side by side, an all-day strip and a "now" line; tap an empty slot to add an event.
+- **Music playlists and sleep timer**: playlists can be created, added to ("Add to playlist" on any track, or "Save as playlist" from the queue), renamed, have tracks removed and be deleted. The hub only lets playlist writes through to Plex; everything else on the Plex server stays read-only. The Sleep button (Now Playing card, or the queue) fades the music out after 15-90 minutes or when the song ends.
+- **Offline and accessibility**: the app page and its scripts are cached so it opens without a connection, with a notice while offline. Icon buttons have names, there is a skip link, sheets close with Escape and keep focus inside, and the command palette is a proper combobox.
+- **Reminders (Web Push)**: Settings -> Reminders turns on notifications on that device for tasks due today (one morning summary, at the time you choose) and events about to start. Works with the app closed; on iPhone/iPad only from the installed home-screen app. Keys are made once in `/data/vapid.json`; set `VAPID_SUBJECT` to a `mailto:` address if you want one in the push messages. Needs HTTPS.
+- **Recovery codes and activity log**: Settings -> Recovery codes makes 8 one-time codes; if you lose every device, the sign-in page's "Use an invite or recovery code" adds a new passkey with one (codes are stored only as hashes). Admins see an **Activity** list in Settings (sign-ins, failed sign-ins, passkeys added or removed, invites, recovery code use, people changes, backups); it is `/data/audit.log` and never contains codes or passwords.
+- **File version history**: on any Seafile file, "Version history" lists earlier versions with who and when, and can download or restore one (restoring keeps the current version in the history). This uses Seafile's history API and has not been tried against a live Seafile server yet.
+- **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`.
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
- **Mail**: Cypht comes with the project (a second container in `docker-compose.yml`), so the Mail tab can open a full webmail inside the app (the shortcut cards are the default page; the **Open webmail** button there opens it, and the envelope button in
diff --git a/public/app.js b/public/app.js
index a9b17ef..ea41e4e 100644
--- a/public/app.js
+++ b/public/app.js
@@ -183,7 +183,7 @@ function openMore() {
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
-const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit };
+const routesOf = () => ({ home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
@@ -218,6 +218,7 @@ addEventListener('popstate', e => { if (e.state && e.state.i != null) { navIdx =
function route() {
trackNav();
const [, id = 'home', ...rest] = location.hash.split('/');
+ const routes = routesOf();
let name = routes[id] ? id : 'home';
if (name === 'edit' ? !(allowed('files') || allowed('office')) : !allowed(name)) { name = 'home'; if (location.hash !== '#/home') history.replaceState(null, '', '#/home'); } // not one of their tabs
try { editor?.destroyEditor(); } catch {}
@@ -293,2270 +294,3 @@ async function walkSearch(repos, term) {
}
return out;
}
-
-// ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others)
-// Seafile: use the search index when the server has one; if it is missing OR comes back empty, scan file names library by library.
-async function searchFiles(term, libs) {
- const enc = encodeURIComponent, lower = term.toLowerCase();
- let results = [], via = 'index';
- try { results = (await sf.req(`/api2/search/?q=${enc(term)}&per_page=20`)).results || []; } catch {}
- if (!results.length) { results = await walkSearch(libs, lower); via = 'scan'; }
- return { results, via, scanned: libs.filter(l => !l.encrypted).length };
-}
-
-// Plex: the server-wide search first; if that finds nothing, filter the artists and albums of the chosen music library directly.
-async function searchMusic(term) {
- const enc = encodeURIComponent, lower = term.toLowerCase(), items = [];
- try {
- const mc = await px.get(`/hubs/search?query=${enc(term)}&limit=10`);
- for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['artist', 'album', 'track'].includes(m.type)) items.push(m);
- } catch {}
- if (!items.length) {
- const sec = await px.section(), big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
- const [artists, albums] = await Promise.all([px.get(`/library/sections/${sec}/all?type=8${big}`), px.get(`/library/sections/${sec}/all?type=9${big}`)]);
- for (const a of artists.Metadata || []) if ((a.title || '').toLowerCase().includes(lower)) items.push({ ...a, type: 'artist' });
- for (const a of albums.Metadata || []) if (((a.title || '') + ' ' + (a.parentTitle || '')).toLowerCase().includes(lower)) items.push({ ...a, type: 'album' });
- }
- return items.slice(0, 16);
-}
-
-function viewSearch() {
- $('#view').innerHTML = `
`;
- const q = $('#q'); q.focus();
- let timer, seq = 0, plexSt = null;
- if (window.pendingSearch) { q.value = window.pendingSearch; window.pendingSearch = ''; setTimeout(() => q.oninput && q.oninput(), 0); }
- // One line that says what Search covers and what each source returned, with a link to sign in where a source needs it.
- const showSources = (r = {}) => {
- const el = $('#src'); if (!el) return;
- const link = (href, t) => `${t} `, bad = t => `${esc(t)} `;
- const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
- const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
- const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
- el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)}`;
- };
- jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
- showSources();
- // Duck.ai and DuckDuckGo refuse to be shown inside other apps, so these hand the question over in a new tab.
- const webLinks = () => { const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); };
- q.oninput = () => { webLinks(); clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
- async function runSearch(term, mySeq) {
- const res = $('#res');
- if (term.length < 2) { res.innerHTML = ''; showSources(); return; }
- res.innerHTML = 'Searching…
';
- const enc = encodeURIComponent, lower = term.toLowerCase();
- const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
- const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
- const [libs, files, music, ddg, hub] = await Promise.allSettled([
- libsP,
- libsP.then(l => searchFiles(term, l)),
- plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
- jfetch('/api/ddg?q=' + enc(term)),
- jfetch('/api/search?q=' + enc(term)),
- ]);
- if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
- const sec = (title, html) => html ? `${title} ${html}
` : '';
- const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
- `${icon('files')} ${esc(r.name)} Library `).join('') : '';
- const found = files.status === 'fulfilled' ? files.value.results : [];
- const fileHtml = found.map(f => {
- const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
- return f.is_dir ? `${icon('files')} ${esc(f.name)} ${esc(f.repo_name || '')} ${esc(f.fullpath)} `
- : `${icon('file')} ${esc(f.name)} ${esc(f.repo_name || '')} ${esc(parent)} `;
- }).join('');
- let musicHtml = '';
- for (const m of music.status === 'fulfilled' ? music.value : []) {
- if (m.type === 'artist') musicHtml += `${esc(m.title)} Artist `;
- else if (m.type === 'album') musicHtml += `${esc(m.title)} Album · ${esc(m.parentTitle || '')} `;
- else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `${esc(m.title)} Track · ${esc(m.grandparentTitle || '')} `; }
- }
- const d = ddg.status === 'fulfilled' ? ddg.value : {};
- const card = (d.text || d.answer) ? `
- ${d.image ? `
` : ''}
-
From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}
- ${d.heading ? `
${esc(d.heading)}
` : ''}${d.answer ? `
${esc(d.answer)}
` : ''}
- ${d.text ? `
${esc(d.text)}
` : ''}${d.url ? `
Read more ` : ''}
` : '';
- const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] };
- evStore.length = 0;
- const taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due);
- return `${icon('check')} ${esc(t.title)} ${esc(t.listName)}${du ? ' · ' + esc(du.text) : ''}${t.done ? ' · done' : ''} `; }).join('');
- const eventHtml = (h.events || []).map(e => { const i = evStore.push(e) - 1, dt = e.allDay ? new Date(e.start + 'T00:00:00') : new Date(e.start);
- return `${esc(e.title)} ${esc(dt.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }))}${e.allDay ? ' · all day' : ' · ' + esc(fmtTime(e.start))} · ${esc(e.cal)} ${e.editable ? icon('chev', 'chev') : ''} `; }).join('');
- // what each source did, so an empty screen always has an explanation
- showSources({
- tasks: hub.status === 'fulfilled' ? { n: (h.tasks || []).length } : { err: hub.reason && hub.reason.message },
- events: hub.status === 'fulfilled' ? { n: (h.events || []).length } : { err: hub.reason && hub.reason.message },
- files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' },
- music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
- });
- const notes = [...(h.errors || [])].map(e => `${esc(e)}
`).join('');
- const linkHtml = allowed('links') ? linkList().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `${icon('link')} ${esc(l.title || l.url)} ${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''} `).join('') : '';
- res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes;
- if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = 'No results. The line above shows what was searched.
' + notes;
- res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
- res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
- res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; });
- }
-}
-
-// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
-const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
-async function loadRange(start, days) {
- const end = new Date(start); end.setDate(end.getDate() + days);
- const last = new Date(end.getTime() - 1);
- const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) });
- const { events, errors } = await jfetch('/api/cal/events?' + q);
- const byDay = new Map();
- const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); };
- for (const e of events) {
- if (e.allDay) { // spans start..end-1 (end exclusive)
- for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); }
- } else put(ymd(new Date(e.start)), e);
- }
- for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
- return { byDay, errors, start };
-}
-const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); };
-const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
-const evStore = []; // events currently on screen, so a tap can open the right one
-const evRow = e => { const i = evStore.push(e) - 1;
- return `${esc(e.title)}
- ${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''} ${e.editable ? icon('chev', 'chev') : ''} `; };
-document.addEventListener('click', e => { const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]); });
-const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
- return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
-
-async function renderAgenda(el, mode) { // Home card (today only)
- if (!el) return;
- try {
- const cals = await jfetch('/api/cal/list');
- const { byDay } = await loadAgenda(1);
- const list = byDay.get(ymd(new Date())) || [];
- if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
- if (!document.body.contains(el)) return;
- el.innerHTML = `Today ${list.length ? list.slice(0, 6).map(evRow).join('') : '
Nothing on the calendar today
'}
`;
- } catch {}
-}
-
-const now0 = new Date();
-const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
-
-async function viewCalendar() {
- const mode = store.get('cal_view', 'month');
- const v = $('#view');
- v.innerHTML = `Calendar
-
${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `
${l} `).join('')}
-
${icon('cal')}Add event
-
Manage calendars
`;
- $('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
- v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
- $('#mc').onclick = manageCalendars;
- try {
- const cals = await jfetch('/api/cal/list');
- if (!cals.length) { $('#cbody').innerHTML = 'No calendars yet. Add a private calendar link to see your events here.
Add a calendar
'; $('#ac').onclick = addCalendar; return; }
- if (mode === 'month') await renderMonth(); else if (mode === 'week') await renderTimeGrid(7); else if (mode === 'day') await renderTimeGrid(1); else await renderAgendaList();
- } catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `${esc(e.message)}
`; }
-}
-
-async function renderAgendaList() {
- const range = store.get('cal_range', 7);
- const { byDay, errors } = await loadAgenda(range);
- if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
- const keys = [...byDay.keys()].sort();
- $('#cbody').innerHTML = `${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `
${l} `).join('')}
`
- + (keys.map(k => `${esc(dayLabel(k))} ${byDay.get(k).map(evRow).join('')}
`).join('') || 'Nothing coming up
')
- + errors.map(e => `${esc(e)}
`).join('');
- $('#cbody').querySelectorAll('.seg a[data-n]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
-}
-
-async function renderMonth() {
- const { y, m } = calState;
- const first = new Date(y, m, 1), gridStart = new Date(y, m, 1 - first.getDay()); // the Sunday on or before the 1st
- const { byDay, errors } = await loadRange(gridStart, 42);
- if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
- const today = ymd(new Date());
- if (calState.sel < ymd(gridStart) || calState.sel > ymd(new Date(y, m, 1 - first.getDay() + 41))) calState.sel = ymd(first);
- const cells = [];
- for (let i = 0; i < 42; i++) {
- const d = new Date(gridStart); d.setDate(d.getDate() + i);
- const k = ymd(d), evs = byDay.get(k) || [], other = d.getMonth() !== m;
- cells.push(`
- ${d.getDate()}
- ${evs.slice(0, 4).map(e => ` `).join('')}
- ${evs.slice(0, 3).map(e => `${esc(e.title)} `).join('')}${evs.length > 3 ? `+${evs.length - 3} more ` : ''} `);
- }
- $('#cbody').innerHTML = `${icon('back')}
-
${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}
- ${icon('back')} Today
- ${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `${d} `).join('')}
- ${cells.join('')}
-
${errors.map(e => `${esc(e)}
`).join('')}`;
- const showDay = () => {
- const k = calState.sel, evs = byDay.get(k) || [];
- $('#dayev').innerHTML = `${esc(dayLabel(k))} ${evs.length ? evs.map(evRow).join('') : '
No events
'}
`;
- $('#cbody').querySelectorAll('.cday').forEach(c => c.classList.toggle('sel', c.dataset.d === k));
- };
- showDay();
- $('#cbody').querySelectorAll('.cday').forEach(c => c.onclick = () => { calState.sel = c.dataset.d; showDay(); });
- const go = (dm) => { const t = new Date(y, m + dm, 1); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
- $('#pm').onclick = () => go(-1); $('#nm').onclick = () => go(1);
- $('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
-}
-
-const deviceTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
-const pad2 = n => String(n).padStart(2, '0');
-const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
-
-function openEvent(e) {
- if (!e) return;
- if (e.editable) return editEvent(e);
- const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
- const el = openSheet(e.title, e.cal, `When ${esc(when)}
- ${e.location ? `
Where ${esc(e.location)}
` : ''}
- This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.
Close
`);
- el.querySelector('#sx').onclick = closeSheet;
-}
-
-async function editEvent(e) {
- try {
- const q = new URLSearchParams({ cal: e.calId, tz: deviceTz() }); if (e.uid) q.set('uid', e.uid); if (e.href) q.set('href', e.href);
- const r = await jfetch('/api/cal/event?' + q);
- eventForm({ mode: 'edit', calId: e.calId, calName: e.cal, uid: e.uid, href: e.href || r.href, etag: r.etag || e.etag, fields: r.fields, series: e.series });
- } catch (err) { toast(err.message); }
-}
-
-async function newEvent(day) {
- try {
- const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
- const d = new Date(day + 'T00:00:00'), now = new Date();
- if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0);
- const end = new Date(d.getTime() + 36e5);
- eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
- } catch (err) { toast(err.message); }
-}
-
-function eventForm(o) {
- const f = { ...o.fields }, editing = o.mode === 'edit';
- const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
- if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']);
- const calPick = editing ? `Calendar ${esc(o.calName)}
`
- : `Calendar ${o.cals.map(c => `${esc(c.name)}${c.icloud ? ' (iCloud)' : ''} `).join('')} `;
- const el = openSheet(editing ? 'Edit event' : 'New event', '', ``);
- el.querySelector('.sheet').classList.add('wide');
- const draw = () => { // date-only inputs for all-day, date+time otherwise
- const t = f.allDay ? 'date' : 'datetime-local', cut = v => f.allDay ? v.slice(0, 10) : v;
- el.querySelector('#fdt').innerHTML = `Starts Ends
- ${!f.allDay && f.tz !== deviceTz() ? `Times are in ${esc(f.tz)}.
` : ''}`;
- const s = el.querySelector('#fs'), en = el.querySelector('#fe2');
- s.onchange = () => { // moving the start keeps the length
- const len = Date.parse(f.allDay ? f.end.slice(0, 10) + 'T00:00' : f.end) - Date.parse(f.allDay ? f.start.slice(0, 10) + 'T00:00' : f.start);
- f.start = s.value; if (s.value) { const ns = Date.parse(f.allDay ? s.value + 'T00:00' : s.value); if (!isNaN(ns) && len >= 0) { const d = new Date(ns + len); f.end = f.allDay ? ymd(d) : localStr(d); en.value = f.allDay ? ymd(d) : localStr(d); } }
- };
- en.onchange = () => { f.end = en.value; };
- };
- draw();
- el.querySelector('#fa').onchange = ev => {
- f.start = el.querySelector('#fs').value || f.start; f.end = el.querySelector('#fe2').value || f.end;
- f.allDay = ev.target.checked;
- if (f.allDay) { f.start = f.start.slice(0, 10); f.end = f.end.slice(0, 10); }
- else { f.start = f.start.slice(0, 10) + 'T09:00'; f.end = f.end.slice(0, 10) + 'T10:00'; }
- draw();
- };
- el.querySelector('#sx').onclick = closeSheet;
- el.querySelector('#ef').onsubmit = async ev => {
- ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#fe').textContent = '';
- const fields = { title: el.querySelector('#ft').value.trim(), location: el.querySelector('#fl').value.trim(), notes: el.querySelector('#fn').value.trim(), allDay: f.allDay,
- start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
- const cal = editing ? o.calId : el.querySelector('#fc').value;
- try {
- await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) });
- if (!editing) store.set('cal_default', cal);
- closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
- } catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
- };
- const del = el.querySelector('#fd');
- if (del) del.onclick = () => confirmSheet('Delete event?', o.series ? f.title + ' and every repeat of it' : f.title, 'Delete', async () => {
- try { await jfetch('/api/cal/event/delete', { method: 'POST', body: JSON.stringify({ cal: o.calId, uid: o.uid, href: o.href, etag: o.etag }) }); toast('Event deleted'); viewCalendar(); }
- catch (err) { toast(err.message); }
- });
-}
-
-function addCalendar() {
- const el = openSheet('Add a calendar link', '', `Name
- Private calendar link (ICS)
- Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. These are read-only: you see the events here but change them in the original app. Anyone with the link can read that calendar, so keep it private.
- Cancel Add
`);
- el.querySelector('#sx').onclick = closeSheet;
- el.querySelector('form').onsubmit = async e => {
- e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
- try { await jfetch('/api/cal/add', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#cn').value.trim() || 'Calendar', url: el.querySelector('#cu2').value.trim() }) }); closeSheet(); toast('Calendar added'); viewCalendar(); }
- catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
- };
-}
-
-async function manageCalendars() {
- const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
- const links = cals.filter(c => !c.local && !c.icloud);
- const el = openSheet('Calendars', '', `
-
NE Hub Events you add here
- ${links.map(c => `
${esc(c.name)} Link (read-only) Remove
`).join('')}
-
- Add a calendar link
- iCloud calendars
- ${ic.connected ? `${ic.calendars.map(c => `${esc(c.name)} `).join('')}
- Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.
Disconnect iCloud
`
- : `Read and add events in your iCloud calendars. Use an app-specific password (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.
-
- Connect iCloud
`}
- See NE Hub events in other apps
- Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).
-
- ${icon('copy')}Copy link Make a new link
- Close
`);
- el.querySelector('.sheet').classList.add('wide');
- el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
- el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); });
- el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } });
- const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
- const icf = el.querySelector('#icf');
- if (icf) icf.onsubmit = async e => {
- e.preventDefault(); const btn = icf.querySelector('.btn'); btn.disabled = true; btn.textContent = 'Connecting…'; el.querySelector('#ice').textContent = '';
- try { await jfetch('/api/cal/icloud/connect', { method: 'POST', body: JSON.stringify({ appleId: el.querySelector('#iu').value, password: el.querySelector('#ip').value }) }); toast('iCloud connected'); closeSheet(); viewCalendar(); }
- catch (err) { btn.disabled = false; btn.textContent = 'Connect iCloud'; el.querySelector('#ice').textContent = err.message; }
- };
- el.querySelector('#fc2').onclick = async () => { try { await navigator.clipboard.writeText(feed.url); toast('Link copied'); } catch { el.querySelector('#fu').select(); toast('Press copy on the selected link'); } };
- el.querySelector('#fr2').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { await jfetch('/api/cal/feed', { method: 'POST', body: '{}' }); manageCalendars(); });
-}
-
-// ---- Tasks: NE Hub lists plus any iCloud reminder lists the account exposes
-const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
- return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
-const taskStore = []; // tasks on screen, so a tap can open the right one
-
-async function viewTasks() {
- const v = $('#view');
- v.innerHTML = '';
- try {
- const { lists, icloud } = await jfetch('/api/tasks/lists');
- if (!location.hash.startsWith('#/tasks')) return;
- let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id;
- const list = lists.find(l => l.id === cur);
- v.innerHTML = `Tasks
-
-
${icon('check')}Add
-
-
`;
- $('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
- : icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : '';
- v.querySelectorAll('.seg a[data-l]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('task_list', a.dataset.l); viewTasks(); });
- $('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
- $('#qa').onsubmit = async e => {
- e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
- try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
- catch (err) { toast(err.message); }
- };
- loadTaskList(cur, list);
- } catch (e) { v.innerHTML = ``; }
-}
-
-async function loadTaskList(cur, list) {
- const box = $('#tl'); if (!box) return;
- try {
- const tasks = await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
- if (!$('#tl')) return;
- taskStore.length = 0;
- const ids = new Set(tasks.map(t => t.id)), byParent = {};
- tasks.forEach(t => { if (t.parent && ids.has(t.parent)) (byParent[t.parent] = byParent[t.parent] || []).push(t); });
- const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title);
- const open = tasks.filter(t => top(t) && !t.done).sort(byDue);
- const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
- const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' };
- const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
- const extra = [rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
- return `${icon('check')}
- ${esc(t.title)} ${du || t.notes || extra ? `${du ? `${esc(du.text)} ` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])} ` : ''}
`; };
- const showDone = store.get('task_show_done', false);
- box.innerHTML = (open.length ? `${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}
` : 'Nothing to do. Add a task above.
')
- + (done.length ? `${showDone ? 'Hide' : 'Show'} completed (${done.length})
${showDone ? `${done.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}
` : ''}` : '');
- const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
- box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
- const t = taskStore[+b.dataset.t]; b.disabled = true;
- try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); }
- catch (err) { b.disabled = false; toast(err.message); }
- });
- box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => taskSheet(taskStore[+b.dataset.e], cur, list));
- } catch (e) { box.innerHTML = `${esc(e.message)}
`; }
-}
-
-function taskSheet(t, cur, list) {
- const el = openSheet('Task', list.name, `
- Due date Clear
- Notes ${esc(t.notes)}
- ${list.local ? `Repeat ${[['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']].map(([v, l]) => `${l} `).join('')}
- A repeating task moves to its next date when you complete it.
` : ''}
- Completed
- ${list.local && t.id && !t.parent ? `Subtasks ${taskStore.filter(k => k.parent === t.id).map(k => `
${esc(k.title)}
`).join('')}
- Add
` : ''}
- Cancel Save
- Delete task
`);
- el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
- el.querySelector('#tf').onsubmit = async e => {
- e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
- try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); loadTaskList(cur, list); }
- catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
- };
- if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => {
- const inp = el.querySelector('#tsub'), title = inp.value.trim(); if (!title) return;
- try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title, notes: '', due: '', done: false, parent: t.id } }) });
- el.querySelector('#tsubs').insertAdjacentHTML('beforeend', `${esc(title)}
`); inp.value = ''; inp.focus(); loadTaskList(cur, list); }
- catch (err) { el.querySelector('#te').textContent = err.message; }
- };
- if (el.querySelector('#tsub')) el.querySelector('#tsub').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); el.querySelector('#tsubadd').click(); } };
- el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title + (taskStore.some(k => k.parent === t.id) ? ' and its subtasks' : ''), 'Delete', async () => {
- try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); loadTaskList(cur, list); }
- catch (err) { toast(err.message); }
- });
-}
-
-function newListSheet(lists, list) {
- const mine = lists.filter(l => l.local && l.id !== 'local:inbox');
- const el = openSheet('Lists', '', `${mine.length ? `${mine.map(l => `
${esc(l.name)} Delete
`).join('')}
` : ''}
- New list
- Close Add list
`);
- el.querySelector('#sx').onclick = closeSheet;
- el.querySelector('#lf').onsubmit = async e => { e.preventDefault(); try { const r = await jfetch('/api/tasks/lists', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#ln').value }) }); store.set('task_list', r.id); closeSheet(); viewTasks(); } catch (err) { el.querySelector('#le').textContent = err.message; } };
- el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); }));
-}
-
-// Home: a card only when something is overdue or due today
-async function renderTasksCard(el) {
- if (!el) return;
- try {
- const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = s.overdue + s.today;
- if (!n || !document.body.contains(el)) return;
- el.innerHTML = `Tasks ${icon('check')}${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention ${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''} ${icon('chev', 'chev')} `;
- } catch {}
-}
-
-// Mail: with WEBMAIL_URL set, your own webmail (which you can allow to be framed) opens inside the app and keeps running between tabs.
-// The provider shortcuts (Gmail, Outlook, ...) stay one tap away, because those sites refuse to be framed.
-function viewMail(args = []) {
- if (cfg.webmail && args[0] === 'webmail') return viewWebmail(); // the shortcuts are the default; the webmail is one tap away
- return viewMailCards();
-}
-function viewWebmail() {
- const old = $('#audiowrap iframe[data-k="mail"]');
- $('#view').innerHTML = ``;
- const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; };
- const open = fresh => {
- setSt('Opening…'); let f = $('#audiowrap iframe[data-k="mail"]'); if (f) f.remove();
- showFrame('mail', cfg.webmail + (fresh ? (cfg.webmail.includes('?') ? '&' : '?') + '_=' + Date.now() : ''), 'Mail', 'fullscreen; clipboard-write; clipboard-read');
- f = $('#audiowrap iframe[data-k="mail"]'); let done = false;
- f.addEventListener('load', () => { done = true; setSt(''); webmailHooks(f); });
- setTimeout(() => { if (!done) setSt('Still loading… if it says it refused to connect, the webmail must allow framing (see the README)'); }, 10000);
- };
- if (!old) open(false); else { showFrame('mail', '', 'Mail', ''); setSt(''); }
- $('#ar').onclick = () => open(true);
- $('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="mail"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); };
-}
-// The webmail page shares our address, so we can help it from outside: sign in for you, and send links that cannot be framed to the right place.
-const SIGNIN_HOSTS = /^(accounts\.google\.com|login\.microsoftonline\.com|login\.live.com|appleid\.apple\.com|github\.com)$/;
-async function webmailHooks(f) {
- let d; try { d = f.contentDocument; if (!d || !d.body) return; } catch { return; }
- if (!d.__hubHooks) {
- d.__hubHooks = true;
- d.addEventListener('click', e => {
- const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented) return;
- let u; try { u = new URL(a.href, d.baseURI); } catch { return; }
- if (!/^https?:$/.test(u.protocol) || u.origin === location.origin) return;
- e.preventDefault(); e.stopPropagation();
- if (SIGNIN_HOSTS.test(u.hostname)) location.href = u.href; // a provider's sign-in page refuses to be framed: use the whole window
- else window.open(u.href, '_blank', 'noopener');
- }, true);
- }
- const u = d.querySelector('input[name=username]'), p = d.querySelector('input[name=password]'), form = u && u.form;
- if (form && p && !d.querySelector('.error, .danger, .alert-danger') && !f.dataset.tried) { // the webmail's own login box: fill it in (once, so a wrong password is not retried)
- f.dataset.tried = 1;
- try { const c = await jfetch('/api/webmail/login'); if (c.user && c.pass) { u.value = c.user; p.value = c.pass; form.submit(); } } catch {}
- }
-}
-function viewMailCards() {
- const hints = mailHints(), on = mailOn(), shown = MAIL.filter(m => on.includes(m.id)), anyHint = shown.some(m => hints[m.id]);
- $('#view').innerHTML = `Mail
-
Each account opens in its own webmail tab. Nothing is stored here.
- ${cfg.webmail ? `
` : ''}
- ${shown.length ? '' : '
No mail apps chosen. Pick the ones you use in Settings .
'}
${shown.map(m => `
-
${icon('mail')} ${esc(m.name)} ${esc(hints[m.id] || (m.id === 'icloud' ? 'Opens iCloud Mail' : 'No address saved'))}
-
`).join('')}
- ${anyHint || !shown.length ? '' : '
Tip: save your address for each account in Settings so the right mailbox opens.
'}
`;
-}
-
-const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join('');
-
-function viewOffice(args = []) {
- if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
- $('#view').innerHTML = `Office Create, open and edit documents without leaving this tab. Everything is saved in Seafile.
-
- ${icon('doc')} Document Word (.docx)
- ${icon('doc')} Spreadsheet Excel (.xlsx)
- ${icon('doc')} Presentation PowerPoint (.pptx)
-
-
${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}
-
-
`;
- $('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l));
- if (!sf.token()) return;
- (async () => {
- try {
- const loc = await officeLocation(false), el = $('#olib'); if (!el) return;
- if (!loc) {
- el.textContent = 'Choose where your documents are saved in Seafile.';
- $('#odocs').innerHTML = 'Choose location
';
- $('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } });
- return;
- }
- el.innerHTML = `Saved in ${esc(locLabel(loc))} · change `;
- $('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); };
- const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`);
- const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60);
- if (!location.hash.startsWith('#/office') || !$('#odocs')) return;
- $('#odocs').innerHTML = docs.length ? `Documents ${docs.map((f, i) => {
- const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b';
- return `${icon('file')} ${esc(f.name)} ${esc(f.parent_dir || '/')} · ${fmtDate(f.mtime)} `; }).join('')}
`
- : 'No documents here yet. Create one above.
';
- $('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => {
- const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name;
- addRecent(loc.repo, path, f.name);
- if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
- });
- } catch (e) { const el = $('#olib'); if (el) el.innerHTML = `${esc(e.message)} `; }
- })();
-}
-
-const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / '));
-// The saved Office location, if it still exists (the library may have been deleted).
-async function officeLocation(ask) {
- const loc = store.get('office_loc');
- if (loc) {
- let repos;
- try { repos = await sf.req('/api2/repos/'); } catch { return loc; } // a network hiccup must not forget the choice
- const r = repos.find(x => x.id === loc.repo);
- if (r) {
- try { await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}`); return { ...loc, repoName: r.name }; }
- catch (e) { if (!/not found|404|exist/i.test(e.message)) return { ...loc, repoName: r.name }; }
- }
- store.del('office_loc'); // library or folder is really gone
- }
- if (!ask) return null;
- const l = await chooseFolder('Save documents in…'); if (l) store.set('office_loc', l); return l;
-}
-
-// Pick a library and folder in Seafile (same structure as the Files tab). Resolves null if cancelled.
-function chooseFolder(title, start, opts = {}) {
- return new Promise(resolve => {
- let repo = start ? start.repo : null, repoName = start ? start.repoName : '', dir = start ? start.dir : '/', done = false;
- const el = openSheet(title, '', '
');
- el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
- const finish = v => { done = true; closeSheet(); resolve(v); };
- const draw = async () => {
- const box = el.querySelector('#cf'); box.innerHTML = 'Loading…
';
- try {
- if (!repo) {
- const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name));
- box.innerHTML = `Choose a library
${repos.map(r => `
${icon('files')}${esc(r.name)} ${icon('chev', 'chev')} `).join('') || '
No libraries. Create one in Files.
'}
- Cancel
`;
- box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; repoName = repos.find(r => r.id === repo).name; dir = '/'; draw(); });
- } else {
- const hidden = opts.hide ? opts.hide(repo, dir) : [], blocked = opts.blocked ? opts.blocked(repo, dir) : false;
- const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).filter(x => x.type === 'dir' && !hidden.includes(x.name)).sort((a, b) => a.name.localeCompare(b.name));
- box.innerHTML = `${esc(locLabel({ repoName, dir }))}
- ${icon('back')}${dir === '/' ? 'All libraries' : 'Up'}
- ${subs.map(x => `${icon('files')}${esc(x.name)} ${icon('chev', 'chev')} `).join('')}
- Cancel ${opts.newFolder ? `${icon('newdir')} ` : ''}${esc(opts.okLabel || 'Save here')}
`;
- box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = joinP(dir, b.dataset.d); draw(); });
- box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); };
- box.querySelector('#cs').onclick = () => finish({ repo, repoName, dir });
- const cn = box.querySelector('#cn'); if (cn) cn.onclick = () => {
- cn.disabled = true; const f = document.createElement('form'); f.style.marginTop = '10px';
- f.innerHTML = '
';
- box.insertBefore(f, box.querySelector('.bar')); f.querySelector('input').focus();
- f.onsubmit = async ev => { ev.preventDefault(); const n = f.querySelector('input').value.trim(), bad = badName(n, subs); if (bad) { f.querySelector('#cme').textContent = bad; return; }
- try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(joinP(dir, n))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'mkdir' }) }); dir = joinP(dir, n); draw(); }
- catch (e) { f.querySelector('#cme').textContent = e.message; } };
- };
- }
- box.querySelector('#cx').onclick = () => finish(null);
- } catch (e) { box.innerHTML = `${esc(e.message)}
`; }
- };
- draw();
- });
-}
-
-// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
-async function createFromTemplate(repo, dir, name, x) {
- const enc = encodeURIComponent;
- if (!['docx', 'xlsx', 'pptx'].includes(x)) {
- await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) });
- return name;
- }
- const tpl = await fetch('/templates/blank.' + x); if (!tpl.ok) throw new Error('Template missing');
- const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
- const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tpl.blob(), name);
- const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd });
- if (!r.ok) throw new Error('Could not create ' + name);
- return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
-}
-
-async function newOfficeDoc(x, label) {
- if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
- let loc;
- try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
- if (!loc) return;
- const el = openSheet('New ' + label, '', `File name
- Saved in Change
- Cancel Create
`);
- const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
- showLoc();
- el.querySelector('#sx').onclick = closeSheet;
- el.querySelector('#nl').onclick = async () => {
- // Sheets don't stack, so close this one, choose, then reopen with the new location.
- const picked = await chooseFolder('Save in…', loc);
- if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label);
- };
- setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
- el.querySelector('form').onsubmit = async e => {
- e.preventDefault();
- const n = el.querySelector('#nn').value.trim(); if (!n) return;
- const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
- const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
- try {
- const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name);
- addRecent(loc.repo, path, name); closeSheet();
- if (cfg.editor) location.hash = officeHash(loc.repo, path);
- else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
- } catch (err) { btn.disabled = false; el.querySelector('#ne').textContent = err.message; }
- };
-}
-
-
-// ---- Audiobooks: embedded site. Sign-in details saved in Settings (this browser only) can be copied with one tap,
-// because a page can't type into another site's login form.
-const abCreds = () => store.get('ab_creds', { u: '', p: '' });
-// Podcast Admin (Verse by Verse): the site refuses to be framed (X-Frame-Options), so this is a launcher.
-function viewVerse() {
- const url = cfg.verse, host = new URL(url).host;
- $('#view').innerHTML = `Podcast Admin ${esc(host + new URL(url).pathname)}
-
This site doesn't allow being shown inside another app, so it opens in its own tab.
-
`;
-}
-
-// Frames that must keep running (audio) live outside #view: created once, then only shown or hidden.
-function showFrame(key, url, title, allow) {
- const wrap = $('#audiowrap');
- let f = wrap.querySelector(`iframe[data-k="${key}"]`);
- if (!f) { f = document.createElement('iframe'); f.dataset.k = key; f.src = url; f.title = title; f.allow = allow; wrap.appendChild(f); }
- wrap.querySelectorAll('iframe').forEach(x => x.classList.toggle('act', x === f));
- wrap.className = 'on'; document.body.classList.add('onaudio');
-}
-
-// Podcast: opens in a frame when it has a secure (https) address; a plain-http address cannot be framed by a secure page.
-// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is
-// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN).
-function viewPodcast() {
- const url = cfg.podcast, u = new URL(url), canFrame = u.protocol === 'https:';
- $('#view').innerHTML = `Podcast ${esc(u.host)}
-
- ${canFrame ? '
If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.
'
- : '
This is a plain http address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.
'}
`;
- if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
-}
-
-// The frame is created once and kept alive, so playback carries on while you use other tabs.
-// It loads /api/audiobooks/enter, where the server adds the site's secret sign-in link itself (the key never reaches this page).
-const ENTER = '/api/audiobooks/enter';
-function viewAudio() {
- const c = abCreds(), old = $('#audiowrap iframe[data-k="audio"]');
- $('#view').innerHTML = `Audiobooks ${old ? '' : 'Opening…'}
- ${c.u ? `
${icon('copy')} ` : ''}
-
${icon('refresh')}
-
${icon('full')}
-
${icon('ext')} `;
- const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; };
- const open = fresh => {
- setSt('Opening…');
- let f = $('#audiowrap iframe[data-k="audio"]'); if (f) f.remove();
- showFrame('audio', ENTER + (fresh ? '?t=' + Date.now() : ''), 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media');
- f = $('#audiowrap iframe[data-k="audio"]'); f.referrerPolicy = 'no-referrer';
- let done = false;
- f.addEventListener('load', () => { done = true; setSt(''); });
- setTimeout(() => { if (!done) setSt('Still loading… if it stays blank, tap the arrow to open it in its own tab'); }, 10000);
- };
- if (!old) open(false); else { showFrame('audio', '', 'Audiobooks', ''); setSt(''); }
- $('#ar').onclick = () => open(true);
- $('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="audio"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); };
- const cu = $('#cu'); if (cu) cu.onclick = async () => { try { await navigator.clipboard.writeText(c.u); toast('Username copied. Password is in Settings.'); } catch { toast('Could not copy'); } };
-}
-
-// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
-const jfetch = async (path, opts) => {
- const r = await fetch(path, opts); const j = await r.json().catch(() => ({}));
- if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
- return j;
-};
-const px = {
- secs: null,
- async get(path) {
- const r = await fetch('/plex' + path, { headers: { Accept: 'application/json' } });
- if (r.status === 401) throw Object.assign(new Error('Plex session expired'), { auth: true });
- if (!r.ok) throw new Error('Plex error ' + r.status);
- return (await r.json()).MediaContainer || {};
- },
- art: (thumb, w = 300) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
- // All music libraries on the server; the chosen one is remembered per server.
- async sections() {
- if (px.secs) return px.secs;
- const d = (await px.get('/library/sections')).Directory || [];
- px.secs = d.filter(x => x.type === 'artist').map(x => ({ key: x.key, title: x.title }));
- if (!px.secs.length) { px.secs = null; throw new Error('No music library found on this Plex server'); }
- return px.secs;
- },
- async section() {
- const secs = await px.sections(), want = store.get('plex_lib', {})[px.serverKey];
- return (secs.find(x => x.key === want) || secs[0]).key;
- },
- serverKey: '',
-};
-const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
-const mapTrack = (t, mc) => {
- const part = t.Media?.[0]?.Part?.[0];
- return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '',
- album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 };
-};
-
-async function viewMusic(args) {
- const v = $('#view');
- v.innerHTML = '';
- try {
- const st = await jfetch('/api/plex/status');
- if (!location.hash.startsWith('#/music')) return;
- if (!st.signedIn) return plexSignIn();
- if (!st.server) return plexPickServer();
- await musicRoute(args, st);
- renderNpCard();
- } catch (e) {
- if (e.auth) return plexSignIn();
- v.innerHTML = `Music ${esc(e.message)}
Change server
`;
- $('#rs').onclick = plexPickServer;
- }
-}
-
-function plexSignIn(msg = '') {
- $('#view').innerHTML = `Music Play your Plex music library here.
-
You'll sign in on plex.tv in a new tab. This app never sees your Plex password.
-
Sign in with Plex
${esc(msg)}
`;
- $('#pl').onclick = async () => {
- const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
- try {
- const pin = await jfetch('/api/plex/pin', { method: 'POST' });
- if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `Tap to open Plex sign-in `;
- for (let i = 0; i < 150; i++) {
- await new Promise(r => setTimeout(r, 2000));
- if (!location.hash.startsWith('#/music')) return;
- const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
- if (r.ok) { try { w?.close(); } catch {} return route(); }
- }
- $('#pe').textContent = 'Timed out waiting for Plex. Try again.';
- } catch (e) { $('#pe').textContent = e.message; }
- };
-}
-
-async function plexPickServer() {
- const v = $('#view');
- v.innerHTML = 'Music Finding your Plex servers…
';
- try {
- const servers = await jfetch('/api/plex/servers');
- if (!servers.length) { v.innerHTML = 'Music No Plex servers found on this account.
'; return; }
- v.innerHTML = `Music Choose a server
${servers.map(s => `
- ${icon('music')}
- ${esc(s.name)} ${s.owned ? 'Yours' : 'Shared with you'} ${icon('chev', 'chev')} `).join('')}
`;
- v.querySelectorAll('button.row').forEach(b => b.onclick = async () => {
- b.disabled = true; $('#pe').textContent = '';
- try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.secs = null; route(); }
- catch (e) {
- b.disabled = false; $('#pe').textContent = e.message;
- // Offer a hand-typed address (e.g. the server's LAN address) when automatic discovery fails.
- $('#pm').innerHTML = `Know the server's address? Enter it (for example http://192.168.1.10:32400 ).
-
- Try this address
`;
- $('#pf2').onsubmit = async ev => {
- ev.preventDefault(); $('#pe').textContent = '';
- try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id, uri: $('#pu').value.trim() }) }); px.secs = null; route(); }
- catch (e2) { $('#pe').textContent = e2.message; }
- };
- }
- });
- } catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = ``; }
-}
-
-async function musicRoute([kind = 'albums', key], st) {
- px.serverKey = st.server;
- const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
- const tabs = ['albums', 'artists', 'playlists', 'recent'];
- const seg = ``;
- const card = (href, thumb, title, sub, round) => `
- ${esc(title)} ${esc(sub)} `;
- const listing = (items, html) => {
- v.innerHTML = `Music ${esc(st.server)}${secs.length > 1 ? ` · ${esc(cur.title)} ▾ ` : ''}
${seg}
-
${html || '
Nothing here
'}
`;
- const pl2 = $('#plib');
- if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
- run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
- $('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
- };
- const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
-
- if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`);
- return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
- if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`);
- return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
- if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`);
- return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); }
- if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio');
- return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); }
-
- if (kind === 'artist') {
- const mc = await px.get(`/library/metadata/${key}/children`);
- v.innerHTML = `${esc(mc.title2 || mc.parentTitle || 'Artist')}
-
Albums ${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}
`;
- return;
- }
- if (kind === 'album' || kind === 'playlist') {
- const album = kind === 'album';
- const [mc, meta] = await Promise.all([px.get(album ? `/library/metadata/${key}/children` : `/playlists/${key}/items`),
- px.get(album ? `/library/metadata/${key}` : `/playlists/${key}`)]);
- const info = (meta.Metadata || [])[0] || {};
- const tracks = (mc.Metadata || []).map(t => { const m = mapTrack(t, mc); if (m && !album) m.pid = t.playlistItemID; return m; }).filter(Boolean);
- const mine = !album && !info.smart;
- const title = info.title || mc.title2 || '', sub = album ? (info.parentTitle || mc.title1 || '') : tracks.length + ' tracks';
- const thumb = info.thumb || info.composite || mc.thumb;
- v.innerHTML = `
-
-
${icon('play')}Play ${icon('shuffle')}Shuffle ${mine ? 'Rename Delete ' : ''}
-
${tracks.map((t, i) => `
${i + 1}
- ${esc(t.title)} ${album ? '' : `${esc(t.artist)} `}${fmtDur(t.dur)}
- ${icon('more')}
`).join('') || '
No playable tracks
'}
`;
- v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
- v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
- if (mine) {
- $('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
- $('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
- }
- $('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
- $('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
- markPlaying();
- return;
- }
- location.hash = '#/music/albums';
-}
-
-// ---- Player: one element that lives outside the views, so music keeps playing as you move around.
-const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; };
-const pl = { q: [], i: -1, a: new Audio(), shuffle: false, repeat: 'off', orig: null, alt: new Set() };
-const cur = () => pl.q[pl.i];
-function playQueue(q, i = 0, shuffleOn = false) { pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); }
-// Shuffle mixes only what is still to come, and turning it off puts the queue back in its original order.
-function setShuffle(on) {
- if (on === pl.shuffle || !pl.q.length) return;
- const t = cur();
- if (on) { pl.orig = [...pl.q]; pl.q = [...pl.q.slice(0, pl.i + 1), ...shuffled(pl.q.slice(pl.i + 1))]; }
- else if (pl.orig) { const have = new Set(pl.q.map(x => x.key)), keep = pl.orig.filter(x => have.has(x.key)), known = new Set(keep.map(x => x.key));
- pl.q = [...keep, ...pl.q.filter(x => !known.has(x.key))]; pl.i = Math.max(0, pl.q.findIndex(x => x.key === t.key)); pl.orig = null; }
- pl.shuffle = on; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); saveState(); tcKick(); updateNow(); modeUI();
-}
-function npQueueCount() { const q = $('#cnq'); if (q) q.textContent = 'Queue · ' + Math.max(0, pl.q.length - pl.i - 1) + ' up next'; }
-function setRepeat(r) { pl.repeat = r; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); updateNow(); modeUI(); }
-const srcOf = t => (pl.alt.has(t.key) ? altSrc(t) : t.src);
-// Some files a browser cannot decode directly; Plex can convert those to MP3 on the fly.
-const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSearchParams({ path: '/library/metadata/' + t.key, mediaIndex: 0, partIndex: 0, protocol: 'http', directPlay: 0, directStream: 0, audioCodec: 'mp3', maxAudioBitrate: 320 });
-// Track cache: the current track and the next two are downloaded whole (one at a time, after the current one has buffered),
-// so changing songs is instant and a slow or lost connection does not stop the music.
-const sleep = ms => new Promise(ok => setTimeout(ok, ms));
-const tc = { m: new Map(), ctl: null, busy: false };
-const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key)).map(t => t.src);
-function tcTrim() {
- const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
- for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
-}
-async function tcFetch(src, signal) {
- for (let n = 0; n < 4 && !signal.aborted; n++) {
- try {
- const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
- const b = await r.blob(); // throws if the connection drops part way
- if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b) });
- return;
- } catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
- }
-}
-async function tcPump() {
- if (tc.busy) return; tc.busy = true;
- try {
- for (;;) {
- tcTrim();
- const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
- tc.ctl = new AbortController(); tc.cur = src;
- await tcFetch(src, tc.ctl.signal);
- if (!tc.ctl.signal.aborted && !tc.m.has(src)) break; // gave up on this one; try again on the next trigger
- }
- } finally { tc.busy = false; tc.ctl = null; }
-}
-function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
-addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
-
-let loadTok = 0, loadTimer = null, stallTimer = null;
-pl.retries = 0; pl.buf = false;
-// Show the new track at once, but wait a moment before fetching it so quickly tapping Next several times only loads the last one.
-function loadCur(delay = 220) {
- const t = cur(); if (!t) return;
- clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
- const tok = ++loadTok; pl.buf = true;
- if (delay) pl.a.pause();
- setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
- if (lyr.open) { lyrShell(); lyrReload(); }
- const go = () => {
- if (tok !== loadTok) return;
- const hit = tc.m.get(t.src);
- pl.a.src = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
- pl.a.play().catch(e => { if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } });
- tcKick();
- const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600);
- };
- if (delay) loadTimer = setTimeout(go, delay); else go();
-}
-function setMeta(t) {
- if (!('mediaSession' in navigator)) return;
- navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
-}
-// If the music stalls, switch to the fully downloaded copy (if there is one) or reload the stream from where it stopped.
-function recover(now) {
- clearTimeout(stallTimer); const t = cur(), a = pl.a;
- if (!t || a.paused) return;
- stallTimer = setTimeout(() => {
- if (a.paused || (a.readyState >= 3 && !a.error)) return;
- const hit = tc.m.get(t.src), pos = a.currentTime;
- const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
- if (bad && !hit) {
- if (!pl.alt.has(t.key)) { pl.alt.add(t.key); pl.retries = 0; }
- else { toast('Cannot play "' + t.title + '". Skipping.'); return advance(true); }
- }
- const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
- if (!hit && !navigator.onLine) return; // 'online' will call us again
- if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; }
- if (!hit || a.src !== hit.url) pl.retries++;
- a.src = want;
- a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
- recover();
- }, now ? 300 : 4000);
-}
-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(); npQueueCount(); toast('Playing next'); };
-const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); npQueueCount(); toast('Added to queue'); };
-function trackSheet(t, extra) {
- 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) },
- { label: 'Add to playlist…', icon: 'plus', run: () => addToPlaylist([t]) },
- ...(extra || []),
- ]);
-}
-// 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;
- const m = store.get('plex_mode', {}); pl.shuffle = !!m.shuffle; pl.repeat = m.repeat || 'off';
- pl.q = st.q; pl.i = st.i; pl.a.src = pl.q[pl.i].src;
- pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
- renderMini(); syncUI();
-}
-function openQueue() {
- const upcoming = pl.q.map((t, i) => ({ t, i }));
- const el = openSheet('Up next', pl.q.length + ' tracks', `${upcoming.map(({ t, i }) => `
-
${i === pl.i ? '▶' : i + 1} ${esc(t.title)} ${esc(t.artist)}
- ${i === pl.i ? '' : `${icon('trash')} `}
`).join('')}
- Clear up next Save as playlist
- ${esc(sleepLabel())} timer Close
`);
- el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
- el.querySelector('#qsp').onclick = () => { const q = pl.q.slice(); closeSheet(); addToPlaylist(q, 'Queue ' + new Date().toLocaleDateString([], { month: 'short', day: 'numeric' })); };
- el.querySelector('#qsl').onclick = () => { closeSheet(); sleepSheet(); };
- el.querySelector('#qc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); openQueue(); };
- el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); 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(); });
-}
-function advance(auto) {
- if (auto && sleepT.eot) { sleepClear(); pl.a.pause(); syncUI(); toast('Sleep timer: music stopped'); return; }
- if (auto && pl.repeat === 'one') { pl.a.currentTime = 0; pl.a.play().catch(() => {}); return; }
- if (pl.i < pl.q.length - 1) pl.i++; else if (pl.repeat === 'all') pl.i = 0; else { pl.a.pause(); syncUI(); return; }
- loadCur(auto ? 0 : 220);
-}
-const next = () => advance(false);
-const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else if (pl.i > 0) { pl.i--; loadCur(); } };
-const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); return pl.a.paused ? pl.a.play() : pl.a.pause(); };
-pl.a.preload = 'auto';
-// Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account).
-let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
-const tlSend = (state, key, pos, dur) => fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {});
-function tlReport(state) {
- const t = cur(); if (!t) return;
- if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over
- tlKey = t.key; tlAt = Date.now(); tlPos = (pl.a.currentTime || 0) * 1000; tlDur = isFinite(pl.a.duration) ? pl.a.duration * 1000 : 0;
- tlSend(state, tlKey, tlPos, tlDur);
-}
-function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
-pl.a.addEventListener('ended', () => advance(true));
-let lastSave = 0, lastPos = 0;
-pl.a.addEventListener('timeupdate', () => {
- if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
- if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); }
- const t = cur(), d = pl.a.duration;
- if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex
- if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} }
-});
-addEventListener('pagehide', saveState);
-['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
-pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
-pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
-pl.a.addEventListener('canplaythrough', () => tcKick());
-['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
-pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; syncUI(); recover(); });
-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 {}
- try { ms.setActionHandler('seekbackward', d => { pl.a.currentTime = Math.max(0, pl.a.currentTime - (d.seekOffset || 10)); }); ms.setActionHandler('seekforward', d => { pl.a.currentTime += d.seekOffset || 10; }); } catch {}
-}
-function markPlaying() {
- const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
-}
-function renderMini() {
- const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
- el.hidden = false;
- el.innerHTML = `
-
${esc(t.title)} ${esc(t.artist)}
-
${icon('lyrics')} ${icon('shuffle')} ${icon('repeat')}
-
${icon('prev')} ${icon('next')}
-
${icon('x')} `;
- $('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
- $('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
- $('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen;
- // tap or drag along the thin bar to seek
- const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
- sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
- // swipe the track area: left for next, right for previous
- const mini = el.querySelector('.mini'); let x0 = null, y0 = 0;
- mini.onpointerdown = e => { if (e.target.closest('button:not(.meta), #mseek')) return; x0 = e.clientX; y0 = e.clientY; miniSwiped = false; };
- mini.onpointerup = e => { if (x0 === null) return; const dx = e.clientX - x0, dy = e.clientY - y0; x0 = null;
- if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 2) { miniSwiped = true; setTimeout(() => { miniSwiped = false; }, 300); dx < 0 ? next() : prev(); } };
- mini.onpointercancel = () => { x0 = null; };
- modeUI(); syncUI();
-}
-let miniSwiped = false;
-function stopPlayer() {
- tlStop();
- pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {}
- if ('mediaSession' in navigator) { navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; }
- tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying(); lyrClose();
-}
-// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
-function modeUI() {
- document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
- document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
-}
-function syncUI() {
- const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
- const state = pl.buf && !a.paused ? 'buf' : String(a.paused), ic = state === 'buf' ? icon('spin') : icon(a.paused ? 'play' : 'pause');
- const mt = $('#mt'); if (mt && mt.dataset.s !== state) { mt.innerHTML = ic; mt.dataset.s = state; }
- const bf = $('#mbuf'); if (bf) { let e = 0; try { for (let i = 0; i < a.buffered.length; i++) if (a.buffered.start(i) <= a.currentTime + 1) e = Math.max(e, a.buffered.end(i)); } catch {} bf.style.width = d ? Math.min(100, (e / d) * 100) + '%' : '0'; }
- const tm = $('#mtm'); if (tm) { const v = fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
- const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
- const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
- const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
- const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
- const nt = $('#npt'); if (nt && nt.dataset.s !== state) { nt.innerHTML = ic; nt.dataset.s = state; }
- 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);
- const lt = $('#lyrt'); if (lt && lt.dataset.s !== state) { lt.innerHTML = ic; lt.dataset.s = state; }
- // the Now Playing card on the Music tab
- const ct = $('#cnt'); if (ct && ct.dataset.s !== state) { ct.innerHTML = ic; ct.dataset.s = state; }
- const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
- const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
- const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
- const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
-}
-// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it).
-function renderNpCard() {
- const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music');
- const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null;
- if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
- const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
- if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); }
- el.innerHTML = `
- NOW PLAYING ${esc(t.title)} ${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}
- 0:00 0:00
- ${icon('shuffle')} ${icon('prev')}
- ${icon('next')} ${icon('repeat')}
- Lyrics Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next ${esc(sleepLabel())}
`;
- const sk = el.querySelector('#cnk');
- sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); };
- sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
- el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen;
- el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle;
- el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
- modeUI(); syncUI();
-}
-// The Now Playing sheet stays open while tracks change, so refresh what it shows.
-function updateNow() {
- const t = cur(), sk = $('#npk'); if (!t || !sk) return;
- $('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
- const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
- delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
- modeUI();
-}
-function openNow() {
- const t = cur(); if (!t) return;
- const el = openSheet('', '', `${esc(t.title)}
-
${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}
-
0:00 0:00
-
${icon('prev')}
- ${icon('next')}
-
${icon('shuffle')} Lyrics Queue · ${pl.q.length - pl.i - 1} up next ${icon('repeat')}
`);
- 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('#npl').onclick = () => { closeSheet(); lyrOpen(); };
- el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
- el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
- el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
- updateNow(); syncUI();
-}
-
-// ---- Lyrics and karaoke: lyrics from Plex when the song has them, otherwise from the Lyrica service on the server; synced ones follow the song.
-function lyrParseLrc(text) {
- const t2ms = (m, s, f) => (+m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000);
- const lines = [];
- for (const raw of String(text || '').split(/\r?\n/)) {
- const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)];
- if (!tags.length) continue;
- let body = raw.replace(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, '').trim(), words = [];
- if (/<\d+:\d{1,2}/.test(body)) {
- const parts = [...body.matchAll(/<(\d+):(\d{1,2})(?:[.:](\d{1,3}))?>([^<]*)/g)];
- words = parts.map(p => ({ t: t2ms(p[1], p[2], p[3]), text: p[4] })).filter(w => w.text.trim());
- words.forEach((w, i) => { w.e = i + 1 < words.length ? words[i + 1].t : null; });
- body = parts.map(p => p[4]).join('').replace(/\s+/g, ' ').trim();
- }
- for (const g of tags) lines.push({ t: t2ms(g[1], g[2], g[3]), text: body, ...(words.length ? { words } : {}) });
- }
- lines.sort((a, b) => a.t - b.t);
- lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; if (l.words) { const last = l.words[l.words.length - 1]; if (last.e == null) last.e = l.e; } });
- return lines;
-}
-const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
-const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
-const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
-const lyrOffset = k => { try { return +localStorage.getItem('lyr_off:' + k) || 0; } catch { return 0; } };
-const lyrSetOffset = (k, v) => { try { localStorage.setItem('lyr_off:' + k, String(v)); } catch {} };
-const lyrQuery = t => { try { return JSON.parse(localStorage.getItem('lyr_q:' + t.key)) || { artist: t.artist, title: t.title }; } catch { return { artist: t.artist, title: t.title }; } };
-const lyrCleanTitle = s => String(s || '').replace(/\s*[\(\[](feat\.?|ft\.?|with|remaster(ed)?|live|deluxe|version|mono|stereo)[^\)\]]*[\)\]]/gi, '').replace(/\s+-\s+(remaster(ed)?|live|mono|stereo|single version).*$/i, '').trim();
-
-async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc file next to it, or downloaded by Plex)
- try {
- const mc = await px.get('/library/metadata/' + t.key), st = ((mc.Metadata || [])[0]?.Media?.[0]?.Part?.[0]?.Stream || []).find(x => x.streamType === 4 && x.key);
- if (!st) return null;
- const r = await fetch('/plex' + st.key + (st.key.includes('?') ? '&' : '?') + 'format=' + (st.format || 'txt'), { headers: { Accept: 'text/plain, */*' } }); if (!r.ok) return null;
- let text = await r.text(); if (/^\s*[{<]/.test(text) && /MediaContainer|"Lyrics"/.test(text)) return null; // (not lyrics)
- const timed = lyrParseLrc(text);
- if (timed.length >= 2) return { found: true, source: 'Plex', sync: timed.some(l => l.words) ? 'word' : 'line', lines: timed.filter(l => l.text || l.words) };
- const lines = text.split(/\r?\n/).map(x => ({ text: x.trim() })); return lines.some(l => l.text) ? { found: true, source: 'Plex', sync: 'none', lines } : null;
- } catch { return null; }
-}
-async function lyrLoad(t, force) {
- if (!force && lyr.cache.has(t.key)) return lyr.cache.get(t.key);
- let res = force ? null : await lyrFromPlex(t);
- if (!res && cfg.lyrics) {
- const q = lyrQuery(t), u = '/api/lyrics?' + new URLSearchParams({ artist: q.artist, title: lyrCleanTitle(q.title), duration: (t.dur || 0) / 1000 || '' });
- try { res = await jfetch(u); if (!res.found && lyrCleanTitle(q.title) !== q.title) { /* the cleaned title is what we asked; nothing else to try */ } } catch (e) { res = { found: false, error: e.message }; }
- }
- res = res || { found: false };
- if (!res.error) lyr.cache.set(t.key, res);
- return res;
-}
-
-function lyrOpen() {
- const t = cur(); if (!t || lyr.open) return;
- lyr.open = true; document.body.classList.add('lyropen');
- const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
- document.body.appendChild(el); lyrShell(); lyrReload();
- addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
-}
-function lyrClose() {
- if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
- if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen');
-}
-const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
-function lyrShell() {
- const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
- lyr.el.className = pref.karaoke ? 'kar' : '';
- lyr.el.style.setProperty('--lf', pref.size + 'px');
- lyr.el.innerHTML = `
- ${esc(t.title)} ${esc(t.artist)}
-
Karaoke
-
A− A+
-
${icon('x')}
-
- `;
- const $l = q => lyr.el.querySelector(q);
- $l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
- $l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
- const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
- $l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
- $l('#lyrs').onclick = lyrSearchAgain;
- const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
- $l('#lyrm').onclick = () => nudge(-500); $l('#lyrp').onclick = () => nudge(500);
- syncUI();
-}
-async function lyrReload(force) {
- const t = cur(); if (!t || !lyr.el) return; lyr.key = t.key; lyr.data = null; lyr.idx = -2;
- const body = lyr.el.querySelector('#lyrbody'); body.innerHTML = 'Looking for the lyrics…
';
- const res = await lyrLoad(t, force); if (!lyr.el || lyr.key !== t.key) return;
- lyr.data = res; lyrDraw();
-}
-function lyrDraw() {
- const t = cur(), d = lyr.data, body = lyr.el && lyr.el.querySelector('#lyrbody'); if (!body || !t) return;
- const src = lyr.el.querySelector('#lyrsrc'), nudge = lyr.el.querySelector('#lyrnudge');
- nudge.hidden = !(d && d.found && d.sync !== 'none'); lyr.el.querySelector('#lyroff').textContent = (lyrOffset(t.key) / 1000).toFixed(1) + ' s';
- if (!d || !d.found) { src.textContent = ''; body.innerHTML = `${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}
`; return; }
- if (d.instrumental) { src.textContent = ''; body.innerHTML = 'This one is instrumental.
'; return; }
- src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced');
- body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `${l.words ? l.words.map((w, j) => `${esc(w.text)} `).join('') : esc(l.text)}
` : '
').join('');
- body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none') return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
- body.scrollTop = 0; lyr.idx = -2;
-}
-function lyrSearchAgain() {
- const t = cur(); if (!t) return; const q = lyrQuery(t);
- const el = openSheet('Search for the lyrics', '', `Artist Song
- Cancel Search
`);
- el.querySelector('#lyrc').onclick = closeSheet;
- el.querySelector('#lyrf').onsubmit = e => { e.preventDefault(); try { localStorage.setItem('lyr_q:' + t.key, JSON.stringify({ artist: el.querySelector('#lyra').value.trim(), title: el.querySelector('#lyrti').value.trim() })); } catch {} closeSheet(); lyr.cache.delete(t.key); lyrReload(true); };
-}
-function lyrTick() { // follows the song: which line is current, and how far into it we are
- lyr.raf = requestAnimationFrame(lyrTick);
- const d = lyr.data, t = cur(); if (!lyr.el || !d || !d.found || d.sync === 'none' || !t) return;
- const pos = pl.a.currentTime * 1000 + lyrOffset(t.key), L = d.lines;
- let lo = 0, hi = L.length - 1, idx = -1; while (lo <= hi) { const m = (lo + hi) >> 1; if (L[m].t <= pos) { idx = m; lo = m + 1; } else hi = m - 1; }
- const body = lyr.el.querySelector('#lyrbody'), nodes = body.querySelectorAll('.ll');
- if (idx !== lyr.idx) {
- lyr.idx = idx; let n = -1;
- nodes.forEach(x => { const i = +x.dataset.i; x.classList.toggle('on', i === idx); x.classList.toggle('past', i < idx); x.classList.toggle('near', Math.abs(i - idx) === 1); x.classList.toggle('upnext', i === idx + 1); if (i === idx) n = i; });
- const cur1 = body.querySelector('.ll.on'); if (cur1) cur1.scrollIntoView({ block: 'center', behavior: 'smooth' });
- }
- const on = body.querySelector('.ll.on'); if (!on) return; const l = L[idx];
- if (l.words) on.querySelectorAll('.w').forEach((w, j) => { const x = l.words[j]; if (!x) return; const p = pos >= x.e ? 1 : pos <= x.t ? 0 : (pos - x.t) / Math.max(1, x.e - x.t); w.classList.toggle('sung', p >= 1); w.style.setProperty('--p', p); });
- else { const e = l.e && l.e > l.t ? l.e : l.t + 4000, p = Math.max(0, Math.min(1, (pos - l.t) / Math.max(400, Math.min(e - l.t, 7000)))); on.style.setProperty('--p', p); } // (no word timing: the colour sweeps across the line)
-}
-
-// ---- People (admin): who can sign in, which tabs each may use, one-time invite codes
-let peopleTabs = [];
-async function loadPeople() {
- const box = $('#people'); if (!box) return;
- try {
- const r = await jfetch('/api/users'); peopleTabs = r.tabs;
- if (!$('#people')) return;
- $('#people').innerHTML = r.users.map(u => `${esc(u.name.slice(0, 1).toUpperCase())}
- ${esc(u.name)}${u.id === ME.id ? ' (you)' : ''}${u.role === 'admin' ? ' · Admin' : ''}${u.disabled ? ' · Off' : ''}
- ${u.passkeys ? u.passkeys + ' passkey' + (u.passkeys === 1 ? '' : 's') : 'No passkey yet (waiting for the invite code)'} · ${u.role === 'admin' ? 'Everything' : u.tabsChosen.map(t => (peopleTabs.find(x => x.id === t) || {}).label || t).join(', ') || 'Home and Settings only'} ${icon('chev', 'chev')} `).join('');
- $('#people').querySelectorAll('button[data-p]').forEach(b => b.onclick = () => personSheet(r.users.find(u => u.id === b.dataset.p)));
- } catch (e) { box.innerHTML = `${esc(e.message)}
`; }
-}
-
-const tabBoxes = (chosen, disabled) => `${peopleTabs.map(t => `${esc(t.label)}
- `).join('')}
`;
-const pickedTabs = el => [...el.querySelectorAll('input[data-tab]:checked')].map(i => i.dataset.tab);
-
-function codeSheet(name, code, minutes) {
- const el = openSheet('Invite code for ' + name, '', `On ${esc(name)}'s device, open ${esc(location.origin)} , tap New device? Use an invite code on the sign-in page, and enter:
- ${esc(code)}
- It works once and expires in ${minutes} minutes. They will then create their own passkey.
- ${icon('copy')}Copy code Done
`);
- el.querySelector('#sx').onclick = () => { closeSheet(); loadPeople(); };
- el.querySelector('#cc').onclick = async () => { try { await navigator.clipboard.writeText(code); toast('Code copied'); } catch { toast('Could not copy'); } };
-}
-
-async function addPersonSheet() {
- if (!peopleTabs.length) { try { peopleTabs = (await jfetch('/api/users')).tabs; } catch (e) { return toast(e.message); } }
- const el = openSheet('Add a person', '', `Name
- What can they use? ${tabBoxes(['tasks', 'calendar', 'search'])}
- They always get Home and Settings, and their own private tasks, events and settings. You can change this later.
- Cancel Create
`);
- el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
- el.querySelector('#pf').onsubmit = async e => {
- e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
- try { const r = await jfetch('/api/users', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#pn').value, tabs: pickedTabs(el) }) }); codeSheet(r.user.name, r.code, r.minutes); }
- catch (err) { btn.disabled = false; el.querySelector('#pe').textContent = err.message; }
- };
-}
-
-function personSheet(u) {
- const self = u.id === ME.id, admin = u.role === 'admin';
- const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `Name
- ${admin ? 'Admins can use everything.
' : `What can they use? ${tabBoxes(u.tabsChosen)}`}
- Close Save
-
- ${icon('copy')}Invite code for a new device Also the way back in if they lose their device
- ${icon('back')}Sign out everywhere
- ${self ? '' : `${icon('more')}${u.disabled ? 'Turn back on' : 'Turn off'} ${u.disabled ? 'Let them sign in again' : 'Signs them out and blocks sign-in; their data is kept'} `}
- ${icon('check')}${admin ? 'Remove admin' : 'Make admin'}
- ${self ? '' : `${icon('trash')}Delete person and their data `}
`);
- el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
- const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg); closeSheet(); loadPeople(); } catch (err) { toast(err.message); } };
- const put = body => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'PUT', body: JSON.stringify(body) });
- el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el) }) }), 'Saved'); };
- el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } };
- el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere');
- const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off');
- el.querySelector('#u-role').onclick = () => act(() => put({ role: admin ? 'user' : 'admin' }), admin ? 'No longer an admin' : 'Now an admin');
- const del = el.querySelector('#u-del'); if (del) del.onclick = () => confirmSheet('Delete ' + u.name + '?', 'Their tasks, events, settings and sign-ins are permanently deleted.', 'Delete', () => act(() => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE' }), 'Deleted'));
-}
-
-function viewSettings() {
- const signed = !!sf.token();
- const isAdmin = ME && ME.role === 'admin';
- const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : '';
- $('#view').innerHTML = `Settings
- ${ME ? `
Signed in as ${esc(ME.name)} ${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}
` : ''}
- ${isAdmin ? `
People
-
Add a person
-
Storage
-
A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.
` : ''}
-
Passkeys
-
Add a passkey Invite a device Sign out of NE Hub
-
Recovery codes Checking… One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper). Make codes
- ${isAdmin ? `
Activity ` : ''}
-
Connections
-
Seafile ${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}
-
Plex Checking… Sign out
-
OnlyOffice ${esc(cfg.onlyoffice)}
-
- ${signed ? '
Sign out of Seafile
' : ''}
-
Mail apps Choose the mail apps you use and add your address for each, so its inbox and compose buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.
-
-
${remindersHtml()}
Install iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.
`;
- remindersInit();
- // only show what this person may use
- const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
- hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
- hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
- if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
- if ($('#sga-s')) {
- const a = sgAlerts(), ns = $('#sga-ns'); $('#sga-s').checked = a.sound; $('#sga-n').checked = a.notify && 'Notification' in window && Notification.permission === 'granted';
- if (!('Notification' in window)) { $('#sga-n').disabled = true; ns.textContent = 'This browser does not support notifications'; }
- else if (Notification.permission === 'denied') { $('#sga-n').disabled = true; ns.textContent = 'Blocked in the browser settings for this site; allow notifications there first'; }
- $('#sga-s').onchange = e => { sgSetAlerts({ sound: e.target.checked }); if (e.target.checked) sgChime(); };
- $('#sga-n').onchange = async e => {
- if (!e.target.checked) return sgSetAlerts({ notify: false });
- let p = Notification.permission; if (p === 'default') p = await Notification.requestPermission();
- if (p === 'granted') { sgSetAlerts({ notify: true }); try { new Notification('Signal alerts are on', { body: 'You will see new messages here when this tab is in the background.', icon: '/icons/icon-192.png' }); } catch {} }
- else { e.target.checked = false; sgSetAlerts({ notify: false }); toast('Notifications are blocked. Allow them for this site in the browser settings.'); }
- };
- $('#sga-t').onclick = () => { sgUnlockAudio(); sgChime(); if (!sgAC || sgAC.state !== 'running') toast('Click anywhere on the page once, then try again (the browser allows sound only after that)'); };
- }
- document.querySelectorAll('input[data-on]').forEach(c => c.onchange = () => {
- const on = [...document.querySelectorAll('input[data-on]:checked')].map(x => x.dataset.on); store.set('mail_on', on);
- const h = document.getElementById('h-' + c.dataset.on); if (h) h.hidden = !c.checked;
- });
- document.querySelectorAll('input[data-id]').forEach(i => i.oninput = () => {
- const h = mailHints(); const v = i.value.trim(); if (v) h[i.dataset.id] = v; else delete h[i.dataset.id]; store.set('mail_hints', h);
- });
- const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
- if ($('#ab-u')) { $('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb; }
- if (signed) sf.req('/api2/account/info/').then(a => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · Signed in' + (a && (a.email || a.name) ? ' as ' + (a.email || a.name) : ''); })
- .catch(e => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · ' + (sf.token() ? 'Signed in (could not check: ' + e.message + ')' : 'Signed out'); });
- if (allowed('music')) jfetch('/api/plex/status').then(st => {
- const el = $('#plexst'); if (!el) return;
- el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server : 'Signed in · no server chosen') : 'Signed out';
- if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
- }).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
- if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
- if (isAdmin) jfetch('/api/system').then(i => {
- const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable;
- const row = (t, d, bad) => `${t} ${d}
`;
- const where = !i.source ? '' : i.source.kind === 'folder' ? ' · host folder ' + esc(i.source.name) : i.source.kind === 'named' ? ' · volume "' + esc(i.source.name) + '"' : '';
- $('#stor').innerHTML =
- row(ok ? 'Data is saved on a volume' : 'Data is NOT being kept safely', anon ? 'This is a temporary Docker volume (' + esc(i.source.name) + '…), NOT your host folder. It is deleted when the container is recreated, and your folder stays empty. Check that your stack mounts the host folder at ' + esc(i.dir) + '.'
- : i.mounted === false ? 'The data folder is not a mounted volume, so passkeys, calendars and sign-ins are lost when the container is recreated. Mount a volume or a host folder at ' + esc(i.dir) + '.'
- : !i.writable ? 'The data folder is not writable by the app.' : esc(i.dir) + ' (mounted' + where + ')', !ok)
- + row('What is stored', `${i.passkeys} passkey${i.passkeys === 1 ? '' : 's'} · ${i.events} NE Hub event${i.events === 1 ? '' : 's'} · ${i.calendarLinks} calendar link${i.calendarLinks === 1 ? '' : 's'} · ${i.settings || 0} saved setting${i.settings === 1 ? '' : 's'}${i.icloud ? ' · iCloud connected' : ''}`)
- + row('Session secret', i.secretFromEnv ? 'Set in the stack (sign-ins survive restarts)' : 'Generated and kept in the data folder', false)
- + row('Running since', new Date(i.startedAt).toLocaleString(), false);
- }).catch(() => { const e = $('#stor'); if (e) e.innerHTML = 'Could not read storage status
'; });
- const loadPks = async () => {
- try {
- const pks = await (await fetch('/api/auth/passkeys')).json();
- $('#pks').innerHTML = pks.map(k => `${esc(k.name)} Added ${new Date(k.created).toLocaleDateString()}${k.lastUsed ? ' · used ' + new Date(k.lastUsed).toLocaleDateString() : ''}
- Remove
`).join('') || 'No passkeys
';
- $('#pks').querySelectorAll('button[data-id]').forEach(b => b.onclick = () => confirmSheet('Remove passkey?', pks.length === 1 ? 'This is your only passkey. You would need the server setup code to enrol a new one.' : 'It will no longer sign in to NE Hub.', 'Remove', async () => {
- await authPost('/api/auth/passkeys', { id: b.dataset.id }, 'DELETE'); loadPks();
- }));
- } catch (e) { $('#pks').innerHTML = `${esc(e.message)}
`; }
- };
- loadPks();
- $('#addpk').onclick = () => textSheet('Add a passkey', 'Name this device', 'Create', async name => {
- try { await passkeyRegister('', name); toast('Passkey added'); loadPks(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Cancelled' : e.message); }
- });
- jfetch('/api/auth/recovery').then(r => { $('#rcn').textContent = r.remaining ? r.remaining + ' recovery code' + (r.remaining === 1 ? '' : 's') + ' left' : 'No recovery codes yet'; }).catch(() => { $('#rcn').textContent = 'Recovery codes'; });
- $('#rcgen').onclick = () => confirmSheet('Make new recovery codes?', 'Any codes you made before stop working.', 'Make codes', async () => {
- try {
- const { codes } = await jfetch('/api/auth/recovery', { method: 'POST', body: '{}' });
- const el = openSheet('Your recovery codes', 'Each works once. They are shown only now: save them before closing.', `${codes.map(c => `
${esc(c)}
`).join('')}
- Copy Done
`);
- el.querySelector('#sx').onclick = () => { closeSheet(); viewSettings(); };
- el.querySelector('#rcc').onclick = async () => { try { await navigator.clipboard.writeText(codes.join('\n')); toast('Copied'); } catch { toast('Could not copy; write them down'); } };
- } catch (e) { toast(e.message); }
- });
- if ($('#audit')) jfetch('/api/audit').then(({ events }) => {
- const el = $('#audit'); if (!el) return;
- el.innerHTML = events.length ? events.slice(0, 40).map(e => `${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''} ${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}
`).join('') : 'Nothing yet
';
- }).catch(() => { const el = $('#audit'); if (el) el.innerHTML = 'Could not load
'; });
- $('#invpk').onclick = async () => {
- try {
- const { code, minutes } = await authPost('/api/auth/invite');
- const el = openSheet('Invite a device', 'On the new device, open this app, tap "New device? Use an invite code", and enter:', `${esc(code)}
- Works once, and expires in ${minutes} minutes.
Done
`);
- el.querySelector('#sx').onclick = closeSheet;
- } catch (e) { toast(e.message); }
- };
- $('#hubout').onclick = async () => { await authPost('/api/auth/logout'); location.replace('/login'); };
- const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
-}
-
-// ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret)
-async function viewEdit([repo, ...rawParts], opts = {}) {
- const parts = rawParts.filter(Boolean);
- const path = '/' + parts.join('/'), name = parts[parts.length - 1] || '';
- const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
- document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
- const v = $('#view');
- v.innerHTML = `
- `;
- if (!sf.token()) return (location.hash = '#/files');
- const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
- try {
- const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
- body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) });
- const conf = await r.json();
- if (!r.ok) return fail(conf.error || 'Could not open document');
- if (!window.DocsAPI) await new Promise((ok, no) => {
- const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js';
- s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s);
- });
- if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
- $('#oomsg').remove();
- conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
- editor = new DocsAPI.DocEditor('oo', conf);
- } catch (e) { fail(e.message); }
-}
-
-// ---- Files
-function viewFiles([repo, ...parts]) {
- if (!sf.token()) return filesLogin();
- const dir = '/' + parts.join('/');
- $('#view').innerHTML = ``;
- libsP = loadLibs(repo);
- if (repo) loadDir(repo, dir);
-}
-
-function filesLogin(msg = '', needOtp = false) {
- $('#view').innerHTML = ``;
- $('#lf').onsubmit = async e => {
- e.preventDefault();
- const f = new FormData(e.target);
- try { await sf.login(f.get('u'), f.get('p'), f.get('o')); route(); }
- catch (err) { filesLogin(err.message, !!err.otp || needOtp); }
- };
-}
-
-async function loadLibs(active) {
- try {
- const repos = (await sf.req('/api2/repos/')).sort((a, b) => a.name.localeCompare(b.name));
- libNames = Object.fromEntries(repos.map(r => [r.id, r.name])); libPerm = Object.fromEntries(repos.map(r => [r.id, r.permission]));
- $('#libs').innerHTML = `${icon('newdir')}New library
Libraries `;
- $('#nl').onclick = () => textSheet('New library', 'Library name', 'Create', async name => {
- try { const r = await sf.req('/api2/repos/', { method: 'POST', body: new URLSearchParams({ name }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); location.hash = '#/files/' + encodeURIComponent(r.repo_id); }
- catch (e) { toast(e.message); }
- });
- if (!active) return;
- const r = repos.find(x => x.id === active);
- if (r && r.encrypted) $('#fmain').innerHTML = 'This library is encrypted. Unlock it in the Seafile web UI first.
';
- } catch (e) { $('#libs').innerHTML = `${esc(e.message)}
`; if (!sf.token()) setTimeout(route, 800); }
-}
-
-// ---- Files: browse, select, move/copy/delete, rename, upload (drag in from the computer, drag a file out to download)
-let fv = null, libsP = Promise.resolve(), libNames = {}, libPerm = {};
-const SORTS = [['name', 'Name'], ['mtime', 'Date modified'], ['size', 'Size'], ['type', 'Kind']];
-const nameCmp = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
-function sortItems(items, s) {
- const by = { name: nameCmp, mtime: (a, b) => a.mtime - b.mtime, size: (a, b) => (a.size || 0) - (b.size || 0), type: (a, b) => ext(a.name).localeCompare(ext(b.name)) }[s.k] || nameCmp;
- return [...items].sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) // folders stay on top
- : (a.type === 'dir' && s.k !== 'name' && s.k !== 'mtime') ? nameCmp(a, b) : s.d * by(a, b) || nameCmp(a, b));
-}
-const badName = (n, items, self) => !n || n === '.' || n === '..' ? 'Enter a name' : /[\/\\]/.test(n) ? 'A name cannot contain / or \\' : n.length > 255 ? 'That name is too long'
- : items.some(x => x.name.toLowerCase() === n.toLowerCase() && x.name !== self) ? 'Something with that name is already here' : '';
-const sfJson = (path, body) => sf.req(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
-const hereItems = (repo, dir) => (fv && fv.repo === repo && fv.dir === dir ? fv.items : []);
-// After something changed in a folder, redraw it if it is the one on screen.
-function afterChange(repo, dir) { if (fv && document.contains(fv.root) && fv.repo === repo && fv.dir === dir) { fv.sel.clear(); fv.selecting = false; fv.reload(); } }
-
-async function removeItems(repo, dir, items) {
- const enc = encodeURIComponent;
- try { await sfJson('/api/v2.1/repos/batch-delete-item/', { repo_id: repo, parent_dir: dir, dirents: items.map(x => x.name) }); }
- catch (e) { // older servers have no batch call: one at a time
- const failed = [];
- for (const it of items) { try { await sf.req(`/api2/repos/${enc(repo)}/${it.type === 'dir' ? 'dir' : 'file'}/?p=${enc(joinP(dir, it.name))}`, { method: 'DELETE' }); } catch { failed.push(it.name); } }
- if (failed.length) throw new Error('Could not delete: ' + failed.join(', '));
- }
-}
-function deleteItems(repo, dir, items) {
- const one = items.length === 1;
- confirmSheet(one ? (items[0].type === 'dir' ? 'Delete folder?' : 'Delete file?') : `Delete ${items.length} items?`,
- one ? items[0].name + (items[0].type === 'dir' ? ' and everything in it' : '') : 'Folders are deleted with everything inside them', 'Delete', async () => {
- try { await removeItems(repo, dir, items); toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); }
- afterChange(repo, dir);
- });
-}
-async function transferItems(mode, repo, dir, names, dst) {
- await sfJson(`/api/v2.1/repos/sync-batch-${mode}-item/`, { src_repo_id: repo, src_parent_dir: dir, dst_repo_id: dst.repo, dst_parent_dir: dst.dir, src_dirents: names });
-}
-function moveSheet(mode, repo, dir, items) {
- const names = items.map(x => x.name), folders = items.filter(x => x.type === 'dir').map(x => joinP(dir, x.name)), verb = mode === 'move' ? 'Move' : 'Copy';
- chooseFolder(verb + (items.length === 1 ? ' “' + names[0] + '”' : ' ' + items.length + ' items') + ' to…', { repo, repoName: libNames[repo] || '', dir },
- { okLabel: verb + ' here', hide: (r, d) => (r === repo && d === dir ? names : []), newFolder: true,
- blocked: (r, d) => r === repo && ((mode === 'move' && d === dir) || folders.some(f => d === f || d.startsWith(f + '/'))) }).then(async dst => {
- if (!dst) return;
- try { await transferItems(mode, repo, dir, names, dst); toast((mode === 'move' ? 'Moved' : 'Copied') + (items.length > 1 ? ' ' + items.length + ' items' : '')); }
- catch (e) { toast(e.message); }
- afterChange(repo, dir);
- });
-}
-function renameItem(repo, path, name, dir, isDir) {
- textSheet('Rename', 'New name', 'Rename', async n => {
- if (n === name) return;
- 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'); }
- catch (e) { toast(e.message); }
- afterChange(repo, dir);
- }, name, n => badName(n, hereItems(repo, dir), name));
-}
-async function downloadItem(repo, path) {
- try { window.open(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), '_blank', 'noopener'); } catch (e) { toast(e.message); }
-}
-
-// ---- Uploads: a queue that survives leaving the folder, with progress, retries and a way to cancel
-const uq = { items: [], running: 0 };
-const upActive = () => uq.items.some(x => x.state === 'wait' || x.state === 'up');
-function enqueueUploads(repo, dir, entries) {
- if (!entries.length) return;
- for (const e of entries) uq.items.push({ repo, dir, file: e.file, rel: e.rel || '', pct: 0, state: 'wait', tries: 0 });
- drawUpBar(); pumpUploads();
-}
-function pumpUploads() {
- while (uq.running < 2) {
- const it = uq.items.find(x => x.state === 'wait'); if (!it) break;
- uq.running++; it.state = 'up';
- sendUpload(it).then(() => { it.state = 'done'; }, err => { it.state = it.cancelled ? 'cancel' : 'fail'; it.err = err.message; })
- .finally(() => { uq.running--; drawUpBar(); pumpUploads(); if (!upActive()) uploadsFinished(); });
- }
-}
-async function sendUpload(it) {
- const enc = encodeURIComponent;
- for (;;) {
- it.tries++; it.pct = 0;
- try {
- const link = await sf.req(`/api2/repos/${enc(it.repo)}/upload-link/?p=${enc(it.dir)}`); // a fresh link each time, so an expired one never blocks a retry
- await new Promise((ok, no) => {
- const x = it.xhr = new XMLHttpRequest(), fd = new FormData();
- fd.append('parent_dir', it.dir); fd.append('replace', '0'); if (it.rel) fd.append('relative_path', it.rel); fd.append('file', it.file, it.file.name);
- x.open('POST', '/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1');
- x.upload.onprogress = e => { if (e.lengthComputable) { it.pct = e.loaded / e.total; drawUpBar(); } };
- x.onload = () => x.status >= 200 && x.status < 300 ? ok() : no(Object.assign(new Error(x.status === 413 ? 'Too large for the server' : x.status === 403 ? 'Not allowed (or out of space)' : 'Upload failed (' + x.status + ')'), { fatal: x.status >= 400 && x.status < 500 }));
- x.onerror = () => no(new Error('Connection lost'));
- x.onabort = () => no(Object.assign(new Error('Cancelled'), { fatal: true }));
- x.send(fd);
- });
- return;
- } catch (e) {
- if (it.cancelled) throw e;
- if (e.fatal || !sf.token() || it.tries >= 3) throw new Error(it.file.name + ': ' + e.message);
- await sleep(1500 * it.tries);
- }
- }
-}
-function uploadsFinished() {
- const done = uq.items.filter(x => x.state === 'done').length, bad = uq.items.filter(x => x.state === 'fail').length;
- if (done) toast(done + (done === 1 ? ' file' : ' files') + ' uploaded' + (bad ? ', ' + bad + ' failed' : ''));
- uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar();
- if (fv && document.contains(fv.root)) fv.reload(true);
-}
-function drawUpBar() {
- let b = $('#upbar');
- const live = uq.items.filter(x => x.state === 'wait' || x.state === 'up'), bad = uq.items.filter(x => x.state === 'fail');
- if (!live.length && !bad.length) { if (b) b.remove(); return; }
- if (!b) { b = document.createElement('div'); b.id = 'upbar'; document.body.appendChild(b); }
- if (live.length) {
- const total = uq.items.length, doneN = uq.items.filter(x => x.state === 'done').length, cur = live.find(x => x.state === 'up') || live[0];
- const pct = Math.round(((doneN + live.reduce((a, x) => a + (x.state === 'up' ? x.pct : 0), 0)) / total) * 100);
- b.innerHTML = `Uploading ${esc(live.length === 1 ? cur.file.name : live.length + ' files')} ${pct}%
Cancel `;
- $('#upx').onclick = () => { uq.items.forEach(x => { if (x.state === 'wait') x.state = 'cancel'; if (x.state === 'up') { x.cancelled = true; x.xhr && x.xhr.abort(); } }); uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar(); };
- } else {
- b.innerHTML = `${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed ${esc(bad[0].err || '')}
Retry Dismiss `;
- $('#upr').onclick = () => { bad.forEach(x => { x.state = 'wait'; x.tries = 0; x.cancelled = false; }); drawUpBar(); pumpUploads(); };
- $('#upd').onclick = () => { uq.items = uq.items.filter(x => x.state !== 'fail'); drawUpBar(); };
- }
-}
-addEventListener('beforeunload', e => { if (upActive()) { e.preventDefault(); e.returnValue = ''; } });
-
-// Everything dropped from the computer, including whole folders (their structure is kept).
-async function droppedFiles(dt) {
- const ents = [...(dt.items || [])].map(i => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null)).filter(Boolean), out = [];
- if (!ents.length) return [...dt.files].map(file => ({ file, rel: '' }));
- const walk = async (en, rel) => {
- if (en.isFile) await new Promise(ok => en.file(f => { out.push({ file: f, rel }); ok(); }, ok));
- else if (en.isDirectory) {
- const here = rel ? rel + '/' + en.name : en.name, rd = en.createReader(); let batch;
- do { batch = await new Promise(ok => rd.readEntries(ok, () => ok([]))); for (const c of batch) await walk(c, here); } while (batch.length);
- }
- };
- for (const en of ents) await walk(en, '');
- return out;
-}
-const dlUrls = new Map(); // direct download addresses, fetched ahead so a file can be dragged out to the desktop
-async function prefetchDl(repo, path) {
- const k = repo + path, c = dlUrls.get(k); if (c && Date.now() - c.t < 45 * 60e3) return;
- dlUrls.set(k, { t: Date.now() });
- try { const u = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`); dlUrls.set(k, { t: Date.now(), url: new URL(u, cfg.seafile).href }); } catch { dlUrls.delete(k); }
-}
-
-async function loadDir(repo, dir) {
- const main = $('#fmain'); if (!main) return;
- main.innerHTML = 'Loading…
';
- await libsP.catch(() => {});
- if (!document.contains(main)) return;
- const enc = encodeURIComponent, ro = libPerm[repo] === 'r';
- const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join('');
- const segs = dir.split('/').filter(Boolean);
- const crumbs = `${esc(libNames[repo] || 'Library')} ` + segs.map((s, i) => `/ ${esc(s)} `).join('');
- const v = fv = { root: main, repo, dir, items: [], sel: new Set(), selecting: false, last: null, filter: '', tok: null };
- const sortCfg = () => store.get('files_sort', { k: 'name', d: 1 });
- const byName = n => v.items.find(x => x.name === n);
- const selItems = () => v.items.filter(x => v.sel.has(x.name));
- const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); };
-
- main.innerHTML = `
${crumbs}
-
`;
- const go = p => { location.hash = hash(p); };
-
- const drawTop = () => {
- const n = v.sel.size, one = n === 1 ? selItems()[0] : null;
- $('#ftop').innerHTML = v.selecting
- ? `${n ? n + ' selected' : 'Select items'}
- ${n && n === visible().length ? 'None' : 'All'} Done
-
${ro ? '' : `${icon('files')}Move `}
- ${icon('copy')}Copy
- ${ro ? '' : `${icon('trash')}Delete `}
- ${icon('more')}
`
- : ``;
- };
- const drawItems = () => {
- const list = visible(), box = $('#items'), ff = $('#ff');
- ff.hidden = v.items.length < 8;
- const row = it => { const d = it.type === 'dir', on = v.sel.has(it.name);
- return `
- ${v.selecting ? `${on ? icon('check') : ''} ` : ''}
- ${icon(d ? 'files' : 'file')}
- ${esc(it.name)} ${d ? fmtDate(it.mtime) : fmtSize(it.size) + ' · ' + fmtDate(it.mtime)}
- ${v.selecting ? '' : `${icon('more')} `}
`; };
- box.innerHTML = list.length ? `${list.map(row).join('')}
`
- : `${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}
`;
- const dirs = v.items.filter(x => x.type === 'dir').length, files = v.items.length - dirs, size = v.items.reduce((a, x) => a + (x.size || 0), 0);
- $('#fsum').textContent = v.items.length ? [dirs && dirs + (dirs === 1 ? ' folder' : ' folders'), files && files + (files === 1 ? ' file' : ' files'), size && fmtSize(size)].filter(Boolean).join(' · ') : '';
- };
- const redraw = () => { drawTop(); drawItems(); };
-
- const toggle = (it, range) => {
- const vis = visible().map(x => x.name);
- if (range && v.last && vis.includes(v.last) && vis.includes(it.name)) { const a = vis.indexOf(v.last), b = vis.indexOf(it.name); vis.slice(Math.min(a, b), Math.max(a, b) + 1).forEach(n => v.sel.add(n)); }
- else { v.sel.has(it.name) ? v.sel.delete(it.name) : v.sel.add(it.name); v.last = it.name; }
- v.selecting = true; redraw();
- };
- const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
- const openIt = it => it.type === 'dir' ? go(joinP(dir, it.name)) : openFile(repo, joinP(dir, it.name), it.name, dir);
- const oneActions = it => {
- const p = joinP(dir, it.name), d = it.type === 'dir', a = [{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, p, d) }];
- if (!ro) a.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, p, it.name, dir, d) });
- if (!d) a.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, p) });
- if (!d) a.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, p, it.name, ro) });
- return a;
- };
-
- 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: FORM }); v.reload(true); }
- catch (e) { toast(e.message); }
- };
- const actions = {
- all: () => { const vis = visible(); if (v.sel.size === vis.length) v.sel.clear(); else vis.forEach(x => v.sel.add(x.name)); redraw(); },
- move: () => v.sel.size && moveSheet('move', repo, dir, selItems()),
- copy: () => v.sel.size && moveSheet('copy', repo, dir, selItems()),
- del: () => v.sel.size && deleteItems(repo, dir, selItems()),
- more: () => { const it = selItems()[0]; if (it) actionSheet(it.name, '', oneActions(it)); },
- done: () => { v.selecting = false; v.sel.clear(); redraw(); },
- sel: () => { v.selecting = true; redraw(); },
- refresh: () => v.reload(),
- sort: () => { const c = sortCfg(); actionSheet('Sort by', 'Folders stay on top', SORTS.map(([k, l]) => ({ label: l + (c.k === k ? (c.d > 0 ? ' ↑' : ' ↓') : ''), icon: 'sort',
- run: () => { store.set('files_sort', { k, d: c.k === k ? -c.d : 1 }); drawItems(); } }))); },
- up: () => actionSheet('Upload', '', [{ label: 'Files', icon: 'up', run: () => $('#upf').click() }, { label: 'A folder', icon: 'newdir', run: () => $('#upd2').click() }]),
- new: () => actionSheet('Create new', '', [
- { label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir'), '', n => badName(n, v.items)) },
- ...[['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); v.reload(true); if (x !== 'txt') openFile(repo, joinP(dir, name), name, dir); }
- catch (e) { toast(e.message); }
- }, '', n => badName(n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x, v.items)) })),
- ]),
- };
- v.actions = actions; v.selItems = selItems; v.openIt = openIt; v.visible = visible; v.one = oneActions;
- $('#ftop').onclick = e => { const b = e.target.closest('[data-a]'); if (b && !b.disabled && actions[b.dataset.a]) actions[b.dataset.a](); };
- $('#ftop').onchange = e => {
- const pick = e.target.id === 'upf' || e.target.id === 'upd2' ? [...e.target.files] : null; if (!pick) return;
- enqueueUploads(repo, dir, pick.map(f => ({ file: f, rel: f.webkitRelativePath ? f.webkitRelativePath.split('/').slice(0, -1).join('/') : '' }))); e.target.value = '';
- };
- $('#ff').oninput = e => { v.filter = e.target.value; drawItems(); };
- main.querySelector('.crumbs').onclick = e => { const b = e.target.closest('[data-go]'); if (b) go(b.dataset.go); };
-
- // tap / click / ctrl-click / shift-click, right-click, and long-press on a touch screen
- const box = $('#items'); let suppress = false, lastTouch = 0, lp = null;
- box.onclick = e => {
- if (suppress) { suppress = false; return; }
- const r = e.target.closest('.fr'); if (!r) return; const it = byName(r.dataset.n); if (!it) return;
- if (e.target.closest('[data-more]')) return itemSheet(it);
- if (v.selecting || e.metaKey || e.ctrlKey || e.shiftKey) return toggle(it, e.shiftKey);
- openIt(it);
- };
- box.oncontextmenu = e => {
- const r = e.target.closest('.fr'); if (!r) return; e.preventDefault(); if (Date.now() - lastTouch < 1200) return;
- const it = byName(r.dataset.n); if (!it) return;
- if (v.selecting && v.sel.size > 1 && v.sel.has(it.name)) return actionSheet(v.sel.size + ' selected', '', [ ...(ro ? [] : [{ label: 'Move', icon: 'files', run: actions.move }]), { label: 'Copy', icon: 'copy', run: actions.copy }, ...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: actions.del }]) ]);
- itemSheet(it);
- };
- box.onpointerdown = e => {
- if (e.pointerType !== 'touch') return; lastTouch = Date.now();
- const r = e.target.closest('.fr'); if (!r || v.selecting) return; const x0 = e.clientX, y0 = e.clientY;
- const stop = () => { clearTimeout(lp); box.removeEventListener('pointermove', mv); };
- const mv = m => { if (Math.abs(m.clientX - x0) + Math.abs(m.clientY - y0) > 10) stop(); };
- box.addEventListener('pointermove', mv); box.addEventListener('pointerup', stop, { once: true }); box.addEventListener('pointercancel', stop, { once: true });
- lp = setTimeout(() => { stop(); const it = byName(r.dataset.n); if (!it) return; suppress = true; v.sel.add(it.name); v.last = it.name; v.selecting = true; redraw(); try { navigator.vibrate && navigator.vibrate(12); } catch {} }, 450);
- };
- box.onmouseover = e => { const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (it && it.type !== 'dir' && !ro) prefetchDl(repo, joinP(dir, it.name)); };
-
- // Drag: out to the desktop (one file, Chrome/Edge), onto a folder or breadcrumb to move (Alt/Option copies), in from the computer to upload.
- box.ondragstart = e => {
- const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (!it) return;
- const names = v.sel.has(it.name) && v.sel.size ? [...v.sel] : [it.name];
- e.dataTransfer.setData('application/x-hub-items', JSON.stringify({ repo, dir, names })); e.dataTransfer.effectAllowed = 'copyMove';
- const c = it.type !== 'dir' && names.length === 1 && dlUrls.get(repo + joinP(dir, it.name));
- if (c && c.url) e.dataTransfer.setData('DownloadURL', `application/octet-stream:${it.name.replace(/:/g, '-')}:${c.url}`);
- else if (it.type !== 'dir') prefetchDl(repo, joinP(dir, it.name));
- };
- const target = e => { const t = e.target.closest('.fr, [data-go]'); if (!t) return null;
- if (t.dataset.go) return { el: t, path: t.dataset.go }; const it = byName(t.dataset.n); return it && it.type === 'dir' ? { el: t, path: joinP(dir, it.name) } : null; };
- const clearDrop = () => main.querySelectorAll('.dropt').forEach(x => x.classList.remove('dropt')), isFiles = e => [...(e.dataTransfer.types || [])].includes('Files');
- main.ondragover = e => {
- const mine = [...(e.dataTransfer.types || [])].includes('application/x-hub-items'); if (!mine && !isFiles(e) || ro) return;
- e.preventDefault(); clearDrop(); const t = target(e); if (t) t.el.classList.add('dropt'); else if (isFiles(e)) main.classList.add('dropt'); e.dataTransfer.dropEffect = mine && e.altKey ? 'copy' : mine ? 'move' : 'copy';
- };
- main.ondragleave = e => { if (!main.contains(e.relatedTarget)) { clearDrop(); main.classList.remove('dropt'); } };
- main.ondrop = async e => {
- if (ro) return; e.preventDefault(); clearDrop(); main.classList.remove('dropt');
- const t = target(e), mine = e.dataTransfer.getData('application/x-hub-items');
- if (mine) {
- const { names } = JSON.parse(mine); if (!t || t.path === dir || names.some(n => joinP(dir, n) === t.path)) return;
- try { await transferItems(e.altKey ? 'copy' : 'move', repo, dir, names, { repo, dir: t.path }); toast((e.altKey ? 'Copied' : 'Moved') + (names.length > 1 ? ' ' + names.length + ' items' : '')); } catch (err) { toast(err.message); }
- return afterChange(repo, dir);
- }
- if (isFiles(e)) { const files = await droppedFiles(e.dataTransfer); enqueueUploads(repo, t ? t.path : dir, files); }
- };
-
- v.reload = async quiet => {
- const tok = v.tok = Symbol(); if (!quiet) $('#items').innerHTML = 'Loading…
';
- try {
- const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
- if (v.tok !== tok || !document.contains(main)) return;
- v.items = items; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n);
- redraw();
- } catch (e) {
- if (v.tok !== tok || !document.contains(main)) return;
- if (!sf.token()) return route();
- $('#items').innerHTML = `${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}
Try again
`;
- $('#fretry').onclick = () => v.reload();
- }
- };
- drawTop(); v.reload();
-}
-
-const joinP = (d, n) => (d === '/' ? '' : d) + '/' + n;
-// Keep the folder fresh when you come back to the tab, and add Finder-style keys.
-document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && fv && document.contains(fv.root) && !upActive() && !document.getElementById('sheet')) fv.reload(true); });
-addEventListener('keydown', e => {
- if (!fv || !document.contains(fv.root) || document.getElementById('sheet') || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) return;
- const mod = e.metaKey || e.ctrlKey, n = fv.sel.size, one = n === 1 ? fv.selItems()[0] : null, a = fv.actions;
- if (e.key === 'Escape' && (n || fv.selecting)) { a.done(); }
- else if (mod && e.key.toLowerCase() === 'a') { e.preventDefault(); fv.selecting = true; fv.visible().forEach(x => fv.sel.add(x.name)); a.sel(); }
- else if ((e.key === 'Delete' || e.key === 'Backspace') && n) { e.preventDefault(); a.del(); }
- else if (e.key === 'F2' && one) { e.preventDefault(); const p = joinP(fv.dir, one.name); renameItem(fv.repo, p, one.name, fv.dir, one.type === 'dir'); }
- else if (e.key === 'Enter' && one) fv.openIt(one);
-});
-
-// ---- Bottom sheet (dialog on tablet/desktop). Body content is built by the caller from trusted/escaped HTML.
-function openSheet(title, sub, bodyHtml) {
- closeSheet();
- const el = document.createElement('div');
- el.id = 'sheet';
- el.innerHTML = `
-
${esc(title)} ${sub ? `
${esc(sub)}
` : ''}${bodyHtml}
`;
- document.body.appendChild(el);
- el.querySelector('.scrim').onclick = closeSheet;
- requestAnimationFrame(() => el.classList.add('on'));
- return el;
-}
-function closeSheet() { document.getElementById('sheet')?.remove(); }
-addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });
-
-function actionSheet(title, sub, actions) {
- const el = openSheet(title, sub, `${actions.map((a, i) => `
- ${icon(a.icon)}${esc(a.label)} `).join('')}
- Cancel
`);
- el.querySelector('#sx').onclick = closeSheet;
- el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); actions[b.dataset.i].run(); });
-}
-
-function textSheet(title, label, okLabel, onOk, initial = '', validate) {
- const el = openSheet(title, '', `${esc(label)}
-
- Cancel
- ${esc(okLabel)}
`);
- el.querySelector('#sx').onclick = closeSheet;
- el.querySelector('form').onsubmit = e => {
- e.preventDefault(); const v = el.querySelector('#tv').value.trim(); if (!v) return;
- const bad = validate && validate(v); if (bad) { el.querySelector('#te').textContent = bad; return; }
- closeSheet(); onOk(v);
- };
- setTimeout(() => { const t = el.querySelector('#tv'); t.focus(); const d = initial.lastIndexOf('.'); t.setSelectionRange(0, d > 0 ? d : initial.length); }, 50);
-}
-
-function confirmSheet(title, sub, okLabel, onOk) {
- const el = openSheet(title, sub, `Cancel
- ${esc(okLabel)}
`);
- el.querySelector('#sx').onclick = closeSheet;
- el.querySelector('#so').onclick = () => { closeSheet(); onOk(); };
-}
-
-// ---- File actions
-const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac' };
-const previewKind = n => Object.keys(PREVIEW).find(k => PREVIEW[k].split(' ').includes(ext(n)));
-const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
-const encP = p => p.split('/').map(encodeURIComponent).join('/');
-const fileUrl = async (repo, path) => { const u = new URL(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), cfg.seafile); return '/sf' + u.pathname; };
-
-// Recently opened files, shown on Home (kept in this browser only).
-function addRecent(repo, path, name) {
- const r = store.get('recents', []).filter(x => !(x.repo === repo && x.path === path));
- r.unshift({ repo, path, name, t: Date.now() }); store.set('recents', r.slice(0, 12));
-}
-
-// Default tap on a file: edit documents, preview media, otherwise show the actions.
-function openFile(repo, path, name, dir) {
- if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); }
- else if (previewKind(name)) previewFile(repo, path, name);
- else fileSheet(repo, path, name, dir);
-}
-
-async function previewFile(repo, path, name) {
- addRecent(repo, path, name);
- try {
- const url = (await fileUrl(repo, path)) + '?hubinline=1', kind = previewKind(name);
- const media = kind === 'img' ? ` `
- : kind === 'pdf' ? ``
- : kind === 'video' ? ` `
- : ` `;
- const el = openSheet(name, '', `${media}`);
- el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
- } catch (e) { toast(e.message); }
-}
-
-async function shareLink(repo, path, isDir) {
- const p = isDir ? path.replace(/\/?$/, '/') : path;
- try {
- let link;
- try { link = (await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORM, body: new URLSearchParams({ repo_id: repo, path: p }) })).link; }
- catch { link = (await sf.req(`/api/v2.1/share-links/?repo_id=${encodeURIComponent(repo)}&path=${encodeURIComponent(p)}`))[0]?.link; }
- if (!link) throw new Error('Could not create a share link');
- const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || '', `
- ${icon('copy')}Copy ${navigator.share ? 'Share… ' : ''}
`);
- el.querySelector('#cp1').onclick = async () => { try { await navigator.clipboard.writeText(link); toast('Link copied'); closeSheet(); } catch { el.querySelector('#sl').select(); toast('Press copy on the selected link'); } };
- const sh = el.querySelector('#sh1'); if (sh) sh.onclick = () => navigator.share({ url: link }).catch(() => {});
- } catch (e) { toast(e.message); }
-}
-
-function fileSheet(repo, path, name, dir) {
- const enc = encodeURIComponent, acts = [], ro = libPerm[repo] === 'r', it = { name, type: 'file' };
- if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) });
- if (cfg.editor && EDIT_EXT.has(ext(name)) && !ro) 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: 'Download', icon: 'dl', run: () => downloadItem(repo, path) });
- acts.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, path, name, ro) });
- if (!ro) acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
- if (!ro) acts.push({ label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) });
- acts.push({ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) });
- if (!ro) acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) });
- actionSheet(name, '', acts);
-}
-
-function dirSheet(repo, path, name, dir) {
- const ro = libPerm[repo] === 'r', it = { name, type: 'dir' };
- actionSheet(name, 'Folder', [
- { label: 'Open', icon: 'files', run: () => { location.hash = '#/files/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); } },
- { label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) },
- ...(ro ? [] : [{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) }, { label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) }]),
- { label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) },
- ...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) }]),
- ]);
-}
-
-// ---- Signal (through signal-cli-rest-api on the server; this hub is one more "linked device" of your Signal account)
-const sgq = q => (sg.root || document).querySelector(q);
-const sgApi = (p, o) => jfetch('/api/signal' + p, o);
-const sgPost = (p, body) => sgApi(p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
-const sgTime = ts => { const d = new Date(ts), n = new Date(); return d.toDateString() === n.toDateString() ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : (n - d < 6 * 864e5 ? d.toLocaleDateString([], { weekday: 'short' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' })); };
-const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
-const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
-const sgOnView = () => location.hash.startsWith('#/signal');
-const sgMounted = () => !!sg.root && document.contains(sg.root);
-async function sgStart() { // keep the unread count and live updates running whatever screen is open
- if (!allowed('signal') || sg.es) return;
- try { const st = await sgApi('/status'); sg.st = st; sg.unread = st.unread || 0; renderNav(activeTab); } catch {}
- sg.es = new EventSource('/api/signal/events');
- sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); };
-}
-function sgStop() { if (sg.es) { sg.es.close(); sg.es = null; } }
-// ---- Getting your attention: a chime, a desktop notification when the app is in the background, the count in the tab title and on the app icon.
-const sgAlerts = () => { try { return { sound: true, notify: false, ...JSON.parse(localStorage.getItem('sg_alerts')) }; } catch { return { sound: true, notify: false }; } };
-const sgSetAlerts = p => { try { localStorage.setItem('sg_alerts', JSON.stringify({ ...sgAlerts(), ...p })); } catch {} };
-let sgAC = null;
-function sgUnlockAudio() { try { sgAC = sgAC || new (window.AudioContext || window.webkitAudioContext)(); if (sgAC.state === 'suspended') sgAC.resume(); } catch {} } // a browser only allows sound after you have used the page once
-['pointerdown', 'keydown', 'touchstart'].forEach(e => addEventListener(e, sgUnlockAudio, { passive: true }));
-function sgChime() {
- sgUnlockAudio(); if (!sgAC || sgAC.state !== 'running') return;
- const t = sgAC.currentTime;
- [[880, 0], [1318, 0.14]].forEach(([f, d]) => { const o = sgAC.createOscillator(), g = sgAC.createGain(); o.type = 'sine'; o.frequency.value = f;
- g.gain.setValueAtTime(0.0001, t + d); g.gain.exponentialRampToValueAtTime(0.22, t + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + d + 0.4);
- o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
-}
-function sgBadgeEverywhere() {
- document.title = (sg.unread ? '(' + (sg.unread > 99 ? '99+' : sg.unread) + ') ' : '') + 'NE Hub';
- try { if (navigator.setAppBadge) { sg.unread ? navigator.setAppBadge(sg.unread) : navigator.clearAppBadge(); } } catch {}
-}
-function sgAlert(ev) { // a message from someone else arrived and you are not looking at that chat
- const a = sgAlerts();
- if (a.sound) sgChime();
- if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
- try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
- n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView()) location.hash = '#/signal/' + encodeURIComponent(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
- }
-}
-let sgEvT = null;
-function sgEvent(ev) {
- if (typeof ev.unread === 'number' && ev.unread !== sg.unread) { sg.unread = ev.unread; renderNav(activeTab); }
- if (ev.t === 'msg' && !ev.out) {
- const looking = sgMounted() && sg.cur === ev.conv && document.visibilityState === 'visible' && document.hasFocus();
- if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
- }
- if (ev.t === 'gone') sgGone(ev.conv, true);
- if (!sgMounted()) return;
- clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
-}
-
-// The Signal tab (full page, desktop) ...
-function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
-// ... and the same screens in a panel that floats over every other screen.
-async function sgMount(root, dock) {
- sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : 'Signal ';
- root.innerHTML = ``;
- let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `${title}
${esc(e.message)}
`; return; }
- if (sg.root !== root) return; sg.st = st;
- if (!st.reachable) { root.innerHTML = `${title}
The Signal service is not answering. Check that its container (signal ) is running, then reload.
`; return; }
- if (!st.linked) return sgLinkScreen(root, dock);
- root.innerHTML = `${dock ? 'Chats ' : '
Signal '}${icon('edit')}New
-
${esc(st.number)}${st.connected ? '' : ' · reconnecting…'}
${st.gap ? `
The link to Signal was down at ${new Date(st.gap.from).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} for ${Math.max(1, Math.round((st.gap.to - st.gap.from) / 1000))} s. A message that arrived then may be missing here.
` : ''}
-
`;
- sgq('#sgnew').onclick = sgNewChat; sgq('#sgq').oninput = sgDrawList;
- await sgRefresh(true);
-}
-async function sgRefresh(first) {
- try {
- const r = await sgApi('/convs'); sg.convs = r.convs; sg.unread = r.unread; renderNav(activeTab);
- if (!sgq('#sglist')) return; sgDrawList();
- if (sg.cur) await sgDrawThread(first); else sgq('#sgm').innerHTML = 'Choose a conversation, or start a new one.
';
- } catch (e) { const l = sgq('#sglist'); if (l) l.innerHTML = `${esc(e.message)}
`; }
-}
-function sgOpen(id) { // in the floating panel a chat opens in place (the page behind does not change)
- sg.cur = id; sgSaveDock(); const box = sgq('.sg'); if (box) box.classList.toggle('open', !!id);
- sgq('#sgm').innerHTML = ''; sgDrawList(); if (id) sgDrawThread(true);
-}
-function sgDrawList() {
- const q = (sgq('#sgq') || {}).value ? sgq('#sgq').value.toLowerCase() : '', box = sgq('#sglist'); if (!box) return;
- const rows = sg.convs.filter(c => !q || (c.name || c.id).toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q));
- box.innerHTML = rows.map(c => `${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}
- ${esc(c.name || c.id)} ${esc(c.preview || '')} ${c.ts ? sgTime(c.ts) : ''} ${c.unread ? `${c.unread} ` : ''} `).join('') || `${q ? 'Nothing matches' : 'No conversations yet. Tap New, or wait for a message.'}
`;
- if (sg.dock) box.onclick = e => { const a = e.target.closest('[data-id]'); if (a) { e.preventDefault(); sgOpen(a.dataset.id); } };
- box.oncontextmenu = e => { const a = e.target.closest('[data-id]'); if (a) { e.preventDefault(); sgChatMenu(a.dataset.id); } };
-}
-
-const sgLink = t => t.replace(/(https?:\/\/[^\s<]+[^\s<.,;:!?)\]'"])/g, u => `${u} `);
-const sgSize = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
-function sgBubble(m, c, prev) {
- const nm = a => (sg.names[a] || (a === sg.st.number ? 'You' : a));
- const att = (m.att || []).map(a => { const u = '/api/signal/attachment/' + encodeURIComponent(a.id);
- return /^image\//.test(a.type || '') ? ` `
- : /^audio\//.test(a.type || '') ? ` `
- : /^video\//.test(a.type || '') ? ` `
- : `${icon('clip')}${esc(a.name || 'file')}${a.size ? ' · ' + sgSize(a.size) : ''} `; }).join('');
- const reacts = {}; Object.values(m.react || {}).forEach(e => { reacts[e] = (reacts[e] || 0) + 1; });
- const day = !prev || new Date(prev.ts).toDateString() !== new Date(m.ts).toDateString() ? `${new Date(m.ts).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}
` : '';
- const tick = m.out ? (m.status === 'read' ? '✓✓' : m.status === 'delivered' ? '✓✓' : '✓') : '';
- return `${day}${c.type === 'group' && !m.out ? `
${esc(m.name || nm(m.from))} ` : ''}
- ${m.quote ? `
${esc(nm(m.quote.author))} ${esc(m.quote.text || '')}
` : ''}${att}
- ${m.deleted ? '
This message was deleted ' : m.text ? `
${sgLink(esc(m.text)).replace(/\n/g, ' ')}
` : ''}
-
${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ` ${tick} ` : ''}
- ${Object.keys(reacts).length ? `
${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')} ` : ''}
`;
-}
-async function sgDrawThread(first) {
- const box = sgq('#sgm'); if (!box) return;
- const id = sg.cur; let r;
- try { r = await sgApi('/conv/' + encodeURIComponent(id)); } catch (e) { box.innerHTML = `${esc(e.message)}
`; return; }
- if (sg.cur !== id || !sgq('#sgm')) return; sg.names = r.names || {};
- const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
- if (!box.querySelector('.sgbar')) {
- box.innerHTML = `${sg.dock ? `
${icon('back')} ` : `
${icon('back')} `}
${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))} ${icon('more')}
-
- ${icon('clip')}
- ${icon('send')} `;
- sgWireComposer(); const bk = sgq('#sgbk'); if (bk) bk.onclick = () => sgOpen(null); sgq('#sgmore').onclick = () => sgChatMenu(sg.cur);
- }
- sgq('#sgtitle').textContent = c.name || c.id; sgq('#sgsub').textContent = c.type === 'group' ? 'Group' : (c.name && c.name !== c.id ? c.id : '');
- const list = r.msgs, el = sgq('#sgmsgs');
- el.innerHTML = (r.more ? 'Older messages are not shown here
' : '') + list.map((m, i) => sgBubble(m, c, list[i - 1])).join('') || 'No messages yet. Say hello.
';
- if (first || stick) el.scrollTop = el.scrollHeight;
- el.onclick = e => { const b = e.target.closest('.sgb'); if (!b || e.target.closest('a, audio, video')) return; sgMessageMenu(list.find(m => String(m.ts) === b.dataset.ts), c); };
- if (c.unread || (sg.convs.find(x => x.id === id) || {}).unread) sgPost('/read', { id }).then(() => { const x = sg.convs.find(q => q.id === id); if (x) x.unread = 0; sgDrawList(); }).catch(() => {});
-}
-function sgMessageMenu(m, c) {
- if (!m || m.deleted) return;
- const el = openSheet('', '', `${['👍', '❤️', '😂', '😮', '😢', '🙏'].map(e => `${e} `).join('')}
- ${icon('back')}Reply ${m.text ? `${icon('copy')}Copy text ` : ''}
`);
- el.querySelectorAll('[data-e]').forEach(b => b.onclick = async () => { closeSheet(); try { await sgPost('/react', { to: c.id, ts: m.ts, emoji: (m.react && m.react[sg.st.number]) === b.dataset.e ? '' : b.dataset.e }); } catch (e) { toast(e.message); } });
- el.querySelector('#sgr').onclick = () => { closeSheet(); sg.reply = m; sgDrawReply(); sgq('#sgtext').focus(); };
- const cp = el.querySelector('#sgcp'); if (cp) cp.onclick = async () => { closeSheet(); try { await navigator.clipboard.writeText(m.text); toast('Copied'); } catch { toast('Could not copy'); } };
-}
-function sgDrawReply() {
- const b = sgq('#sgrep'); if (!b) return;
- if (!sg.reply) { b.hidden = true; return; }
- b.hidden = false; b.innerHTML = `${esc(sg.reply.out ? 'You' : (sg.reply.name || sg.reply.from))} ${esc(sg.reply.text || 'Attachment')} ${icon('x')} `;
- sgq('#sgrx').onclick = () => { sg.reply = null; sgDrawReply(); };
-}
-function sgDrawTray() {
- const t = sgq('#sgtray'); if (!t) return; t.hidden = !sg.tray.length;
- t.innerHTML = sg.tray.map((f, i) => `${/^image\//.test(f.type) ? ` ` : icon('clip')}${esc(f.name)} ${icon('x')} `).join('');
- t.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { sg.tray.splice(+b.dataset.x, 1); sgDrawTray(); });
-}
-const sgB64 = f => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = () => no(new Error('Could not read ' + f.name)); r.readAsDataURL(f); });
-function sgWireComposer() {
- const ta = sgq('#sgtext'), grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; };
- ta.oninput = grow;
- ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); sgq('#sgform').requestSubmit(); } };
- sgq('#sgat').onclick = () => sgq('#sgfile').click();
- sgq('#sgfile').onchange = e => { sg.tray.push(...e.target.files); e.target.value = ''; sgDrawTray(); };
- ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { sg.tray.push(...fs); sgDrawTray(); } });
- sgq('#sgform').onsubmit = async e => {
- e.preventDefault(); const text = ta.value, files = [...sg.tray]; if (!text.trim() && !files.length) return;
- const btn = sgq('#sgsend'); btn.disabled = true;
- try {
- const attachments = []; for (const f of files) attachments.push({ name: f.name, type: f.type || 'application/octet-stream', data: await sgB64(f) });
- await sgPost('/send', { to: sg.cur, text, attachments, quote: sg.reply ? sg.reply.ts : undefined });
- ta.value = ''; grow(); sg.tray = []; sg.reply = null; sgDrawTray(); sgDrawReply(); await sgRefresh(); const el = sgq('#sgmsgs'); if (el) el.scrollTop = el.scrollHeight;
- } catch (err) { toast(err.message); }
- btn.disabled = false; ta.focus();
- };
-}
-// ---- editing contacts and removing chats
-function sgChatMenu(id) {
- const c = sg.convs.find(x => x.id === id); if (!c) return;
- const acts = [];
- if (c.type === 'direct') acts.push({ label: 'Edit contact', icon: 'edit', run: () => sgEditContact(c) });
- acts.push({ label: 'Clear messages', icon: 'trash', run: () => confirmSheet('Clear all messages?', (c.name || c.id) + ': removed from this app only. Your phone keeps them.', 'Clear', async () => { try { await sgPost('/conv/clear', { id }); await sgRefresh(); } catch (e) { toast(e.message); } }) });
- acts.push({ label: 'Remove chat', icon: 'trash', danger: true, run: () => confirmSheet('Remove this chat?', 'It is removed from this app only. Your phone keeps it, and a new message from them starts it again here.', 'Remove', async () => { try { await sgPost('/conv/delete', { id }); sgGone(id); } catch (e) { toast(e.message); } }) });
- actionSheet(c.name || c.id, c.type === 'group' ? 'Group' : c.id, acts);
-}
-function sgGone(id, fromServer) {
- sg.convs = sg.convs.filter(x => x.id !== id);
- if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sgOnView()) location.hash = '#/signal'; }
- if (!fromServer || sgMounted()) sgRefresh();
-}
-function sgEditContact(c, after) {
- const el = openSheet('Edit contact', c.id || c.number, `Name
- Shown in this app, and sent to Signal as the contact's name. Leave it empty to use the name Signal has.
- Cancel Save
`);
- el.querySelector('#sgcx').onclick = closeSheet; setTimeout(() => el.querySelector('#sgcn').focus(), 60);
- el.querySelector('#sgcf').onsubmit = async e => { e.preventDefault();
- try { await sgPost('/contact', { number: c.id || c.number, name: el.querySelector('#sgcn').value }); closeSheet(); toast('Saved'); if (after) after(); await sgRefresh(); }
- catch (err) { el.querySelector('#sgce').textContent = err.message; } };
-}
-async function sgNewChat() {
- let contacts = []; try { contacts = (await sgApi('/contacts')).contacts; } catch {}
- const el = openSheet('New chat', '', `Phone number (with country code) or a contact
-
Start chat
`);
- const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `${esc(sgInitial(c.name || c.number))} ${esc(c.name || c.number)} ${esc(c.number)} ${icon('edit')}
`).join('');
- el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n));
- el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { const k = contacts.find(x => x.number === b.dataset.e); sgEditContact(k, async () => { try { contacts = (await sgApi('/contacts')).contacts; } catch {} }); }); };
- const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = '#/signal/' + encodeURIComponent(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
- input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
-}
-function sgLinkScreen(root, dock) {
- const v = root, cls = dock ? 'sgdockin' : 'wrap';
- const draw = () => { const img = sgq('#sgqr'); if (img) img.src = '/api/signal/qr?t=' + Date.now(); };
- v.innerHTML = `${dock ? '' : '
Signal '}
Link this app to your Signal account. It becomes one more linked device, like Signal Desktop.
-
On your phone open Signal → Settings → Linked devices → Link new device . Scan this code.
-
-
${icon('refresh')}New code
Waiting for you to scan…
-
Messages from before you link are not copied over, only new ones. Conversations are kept on your server.
`;
- draw(); sgq('#sgrq').onclick = draw;
- sg.timers.push(setInterval(draw, 55000));
- sg.timers.push(setInterval(async () => { try { const st = await sgApi('/status'); if (st.linked && sgMounted()) { toast('Signal linked'); sgMount(sg.root, sg.dock); } } catch {} }, 3000));
-}
-
-// ---- The floating chat: a button on every screen (desktop) that opens Signal over whatever you are doing.
-const sgDockState = () => { try { return JSON.parse(localStorage.getItem('sg_dock')) || {}; } catch { return {}; } };
-function sgSaveDock() { try { localStorage.setItem('sg_dock', JSON.stringify({ open: !!sg.dockOpen, cur: sg.cur })); } catch {} }
-function sgDockInit() {
- if ($('#sgfab') || !allowed('signal')) return;
- const st = sgDockState(); sg.dockOpen = !!st.open; if (st.cur) sg.cur = st.cur;
- const fab = document.createElement('button'); fab.id = 'sgfab'; fab.setAttribute('aria-label', 'Signal chat'); fab.title = 'Signal chat'; fab.hidden = true;
- const dock = document.createElement('div'); dock.id = 'sgdock'; dock.hidden = true; dock.setAttribute('role', 'dialog'); dock.setAttribute('aria-label', 'Signal chat');
- dock.innerHTML = `Signal ${icon('full')} ${icon('x')}
`;
- document.body.append(dock, fab);
- fab.onclick = () => (sg.dockOpen ? sgDockClose() : sgDockOpen());
- dock.querySelector('#sgdx').onclick = sgDockClose;
- dock.querySelector('#sgdfull').onclick = () => { const id = sg.cur; sgDockClose(); location.hash = '#/signal' + (id ? '/' + encodeURIComponent(id) : ''); };
- const ro = new ResizeObserver(() => document.documentElement.style.setProperty('--ph', ($('#player') && !$('#player').hidden ? $('#player').offsetHeight : 0) + 'px')); ro.observe($('#player'));
-}
-function sgDockBadge() { const f = $('#sgfab'); if (f) f.innerHTML = icon('chat') + (sg.unread ? `${sg.unread > 99 ? '99+' : sg.unread} ` : ''); }
-function sgDockOpen() { const d = $('#sgdock'); if (!d) return; sg.dockOpen = true; d.hidden = false; sgSaveDock(); const body = $('#sgdbody'); if (sg.root !== body) sgMount(body, true); }
-function sgDockClose() { const d = $('#sgdock'); if (d) d.hidden = true; sg.dockOpen = false; if (sg.dock) sg.root = null; const b = $('#sgdbody'); if (b) b.innerHTML = ''; sgSaveDock(); }
-function sgDockHide() { const d = $('#sgdock'), f = $('#sgfab'); if (d) d.hidden = true; if (f) f.hidden = true; if (sg.dock) sg.root = null; const b = $('#sgdbody'); if (b) b.innerHTML = ''; }
-function sgDockSync(name) { // after every screen change
- if (!allowed('signal')) { sgDockHide(); return; }
- sgDockInit(); const f = $('#sgfab'), d = $('#sgdock'); if (!f) return;
- if (name === 'signal' || name === 'edit') { sgDockHide(); return; }
- f.hidden = false; sgDockBadge();
- if (sg.dockOpen) { d.hidden = false; if (sg.root !== $('#sgdbody')) sgMount($('#sgdbody'), true); } else d.hidden = true;
-}
-sgMQ.addEventListener('change', () => { // a window made narrower than a desktop (or back)
- renderNav(activeTab);
- if (!sgMQ.matches) { sgStop(); sgDockHide(); if (sgOnView()) location.hash = '#/home'; } else { sgStart(); sgDockSync(activeTab); }
-});
-
-// ---- Boot
-(async () => {
- try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
- const loadedBuild = cfg.build;
- // When a new version is deployed, an open page keeps running the old one. Tell the person, and offer a reload.
- const checkForUpdate = async () => {
- try {
- const c = await (await fetch('/api/config', { cache: 'no-store' })).json();
- if (c.build && loadedBuild && c.build !== loadedBuild && !document.getElementById('upd')) {
- const b = document.createElement('div'); b.id = 'upd';
- b.innerHTML = 'A new version of NE Hub is available. Reload ';
- document.body.appendChild(b); document.getElementById('updgo').onclick = () => location.reload();
- }
- } catch {}
- };
- document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkForUpdate(); });
- setInterval(checkForUpdate, 10 * 60e3);
- await loadMe();
- await loadSettings();
- sgStart();
- addEventListener('hashchange', route);
- restorePlayer();
- route();
- if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
-})();
-
-// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
-addEventListener('keydown', e => {
- const tag = e.target.tagName || '', k = e.key;
- if (e.metaKey || e.ctrlKey || e.altKey || !cur() || /^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (k === ' ' && /^(BUTTON|A)$/.test(tag))) return;
- if (k === ' ') { e.preventDefault(); toggle(); }
- else if (k === 'ArrowRight') pl.a.currentTime += 10; else if (k === 'ArrowLeft') pl.a.currentTime = Math.max(0, pl.a.currentTime - 10);
- else if (k === 'n' || k === 'N') next(); else if (k === 'p' || k === 'P') prev();
-});
diff --git a/public/audiobooks.js b/public/audiobooks.js
new file mode 100644
index 0000000..fb09329
--- /dev/null
+++ b/public/audiobooks.js
@@ -0,0 +1,58 @@
+'use strict';
+// ---- Audiobooks: embedded site. Sign-in details saved in Settings (this browser only) can be copied with one tap,
+// because a page can't type into another site's login form.
+const abCreds = () => store.get('ab_creds', { u: '', p: '' });
+// Podcast Admin (Verse by Verse): the site refuses to be framed (X-Frame-Options), so this is a launcher.
+function viewVerse() {
+ const url = cfg.verse, host = new URL(url).host;
+ $('#view').innerHTML = `Podcast Admin ${esc(host + new URL(url).pathname)}
+
This site doesn't allow being shown inside another app, so it opens in its own tab.
+
`;
+}
+
+// Frames that must keep running (audio) live outside #view: created once, then only shown or hidden.
+function showFrame(key, url, title, allow) {
+ const wrap = $('#audiowrap');
+ let f = wrap.querySelector(`iframe[data-k="${key}"]`);
+ if (!f) { f = document.createElement('iframe'); f.dataset.k = key; f.src = url; f.title = title; f.allow = allow; wrap.appendChild(f); }
+ wrap.querySelectorAll('iframe').forEach(x => x.classList.toggle('act', x === f));
+ wrap.className = 'on'; document.body.classList.add('onaudio');
+}
+
+// Podcast: opens in a frame when it has a secure (https) address; a plain-http address cannot be framed by a secure page.
+// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is
+// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN).
+function viewPodcast() {
+ const url = cfg.podcast, u = new URL(url), canFrame = u.protocol === 'https:';
+ $('#view').innerHTML = `Podcast ${esc(u.host)}
+
+ ${canFrame ? '
If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.
'
+ : '
This is a plain http address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.
'}
`;
+ if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
+}
+
+// The frame is created once and kept alive, so playback carries on while you use other tabs.
+// It loads /api/audiobooks/enter, where the server adds the site's secret sign-in link itself (the key never reaches this page).
+const ENTER = '/api/audiobooks/enter';
+function viewAudio() {
+ const c = abCreds(), old = $('#audiowrap iframe[data-k="audio"]');
+ $('#view').innerHTML = `Audiobooks ${old ? '' : 'Opening…'}
+ ${c.u ? `
${icon('copy')} ` : ''}
+
${icon('refresh')}
+
${icon('full')}
+
${icon('ext')} `;
+ const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; };
+ const open = fresh => {
+ setSt('Opening…');
+ let f = $('#audiowrap iframe[data-k="audio"]'); if (f) f.remove();
+ showFrame('audio', ENTER + (fresh ? '?t=' + Date.now() : ''), 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media');
+ f = $('#audiowrap iframe[data-k="audio"]'); f.referrerPolicy = 'no-referrer';
+ let done = false;
+ f.addEventListener('load', () => { done = true; setSt(''); });
+ setTimeout(() => { if (!done) setSt('Still loading… if it stays blank, tap the arrow to open it in its own tab'); }, 10000);
+ };
+ if (!old) open(false); else { showFrame('audio', '', 'Audiobooks', ''); setSt(''); }
+ $('#ar').onclick = () => open(true);
+ $('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="audio"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); };
+ const cu = $('#cu'); if (cu) cu.onclick = async () => { try { await navigator.clipboard.writeText(c.u); toast('Username copied. Password is in Settings.'); } catch { toast('Could not copy'); } };
+}
diff --git a/public/boot.js b/public/boot.js
new file mode 100644
index 0000000..7529183
--- /dev/null
+++ b/public/boot.js
@@ -0,0 +1,35 @@
+'use strict';
+// ---- Boot
+(async () => {
+ try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
+ const loadedBuild = cfg.build;
+ // When a new version is deployed, an open page keeps running the old one. Tell the person, and offer a reload.
+ const checkForUpdate = async () => {
+ try {
+ const c = await (await fetch('/api/config', { cache: 'no-store' })).json();
+ if (c.build && loadedBuild && c.build !== loadedBuild && !document.getElementById('upd')) {
+ const b = document.createElement('div'); b.id = 'upd';
+ b.innerHTML = 'A new version of NE Hub is available. Reload ';
+ document.body.appendChild(b); document.getElementById('updgo').onclick = () => location.reload();
+ }
+ } catch {}
+ };
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkForUpdate(); });
+ setInterval(checkForUpdate, 10 * 60e3);
+ await loadMe();
+ await loadSettings();
+ sgStart();
+ addEventListener('hashchange', route);
+ restorePlayer();
+ route();
+ if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
+})();
+
+// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
+addEventListener('keydown', e => {
+ const tag = e.target.tagName || '', k = e.key;
+ if (e.metaKey || e.ctrlKey || e.altKey || !cur() || /^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (k === ' ' && /^(BUTTON|A)$/.test(tag))) return;
+ if (k === ' ') { e.preventDefault(); toggle(); }
+ else if (k === 'ArrowRight') pl.a.currentTime += 10; else if (k === 'ArrowLeft') pl.a.currentTime = Math.max(0, pl.a.currentTime - 10);
+ else if (k === 'n' || k === 'N') next(); else if (k === 'p' || k === 'P') prev();
+});
diff --git a/public/calendar.js b/public/calendar.js
new file mode 100644
index 0000000..85ef361
--- /dev/null
+++ b/public/calendar.js
@@ -0,0 +1,239 @@
+'use strict';
+// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
+const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+async function loadRange(start, days) {
+ const end = new Date(start); end.setDate(end.getDate() + days);
+ const last = new Date(end.getTime() - 1);
+ const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) });
+ const { events, errors } = await jfetch('/api/cal/events?' + q);
+ const byDay = new Map();
+ const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); };
+ for (const e of events) {
+ if (e.allDay) { // spans start..end-1 (end exclusive)
+ for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); }
+ } else put(ymd(new Date(e.start)), e);
+ }
+ for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
+ return { byDay, errors, start };
+}
+const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); };
+const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+const evStore = []; // events currently on screen, so a tap can open the right one
+const evRow = e => { const i = evStore.push(e) - 1;
+ return `${esc(e.title)}
+ ${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''} ${e.editable ? icon('chev', 'chev') : ''} `; };
+document.addEventListener('click', e => { const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]); });
+const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
+ return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
+
+async function renderAgenda(el, mode) { // Home card (today only)
+ if (!el) return;
+ try {
+ const cals = await jfetch('/api/cal/list');
+ const { byDay } = await loadAgenda(1);
+ const list = byDay.get(ymd(new Date())) || [];
+ if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
+ if (!document.body.contains(el)) return;
+ el.innerHTML = `Today ${list.length ? list.slice(0, 6).map(evRow).join('') : '
Nothing on the calendar today
'}
`;
+ } catch {}
+}
+
+const now0 = new Date();
+const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
+
+async function viewCalendar() {
+ const mode = store.get('cal_view', 'month');
+ const v = $('#view');
+ v.innerHTML = `Calendar
+
${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `
${l} `).join('')}
+
${icon('cal')}Add event
+
Manage calendars
`;
+ $('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
+ v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
+ $('#mc').onclick = manageCalendars;
+ try {
+ const cals = await jfetch('/api/cal/list');
+ if (!cals.length) { $('#cbody').innerHTML = 'No calendars yet. Add a private calendar link to see your events here.
Add a calendar
'; $('#ac').onclick = addCalendar; return; }
+ if (mode === 'month') await renderMonth(); else if (mode === 'week') await renderTimeGrid(7); else if (mode === 'day') await renderTimeGrid(1); else await renderAgendaList();
+ } catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `${esc(e.message)}
`; }
+}
+
+async function renderAgendaList() {
+ const range = store.get('cal_range', 7);
+ const { byDay, errors } = await loadAgenda(range);
+ if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
+ const keys = [...byDay.keys()].sort();
+ $('#cbody').innerHTML = `${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `
${l} `).join('')}
`
+ + (keys.map(k => `${esc(dayLabel(k))} ${byDay.get(k).map(evRow).join('')}
`).join('') || 'Nothing coming up
')
+ + errors.map(e => `${esc(e)}
`).join('');
+ $('#cbody').querySelectorAll('.seg a[data-n]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
+}
+
+async function renderMonth() {
+ const { y, m } = calState;
+ const first = new Date(y, m, 1), gridStart = new Date(y, m, 1 - first.getDay()); // the Sunday on or before the 1st
+ const { byDay, errors } = await loadRange(gridStart, 42);
+ if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
+ const today = ymd(new Date());
+ if (calState.sel < ymd(gridStart) || calState.sel > ymd(new Date(y, m, 1 - first.getDay() + 41))) calState.sel = ymd(first);
+ const cells = [];
+ for (let i = 0; i < 42; i++) {
+ const d = new Date(gridStart); d.setDate(d.getDate() + i);
+ const k = ymd(d), evs = byDay.get(k) || [], other = d.getMonth() !== m;
+ cells.push(`
+ ${d.getDate()}
+ ${evs.slice(0, 4).map(e => ` `).join('')}
+ ${evs.slice(0, 3).map(e => `${esc(e.title)} `).join('')}${evs.length > 3 ? `+${evs.length - 3} more ` : ''} `);
+ }
+ $('#cbody').innerHTML = `${icon('back')}
+
${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}
+ ${icon('back')} Today
+ ${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `${d} `).join('')}
+ ${cells.join('')}
+
${errors.map(e => `${esc(e)}
`).join('')}`;
+ const showDay = () => {
+ const k = calState.sel, evs = byDay.get(k) || [];
+ $('#dayev').innerHTML = `${esc(dayLabel(k))} ${evs.length ? evs.map(evRow).join('') : '
No events
'}
`;
+ $('#cbody').querySelectorAll('.cday').forEach(c => c.classList.toggle('sel', c.dataset.d === k));
+ };
+ showDay();
+ $('#cbody').querySelectorAll('.cday').forEach(c => c.onclick = () => { calState.sel = c.dataset.d; showDay(); });
+ const go = (dm) => { const t = new Date(y, m + dm, 1); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
+ $('#pm').onclick = () => go(-1); $('#nm').onclick = () => go(1);
+ $('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
+}
+
+const deviceTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+const pad2 = n => String(n).padStart(2, '0');
+const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+
+function openEvent(e) {
+ if (!e) return;
+ if (e.editable) return editEvent(e);
+ const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
+ const el = openSheet(e.title, e.cal, `When ${esc(when)}
+ ${e.location ? `
Where ${esc(e.location)}
` : ''}
+ This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.
Close
`);
+ el.querySelector('#sx').onclick = closeSheet;
+}
+
+async function editEvent(e) {
+ try {
+ const q = new URLSearchParams({ cal: e.calId, tz: deviceTz() }); if (e.uid) q.set('uid', e.uid); if (e.href) q.set('href', e.href);
+ const r = await jfetch('/api/cal/event?' + q);
+ eventForm({ mode: 'edit', calId: e.calId, calName: e.cal, uid: e.uid, href: e.href || r.href, etag: r.etag || e.etag, fields: r.fields, series: e.series });
+ } catch (err) { toast(err.message); }
+}
+
+async function newEvent(day) {
+ try {
+ const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
+ const d = new Date(day + 'T00:00:00'), now = new Date();
+ if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0);
+ const end = new Date(d.getTime() + 36e5);
+ eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
+ } catch (err) { toast(err.message); }
+}
+
+function eventForm(o) {
+ const f = { ...o.fields }, editing = o.mode === 'edit';
+ const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
+ if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']);
+ const calPick = editing ? `Calendar ${esc(o.calName)}
`
+ : `Calendar ${o.cals.map(c => `${esc(c.name)}${c.icloud ? ' (iCloud)' : ''} `).join('')} `;
+ const el = openSheet(editing ? 'Edit event' : 'New event', '', `
+ All day
+
+ Repeat ${opts.map(([v, l]) => `${l} `).join('')}
+ Location
+ Notes ${esc(f.notes)}
+ ${calPick}
+ ${editing && o.series ? 'This repeats. Changes apply to the whole series.
' : ''}
+ Cancel ${editing ? 'Save' : 'Add'}
+ ${editing ? 'Delete event
' : ''}
+
`);
+ el.querySelector('.sheet').classList.add('wide');
+ const draw = () => { // date-only inputs for all-day, date+time otherwise
+ const t = f.allDay ? 'date' : 'datetime-local', cut = v => f.allDay ? v.slice(0, 10) : v;
+ el.querySelector('#fdt').innerHTML = `Starts Ends
+ ${!f.allDay && f.tz !== deviceTz() ? `Times are in ${esc(f.tz)}.
` : ''}`;
+ const s = el.querySelector('#fs'), en = el.querySelector('#fe2');
+ s.onchange = () => { // moving the start keeps the length
+ const len = Date.parse(f.allDay ? f.end.slice(0, 10) + 'T00:00' : f.end) - Date.parse(f.allDay ? f.start.slice(0, 10) + 'T00:00' : f.start);
+ f.start = s.value; if (s.value) { const ns = Date.parse(f.allDay ? s.value + 'T00:00' : s.value); if (!isNaN(ns) && len >= 0) { const d = new Date(ns + len); f.end = f.allDay ? ymd(d) : localStr(d); en.value = f.allDay ? ymd(d) : localStr(d); } }
+ };
+ en.onchange = () => { f.end = en.value; };
+ };
+ draw();
+ el.querySelector('#fa').onchange = ev => {
+ f.start = el.querySelector('#fs').value || f.start; f.end = el.querySelector('#fe2').value || f.end;
+ f.allDay = ev.target.checked;
+ if (f.allDay) { f.start = f.start.slice(0, 10); f.end = f.end.slice(0, 10); }
+ else { f.start = f.start.slice(0, 10) + 'T09:00'; f.end = f.end.slice(0, 10) + 'T10:00'; }
+ draw();
+ };
+ el.querySelector('#sx').onclick = closeSheet;
+ el.querySelector('#ef').onsubmit = async ev => {
+ ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#fe').textContent = '';
+ const fields = { title: el.querySelector('#ft').value.trim(), location: el.querySelector('#fl').value.trim(), notes: el.querySelector('#fn').value.trim(), allDay: f.allDay,
+ start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
+ const cal = editing ? o.calId : el.querySelector('#fc').value;
+ try {
+ await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) });
+ if (!editing) store.set('cal_default', cal);
+ closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
+ } catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
+ };
+ const del = el.querySelector('#fd');
+ if (del) del.onclick = () => confirmSheet('Delete event?', o.series ? f.title + ' and every repeat of it' : f.title, 'Delete', async () => {
+ try { await jfetch('/api/cal/event/delete', { method: 'POST', body: JSON.stringify({ cal: o.calId, uid: o.uid, href: o.href, etag: o.etag }) }); toast('Event deleted'); viewCalendar(); }
+ catch (err) { toast(err.message); }
+ });
+}
+
+function addCalendar() {
+ const el = openSheet('Add a calendar link', '', `Name
+ Private calendar link (ICS)
+ Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. These are read-only: you see the events here but change them in the original app. Anyone with the link can read that calendar, so keep it private.
+ Cancel Add
`);
+ el.querySelector('#sx').onclick = closeSheet;
+ el.querySelector('form').onsubmit = async e => {
+ e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
+ try { await jfetch('/api/cal/add', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#cn').value.trim() || 'Calendar', url: el.querySelector('#cu2').value.trim() }) }); closeSheet(); toast('Calendar added'); viewCalendar(); }
+ catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
+ };
+}
+
+async function manageCalendars() {
+ const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
+ const links = cals.filter(c => !c.local && !c.icloud);
+ const el = openSheet('Calendars', '', `
+
NE Hub Events you add here
+ ${links.map(c => `
${esc(c.name)} Link (read-only) Remove
`).join('')}
+
+ Add a calendar link
+ iCloud calendars
+ ${ic.connected ? `${ic.calendars.map(c => `${esc(c.name)} `).join('')}
+ Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.
Disconnect iCloud
`
+ : `Read and add events in your iCloud calendars. Use an app-specific password (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.
+
+ Connect iCloud
`}
+ See NE Hub events in other apps
+ Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).
+
+ ${icon('copy')}Copy link Make a new link
+ Close
`);
+ el.querySelector('.sheet').classList.add('wide');
+ el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
+ el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); });
+ el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } });
+ const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
+ const icf = el.querySelector('#icf');
+ if (icf) icf.onsubmit = async e => {
+ e.preventDefault(); const btn = icf.querySelector('.btn'); btn.disabled = true; btn.textContent = 'Connecting…'; el.querySelector('#ice').textContent = '';
+ try { await jfetch('/api/cal/icloud/connect', { method: 'POST', body: JSON.stringify({ appleId: el.querySelector('#iu').value, password: el.querySelector('#ip').value }) }); toast('iCloud connected'); closeSheet(); viewCalendar(); }
+ catch (err) { btn.disabled = false; btn.textContent = 'Connect iCloud'; el.querySelector('#ice').textContent = err.message; }
+ };
+ el.querySelector('#fc2').onclick = async () => { try { await navigator.clipboard.writeText(feed.url); toast('Link copied'); } catch { el.querySelector('#fu').select(); toast('Press copy on the selected link'); } };
+ el.querySelector('#fr2').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { await jfetch('/api/cal/feed', { method: 'POST', body: '{}' }); manageCalendars(); });
+}
diff --git a/public/file-actions.js b/public/file-actions.js
new file mode 100644
index 0000000..89792a6
--- /dev/null
+++ b/public/file-actions.js
@@ -0,0 +1,73 @@
+'use strict';
+// ---- File actions
+const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac' };
+const previewKind = n => Object.keys(PREVIEW).find(k => PREVIEW[k].split(' ').includes(ext(n)));
+const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
+const encP = p => p.split('/').map(encodeURIComponent).join('/');
+const fileUrl = async (repo, path) => { const u = new URL(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), cfg.seafile); return '/sf' + u.pathname; };
+
+// Recently opened files, shown on Home (kept in this browser only).
+function addRecent(repo, path, name) {
+ const r = store.get('recents', []).filter(x => !(x.repo === repo && x.path === path));
+ r.unshift({ repo, path, name, t: Date.now() }); store.set('recents', r.slice(0, 12));
+}
+
+// Default tap on a file: edit documents, preview media, otherwise show the actions.
+function openFile(repo, path, name, dir) {
+ if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); }
+ else if (previewKind(name)) previewFile(repo, path, name);
+ else fileSheet(repo, path, name, dir);
+}
+
+async function previewFile(repo, path, name) {
+ addRecent(repo, path, name);
+ try {
+ const url = (await fileUrl(repo, path)) + '?hubinline=1', kind = previewKind(name);
+ const media = kind === 'img' ? ` `
+ : kind === 'pdf' ? ``
+ : kind === 'video' ? ` `
+ : ` `;
+ const el = openSheet(name, '', `${media}`);
+ el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
+ } catch (e) { toast(e.message); }
+}
+
+async function shareLink(repo, path, isDir) {
+ const p = isDir ? path.replace(/\/?$/, '/') : path;
+ try {
+ let link;
+ try { link = (await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORM, body: new URLSearchParams({ repo_id: repo, path: p }) })).link; }
+ catch { link = (await sf.req(`/api/v2.1/share-links/?repo_id=${encodeURIComponent(repo)}&path=${encodeURIComponent(p)}`))[0]?.link; }
+ if (!link) throw new Error('Could not create a share link');
+ const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || '', `
+ ${icon('copy')}Copy ${navigator.share ? 'Share… ' : ''}
`);
+ el.querySelector('#cp1').onclick = async () => { try { await navigator.clipboard.writeText(link); toast('Link copied'); closeSheet(); } catch { el.querySelector('#sl').select(); toast('Press copy on the selected link'); } };
+ const sh = el.querySelector('#sh1'); if (sh) sh.onclick = () => navigator.share({ url: link }).catch(() => {});
+ } catch (e) { toast(e.message); }
+}
+
+function fileSheet(repo, path, name, dir) {
+ const enc = encodeURIComponent, acts = [], ro = libPerm[repo] === 'r', it = { name, type: 'file' };
+ if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) });
+ if (cfg.editor && EDIT_EXT.has(ext(name)) && !ro) 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: 'Download', icon: 'dl', run: () => downloadItem(repo, path) });
+ acts.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, path, name, ro) });
+ if (!ro) acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
+ if (!ro) acts.push({ label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) });
+ acts.push({ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) });
+ if (!ro) acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) });
+ actionSheet(name, '', acts);
+}
+
+function dirSheet(repo, path, name, dir) {
+ const ro = libPerm[repo] === 'r', it = { name, type: 'dir' };
+ actionSheet(name, 'Folder', [
+ { label: 'Open', icon: 'files', run: () => { location.hash = '#/files/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); } },
+ { label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) },
+ ...(ro ? [] : [{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) }, { label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) }]),
+ { label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) },
+ ...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) }]),
+ ]);
+}
diff --git a/public/files.js b/public/files.js
new file mode 100644
index 0000000..a1e7a2d
--- /dev/null
+++ b/public/files.js
@@ -0,0 +1,398 @@
+'use strict';
+// ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret)
+async function viewEdit([repo, ...rawParts], opts = {}) {
+ const parts = rawParts.filter(Boolean);
+ const path = '/' + parts.join('/'), name = parts[parts.length - 1] || '';
+ const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
+ document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
+ const v = $('#view');
+ v.innerHTML = `
+ `;
+ if (!sf.token()) return (location.hash = '#/files');
+ const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
+ try {
+ const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
+ body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) });
+ const conf = await r.json();
+ if (!r.ok) return fail(conf.error || 'Could not open document');
+ if (!window.DocsAPI) await new Promise((ok, no) => {
+ const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js';
+ s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s);
+ });
+ if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
+ $('#oomsg').remove();
+ conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
+ editor = new DocsAPI.DocEditor('oo', conf);
+ } catch (e) { fail(e.message); }
+}
+
+// ---- Files
+function viewFiles([repo, ...parts]) {
+ if (!sf.token()) return filesLogin();
+ const dir = '/' + parts.join('/');
+ $('#view').innerHTML = ``;
+ libsP = loadLibs(repo);
+ if (repo) loadDir(repo, dir);
+}
+
+function filesLogin(msg = '', needOtp = false) {
+ $('#view').innerHTML = ``;
+ $('#lf').onsubmit = async e => {
+ e.preventDefault();
+ const f = new FormData(e.target);
+ try { await sf.login(f.get('u'), f.get('p'), f.get('o')); route(); }
+ catch (err) { filesLogin(err.message, !!err.otp || needOtp); }
+ };
+}
+
+async function loadLibs(active) {
+ try {
+ const repos = (await sf.req('/api2/repos/')).sort((a, b) => a.name.localeCompare(b.name));
+ libNames = Object.fromEntries(repos.map(r => [r.id, r.name])); libPerm = Object.fromEntries(repos.map(r => [r.id, r.permission]));
+ $('#libs').innerHTML = `${icon('newdir')}New library
Libraries `;
+ $('#nl').onclick = () => textSheet('New library', 'Library name', 'Create', async name => {
+ try { const r = await sf.req('/api2/repos/', { method: 'POST', body: new URLSearchParams({ name }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); location.hash = '#/files/' + encodeURIComponent(r.repo_id); }
+ catch (e) { toast(e.message); }
+ });
+ if (!active) return;
+ const r = repos.find(x => x.id === active);
+ if (r && r.encrypted) $('#fmain').innerHTML = 'This library is encrypted. Unlock it in the Seafile web UI first.
';
+ } catch (e) { $('#libs').innerHTML = `${esc(e.message)}
`; if (!sf.token()) setTimeout(route, 800); }
+}
+
+// ---- Files: browse, select, move/copy/delete, rename, upload (drag in from the computer, drag a file out to download)
+let fv = null, libsP = Promise.resolve(), libNames = {}, libPerm = {};
+const SORTS = [['name', 'Name'], ['mtime', 'Date modified'], ['size', 'Size'], ['type', 'Kind']];
+const nameCmp = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
+function sortItems(items, s) {
+ const by = { name: nameCmp, mtime: (a, b) => a.mtime - b.mtime, size: (a, b) => (a.size || 0) - (b.size || 0), type: (a, b) => ext(a.name).localeCompare(ext(b.name)) }[s.k] || nameCmp;
+ return [...items].sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) // folders stay on top
+ : (a.type === 'dir' && s.k !== 'name' && s.k !== 'mtime') ? nameCmp(a, b) : s.d * by(a, b) || nameCmp(a, b));
+}
+const badName = (n, items, self) => !n || n === '.' || n === '..' ? 'Enter a name' : /[\/\\]/.test(n) ? 'A name cannot contain / or \\' : n.length > 255 ? 'That name is too long'
+ : items.some(x => x.name.toLowerCase() === n.toLowerCase() && x.name !== self) ? 'Something with that name is already here' : '';
+const sfJson = (path, body) => sf.req(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+const hereItems = (repo, dir) => (fv && fv.repo === repo && fv.dir === dir ? fv.items : []);
+// After something changed in a folder, redraw it if it is the one on screen.
+function afterChange(repo, dir) { if (fv && document.contains(fv.root) && fv.repo === repo && fv.dir === dir) { fv.sel.clear(); fv.selecting = false; fv.reload(); } }
+
+async function removeItems(repo, dir, items) {
+ const enc = encodeURIComponent;
+ try { await sfJson('/api/v2.1/repos/batch-delete-item/', { repo_id: repo, parent_dir: dir, dirents: items.map(x => x.name) }); }
+ catch (e) { // older servers have no batch call: one at a time
+ const failed = [];
+ for (const it of items) { try { await sf.req(`/api2/repos/${enc(repo)}/${it.type === 'dir' ? 'dir' : 'file'}/?p=${enc(joinP(dir, it.name))}`, { method: 'DELETE' }); } catch { failed.push(it.name); } }
+ if (failed.length) throw new Error('Could not delete: ' + failed.join(', '));
+ }
+}
+function deleteItems(repo, dir, items) {
+ const one = items.length === 1;
+ confirmSheet(one ? (items[0].type === 'dir' ? 'Delete folder?' : 'Delete file?') : `Delete ${items.length} items?`,
+ one ? items[0].name + (items[0].type === 'dir' ? ' and everything in it' : '') : 'Folders are deleted with everything inside them', 'Delete', async () => {
+ try { await removeItems(repo, dir, items); toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); }
+ afterChange(repo, dir);
+ });
+}
+async function transferItems(mode, repo, dir, names, dst) {
+ await sfJson(`/api/v2.1/repos/sync-batch-${mode}-item/`, { src_repo_id: repo, src_parent_dir: dir, dst_repo_id: dst.repo, dst_parent_dir: dst.dir, src_dirents: names });
+}
+function moveSheet(mode, repo, dir, items) {
+ const names = items.map(x => x.name), folders = items.filter(x => x.type === 'dir').map(x => joinP(dir, x.name)), verb = mode === 'move' ? 'Move' : 'Copy';
+ chooseFolder(verb + (items.length === 1 ? ' “' + names[0] + '”' : ' ' + items.length + ' items') + ' to…', { repo, repoName: libNames[repo] || '', dir },
+ { okLabel: verb + ' here', hide: (r, d) => (r === repo && d === dir ? names : []), newFolder: true,
+ blocked: (r, d) => r === repo && ((mode === 'move' && d === dir) || folders.some(f => d === f || d.startsWith(f + '/'))) }).then(async dst => {
+ if (!dst) return;
+ try { await transferItems(mode, repo, dir, names, dst); toast((mode === 'move' ? 'Moved' : 'Copied') + (items.length > 1 ? ' ' + items.length + ' items' : '')); }
+ catch (e) { toast(e.message); }
+ afterChange(repo, dir);
+ });
+}
+function renameItem(repo, path, name, dir, isDir) {
+ textSheet('Rename', 'New name', 'Rename', async n => {
+ if (n === name) return;
+ 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'); }
+ catch (e) { toast(e.message); }
+ afterChange(repo, dir);
+ }, name, n => badName(n, hereItems(repo, dir), name));
+}
+async function downloadItem(repo, path) {
+ try { window.open(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), '_blank', 'noopener'); } catch (e) { toast(e.message); }
+}
+
+// ---- Uploads: a queue that survives leaving the folder, with progress, retries and a way to cancel
+const uq = { items: [], running: 0 };
+const upActive = () => uq.items.some(x => x.state === 'wait' || x.state === 'up');
+function enqueueUploads(repo, dir, entries) {
+ if (!entries.length) return;
+ for (const e of entries) uq.items.push({ repo, dir, file: e.file, rel: e.rel || '', pct: 0, state: 'wait', tries: 0 });
+ drawUpBar(); pumpUploads();
+}
+function pumpUploads() {
+ while (uq.running < 2) {
+ const it = uq.items.find(x => x.state === 'wait'); if (!it) break;
+ uq.running++; it.state = 'up';
+ sendUpload(it).then(() => { it.state = 'done'; }, err => { it.state = it.cancelled ? 'cancel' : 'fail'; it.err = err.message; })
+ .finally(() => { uq.running--; drawUpBar(); pumpUploads(); if (!upActive()) uploadsFinished(); });
+ }
+}
+async function sendUpload(it) {
+ const enc = encodeURIComponent;
+ for (;;) {
+ it.tries++; it.pct = 0;
+ try {
+ const link = await sf.req(`/api2/repos/${enc(it.repo)}/upload-link/?p=${enc(it.dir)}`); // a fresh link each time, so an expired one never blocks a retry
+ await new Promise((ok, no) => {
+ const x = it.xhr = new XMLHttpRequest(), fd = new FormData();
+ fd.append('parent_dir', it.dir); fd.append('replace', '0'); if (it.rel) fd.append('relative_path', it.rel); fd.append('file', it.file, it.file.name);
+ x.open('POST', '/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1');
+ x.upload.onprogress = e => { if (e.lengthComputable) { it.pct = e.loaded / e.total; drawUpBar(); } };
+ x.onload = () => x.status >= 200 && x.status < 300 ? ok() : no(Object.assign(new Error(x.status === 413 ? 'Too large for the server' : x.status === 403 ? 'Not allowed (or out of space)' : 'Upload failed (' + x.status + ')'), { fatal: x.status >= 400 && x.status < 500 }));
+ x.onerror = () => no(new Error('Connection lost'));
+ x.onabort = () => no(Object.assign(new Error('Cancelled'), { fatal: true }));
+ x.send(fd);
+ });
+ return;
+ } catch (e) {
+ if (it.cancelled) throw e;
+ if (e.fatal || !sf.token() || it.tries >= 3) throw new Error(it.file.name + ': ' + e.message);
+ await sleep(1500 * it.tries);
+ }
+ }
+}
+function uploadsFinished() {
+ const done = uq.items.filter(x => x.state === 'done').length, bad = uq.items.filter(x => x.state === 'fail').length;
+ if (done) toast(done + (done === 1 ? ' file' : ' files') + ' uploaded' + (bad ? ', ' + bad + ' failed' : ''));
+ uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar();
+ if (fv && document.contains(fv.root)) fv.reload(true);
+}
+function drawUpBar() {
+ let b = $('#upbar');
+ const live = uq.items.filter(x => x.state === 'wait' || x.state === 'up'), bad = uq.items.filter(x => x.state === 'fail');
+ if (!live.length && !bad.length) { if (b) b.remove(); return; }
+ if (!b) { b = document.createElement('div'); b.id = 'upbar'; document.body.appendChild(b); }
+ if (live.length) {
+ const total = uq.items.length, doneN = uq.items.filter(x => x.state === 'done').length, cur = live.find(x => x.state === 'up') || live[0];
+ const pct = Math.round(((doneN + live.reduce((a, x) => a + (x.state === 'up' ? x.pct : 0), 0)) / total) * 100);
+ b.innerHTML = `Uploading ${esc(live.length === 1 ? cur.file.name : live.length + ' files')} ${pct}%
Cancel `;
+ $('#upx').onclick = () => { uq.items.forEach(x => { if (x.state === 'wait') x.state = 'cancel'; if (x.state === 'up') { x.cancelled = true; x.xhr && x.xhr.abort(); } }); uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar(); };
+ } else {
+ b.innerHTML = `${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed ${esc(bad[0].err || '')}
Retry Dismiss `;
+ $('#upr').onclick = () => { bad.forEach(x => { x.state = 'wait'; x.tries = 0; x.cancelled = false; }); drawUpBar(); pumpUploads(); };
+ $('#upd').onclick = () => { uq.items = uq.items.filter(x => x.state !== 'fail'); drawUpBar(); };
+ }
+}
+addEventListener('beforeunload', e => { if (upActive()) { e.preventDefault(); e.returnValue = ''; } });
+
+// Everything dropped from the computer, including whole folders (their structure is kept).
+async function droppedFiles(dt) {
+ const ents = [...(dt.items || [])].map(i => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null)).filter(Boolean), out = [];
+ if (!ents.length) return [...dt.files].map(file => ({ file, rel: '' }));
+ const walk = async (en, rel) => {
+ if (en.isFile) await new Promise(ok => en.file(f => { out.push({ file: f, rel }); ok(); }, ok));
+ else if (en.isDirectory) {
+ const here = rel ? rel + '/' + en.name : en.name, rd = en.createReader(); let batch;
+ do { batch = await new Promise(ok => rd.readEntries(ok, () => ok([]))); for (const c of batch) await walk(c, here); } while (batch.length);
+ }
+ };
+ for (const en of ents) await walk(en, '');
+ return out;
+}
+const dlUrls = new Map(); // direct download addresses, fetched ahead so a file can be dragged out to the desktop
+async function prefetchDl(repo, path) {
+ const k = repo + path, c = dlUrls.get(k); if (c && Date.now() - c.t < 45 * 60e3) return;
+ dlUrls.set(k, { t: Date.now() });
+ try { const u = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`); dlUrls.set(k, { t: Date.now(), url: new URL(u, cfg.seafile).href }); } catch { dlUrls.delete(k); }
+}
+
+async function loadDir(repo, dir) {
+ const main = $('#fmain'); if (!main) return;
+ main.innerHTML = 'Loading…
';
+ await libsP.catch(() => {});
+ if (!document.contains(main)) return;
+ const enc = encodeURIComponent, ro = libPerm[repo] === 'r';
+ const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join('');
+ const segs = dir.split('/').filter(Boolean);
+ const crumbs = `${esc(libNames[repo] || 'Library')} ` + segs.map((s, i) => `/ ${esc(s)} `).join('');
+ const v = fv = { root: main, repo, dir, items: [], sel: new Set(), selecting: false, last: null, filter: '', tok: null };
+ const sortCfg = () => store.get('files_sort', { k: 'name', d: 1 });
+ const byName = n => v.items.find(x => x.name === n);
+ const selItems = () => v.items.filter(x => v.sel.has(x.name));
+ const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); };
+
+ main.innerHTML = `
${crumbs}
+
`;
+ const go = p => { location.hash = hash(p); };
+
+ const drawTop = () => {
+ const n = v.sel.size, one = n === 1 ? selItems()[0] : null;
+ $('#ftop').innerHTML = v.selecting
+ ? `${n ? n + ' selected' : 'Select items'}
+ ${n && n === visible().length ? 'None' : 'All'} Done
+
${ro ? '' : `${icon('files')}Move `}
+ ${icon('copy')}Copy
+ ${ro ? '' : `${icon('trash')}Delete `}
+ ${icon('more')}
`
+ : ``;
+ };
+ const drawItems = () => {
+ const list = visible(), box = $('#items'), ff = $('#ff');
+ ff.hidden = v.items.length < 8;
+ const row = it => { const d = it.type === 'dir', on = v.sel.has(it.name);
+ return `
+ ${v.selecting ? `${on ? icon('check') : ''} ` : ''}
+ ${icon(d ? 'files' : 'file')}
+ ${esc(it.name)} ${d ? fmtDate(it.mtime) : fmtSize(it.size) + ' · ' + fmtDate(it.mtime)}
+ ${v.selecting ? '' : `${icon('more')} `}
`; };
+ box.innerHTML = list.length ? `${list.map(row).join('')}
`
+ : `${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}
`;
+ const dirs = v.items.filter(x => x.type === 'dir').length, files = v.items.length - dirs, size = v.items.reduce((a, x) => a + (x.size || 0), 0);
+ $('#fsum').textContent = v.items.length ? [dirs && dirs + (dirs === 1 ? ' folder' : ' folders'), files && files + (files === 1 ? ' file' : ' files'), size && fmtSize(size)].filter(Boolean).join(' · ') : '';
+ };
+ const redraw = () => { drawTop(); drawItems(); };
+
+ const toggle = (it, range) => {
+ const vis = visible().map(x => x.name);
+ if (range && v.last && vis.includes(v.last) && vis.includes(it.name)) { const a = vis.indexOf(v.last), b = vis.indexOf(it.name); vis.slice(Math.min(a, b), Math.max(a, b) + 1).forEach(n => v.sel.add(n)); }
+ else { v.sel.has(it.name) ? v.sel.delete(it.name) : v.sel.add(it.name); v.last = it.name; }
+ v.selecting = true; redraw();
+ };
+ const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
+ const openIt = it => it.type === 'dir' ? go(joinP(dir, it.name)) : openFile(repo, joinP(dir, it.name), it.name, dir);
+ const oneActions = it => {
+ const p = joinP(dir, it.name), d = it.type === 'dir', a = [{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, p, d) }];
+ if (!ro) a.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, p, it.name, dir, d) });
+ if (!d) a.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, p) });
+ if (!d) a.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, p, it.name, ro) });
+ return a;
+ };
+
+ 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: FORM }); v.reload(true); }
+ catch (e) { toast(e.message); }
+ };
+ const actions = {
+ all: () => { const vis = visible(); if (v.sel.size === vis.length) v.sel.clear(); else vis.forEach(x => v.sel.add(x.name)); redraw(); },
+ move: () => v.sel.size && moveSheet('move', repo, dir, selItems()),
+ copy: () => v.sel.size && moveSheet('copy', repo, dir, selItems()),
+ del: () => v.sel.size && deleteItems(repo, dir, selItems()),
+ more: () => { const it = selItems()[0]; if (it) actionSheet(it.name, '', oneActions(it)); },
+ done: () => { v.selecting = false; v.sel.clear(); redraw(); },
+ sel: () => { v.selecting = true; redraw(); },
+ refresh: () => v.reload(),
+ sort: () => { const c = sortCfg(); actionSheet('Sort by', 'Folders stay on top', SORTS.map(([k, l]) => ({ label: l + (c.k === k ? (c.d > 0 ? ' ↑' : ' ↓') : ''), icon: 'sort',
+ run: () => { store.set('files_sort', { k, d: c.k === k ? -c.d : 1 }); drawItems(); } }))); },
+ up: () => actionSheet('Upload', '', [{ label: 'Files', icon: 'up', run: () => $('#upf').click() }, { label: 'A folder', icon: 'newdir', run: () => $('#upd2').click() }]),
+ new: () => actionSheet('Create new', '', [
+ { label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir'), '', n => badName(n, v.items)) },
+ ...[['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); v.reload(true); if (x !== 'txt') openFile(repo, joinP(dir, name), name, dir); }
+ catch (e) { toast(e.message); }
+ }, '', n => badName(n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x, v.items)) })),
+ ]),
+ };
+ v.actions = actions; v.selItems = selItems; v.openIt = openIt; v.visible = visible; v.one = oneActions;
+ $('#ftop').onclick = e => { const b = e.target.closest('[data-a]'); if (b && !b.disabled && actions[b.dataset.a]) actions[b.dataset.a](); };
+ $('#ftop').onchange = e => {
+ const pick = e.target.id === 'upf' || e.target.id === 'upd2' ? [...e.target.files] : null; if (!pick) return;
+ enqueueUploads(repo, dir, pick.map(f => ({ file: f, rel: f.webkitRelativePath ? f.webkitRelativePath.split('/').slice(0, -1).join('/') : '' }))); e.target.value = '';
+ };
+ $('#ff').oninput = e => { v.filter = e.target.value; drawItems(); };
+ main.querySelector('.crumbs').onclick = e => { const b = e.target.closest('[data-go]'); if (b) go(b.dataset.go); };
+
+ // tap / click / ctrl-click / shift-click, right-click, and long-press on a touch screen
+ const box = $('#items'); let suppress = false, lastTouch = 0, lp = null;
+ box.onclick = e => {
+ if (suppress) { suppress = false; return; }
+ const r = e.target.closest('.fr'); if (!r) return; const it = byName(r.dataset.n); if (!it) return;
+ if (e.target.closest('[data-more]')) return itemSheet(it);
+ if (v.selecting || e.metaKey || e.ctrlKey || e.shiftKey) return toggle(it, e.shiftKey);
+ openIt(it);
+ };
+ box.oncontextmenu = e => {
+ const r = e.target.closest('.fr'); if (!r) return; e.preventDefault(); if (Date.now() - lastTouch < 1200) return;
+ const it = byName(r.dataset.n); if (!it) return;
+ if (v.selecting && v.sel.size > 1 && v.sel.has(it.name)) return actionSheet(v.sel.size + ' selected', '', [ ...(ro ? [] : [{ label: 'Move', icon: 'files', run: actions.move }]), { label: 'Copy', icon: 'copy', run: actions.copy }, ...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: actions.del }]) ]);
+ itemSheet(it);
+ };
+ box.onpointerdown = e => {
+ if (e.pointerType !== 'touch') return; lastTouch = Date.now();
+ const r = e.target.closest('.fr'); if (!r || v.selecting) return; const x0 = e.clientX, y0 = e.clientY;
+ const stop = () => { clearTimeout(lp); box.removeEventListener('pointermove', mv); };
+ const mv = m => { if (Math.abs(m.clientX - x0) + Math.abs(m.clientY - y0) > 10) stop(); };
+ box.addEventListener('pointermove', mv); box.addEventListener('pointerup', stop, { once: true }); box.addEventListener('pointercancel', stop, { once: true });
+ lp = setTimeout(() => { stop(); const it = byName(r.dataset.n); if (!it) return; suppress = true; v.sel.add(it.name); v.last = it.name; v.selecting = true; redraw(); try { navigator.vibrate && navigator.vibrate(12); } catch {} }, 450);
+ };
+ box.onmouseover = e => { const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (it && it.type !== 'dir' && !ro) prefetchDl(repo, joinP(dir, it.name)); };
+
+ // Drag: out to the desktop (one file, Chrome/Edge), onto a folder or breadcrumb to move (Alt/Option copies), in from the computer to upload.
+ box.ondragstart = e => {
+ const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (!it) return;
+ const names = v.sel.has(it.name) && v.sel.size ? [...v.sel] : [it.name];
+ e.dataTransfer.setData('application/x-hub-items', JSON.stringify({ repo, dir, names })); e.dataTransfer.effectAllowed = 'copyMove';
+ const c = it.type !== 'dir' && names.length === 1 && dlUrls.get(repo + joinP(dir, it.name));
+ if (c && c.url) e.dataTransfer.setData('DownloadURL', `application/octet-stream:${it.name.replace(/:/g, '-')}:${c.url}`);
+ else if (it.type !== 'dir') prefetchDl(repo, joinP(dir, it.name));
+ };
+ const target = e => { const t = e.target.closest('.fr, [data-go]'); if (!t) return null;
+ if (t.dataset.go) return { el: t, path: t.dataset.go }; const it = byName(t.dataset.n); return it && it.type === 'dir' ? { el: t, path: joinP(dir, it.name) } : null; };
+ const clearDrop = () => main.querySelectorAll('.dropt').forEach(x => x.classList.remove('dropt')), isFiles = e => [...(e.dataTransfer.types || [])].includes('Files');
+ main.ondragover = e => {
+ const mine = [...(e.dataTransfer.types || [])].includes('application/x-hub-items'); if (!mine && !isFiles(e) || ro) return;
+ e.preventDefault(); clearDrop(); const t = target(e); if (t) t.el.classList.add('dropt'); else if (isFiles(e)) main.classList.add('dropt'); e.dataTransfer.dropEffect = mine && e.altKey ? 'copy' : mine ? 'move' : 'copy';
+ };
+ main.ondragleave = e => { if (!main.contains(e.relatedTarget)) { clearDrop(); main.classList.remove('dropt'); } };
+ main.ondrop = async e => {
+ if (ro) return; e.preventDefault(); clearDrop(); main.classList.remove('dropt');
+ const t = target(e), mine = e.dataTransfer.getData('application/x-hub-items');
+ if (mine) {
+ const { names } = JSON.parse(mine); if (!t || t.path === dir || names.some(n => joinP(dir, n) === t.path)) return;
+ try { await transferItems(e.altKey ? 'copy' : 'move', repo, dir, names, { repo, dir: t.path }); toast((e.altKey ? 'Copied' : 'Moved') + (names.length > 1 ? ' ' + names.length + ' items' : '')); } catch (err) { toast(err.message); }
+ return afterChange(repo, dir);
+ }
+ if (isFiles(e)) { const files = await droppedFiles(e.dataTransfer); enqueueUploads(repo, t ? t.path : dir, files); }
+ };
+
+ v.reload = async quiet => {
+ const tok = v.tok = Symbol(); if (!quiet) $('#items').innerHTML = 'Loading…
';
+ try {
+ const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
+ if (v.tok !== tok || !document.contains(main)) return;
+ v.items = items; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n);
+ redraw();
+ } catch (e) {
+ if (v.tok !== tok || !document.contains(main)) return;
+ if (!sf.token()) return route();
+ $('#items').innerHTML = `${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}
Try again
`;
+ $('#fretry').onclick = () => v.reload();
+ }
+ };
+ drawTop(); v.reload();
+}
+
+const joinP = (d, n) => (d === '/' ? '' : d) + '/' + n;
+// Keep the folder fresh when you come back to the tab, and add Finder-style keys.
+document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && fv && document.contains(fv.root) && !upActive() && !document.getElementById('sheet')) fv.reload(true); });
+addEventListener('keydown', e => {
+ if (!fv || !document.contains(fv.root) || document.getElementById('sheet') || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) return;
+ const mod = e.metaKey || e.ctrlKey, n = fv.sel.size, one = n === 1 ? fv.selItems()[0] : null, a = fv.actions;
+ if (e.key === 'Escape' && (n || fv.selecting)) { a.done(); }
+ else if (mod && e.key.toLowerCase() === 'a') { e.preventDefault(); fv.selecting = true; fv.visible().forEach(x => fv.sel.add(x.name)); a.sel(); }
+ else if ((e.key === 'Delete' || e.key === 'Backspace') && n) { e.preventDefault(); a.del(); }
+ else if (e.key === 'F2' && one) { e.preventDefault(); const p = joinP(fv.dir, one.name); renameItem(fv.repo, p, one.name, fv.dir, one.type === 'dir'); }
+ else if (e.key === 'Enter' && one) fv.openIt(one);
+});
diff --git a/public/index.html b/public/index.html
index a9d50a4..b5dd841 100644
--- a/public/index.html
+++ b/public/index.html
@@ -60,6 +60,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+