'use strict';
// If the server says the passkey session is gone, go back to the sign-in page.
const _fetch = window.fetch.bind(window);
window.fetch = async (...a) => {
const r = await _fetch(...a);
if (r.status === 401 && r.headers.get('x-hub-auth') === 'required') location.replace('/login');
return r;
};
const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const store = {
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} },
del(k) { try { localStorage.removeItem(k); } catch {} },
};
const ICONS = {
home: ' ',
mail: ' ',
files: ' ',
doc: ' ',
cog: ' ',
chev: ' ',
back: ' ',
edit: ' ',
up: ' ',
newdir: ' ',
file: ' ',
dl: ' ',
audio: ' ',
copy: ' ',
music: ' ',
play: ' ',
pause: ' ',
next: ' ',
prev: ' ',
shuffle: ' ',
more: ' ',
cal: ' ',
search: ' ',
trash: ' ',
};
const icon = (n, cls = '') => `${ICONS[n]} `;
// ---- Email: providers block iframes and need OAuth apps for API access, so we deep-link to webmail.
const MAIL = [
{ id: 'gmail', name: 'Gmail', color: '#d9453a', inbox: 'https://mail.google.com/mail/u/0/#inbox', compose: 'https://mail.google.com/mail/?view=cm&fs=1' },
{ id: 'outlook', name: 'Outlook.com', color: '#0a64c8', inbox: 'https://outlook.live.com/mail/0/inbox', compose: 'https://outlook.live.com/mail/0/deeplink/compose' },
{ id: 'm365', name: 'Microsoft 365', color: '#d83b01', inbox: 'https://outlook.office.com/mail/inbox', compose: 'https://outlook.office.com/mail/deeplink/compose' },
{ id: 'icloud', name: 'iCloud Mail', color: '#3b97e8', inbox: 'https://www.icloud.com/mail', compose: 'https://www.icloud.com/mail' },
];
// Saved account addresses (browser-only, no passwords) are passed as sign-in hints so the provider opens the right mailbox.
const mailHints = () => store.get('mail_hints', {});
function mailUrl(m, kind) {
const base = m[kind], hint = (mailHints()[m.id] || '').trim();
if (!hint) return base;
const u = new URL(base), q = encodeURIComponent(hint);
if (m.id === 'gmail') { if (u.pathname.startsWith('/mail/u/')) u.pathname = '/mail/'; u.searchParams.set('authuser', hint); return u.toString(); }
if (m.id === 'outlook' || m.id === 'm365') { u.searchParams.set('login_hint', hint); return u.toString(); }
return base; // iCloud has no sign-in hint
}
let cfg = { audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
// ---- Seafile client (via same-origin proxy)
const sf = {
token: () => store.get('sf_token'),
async req(path, opts = {}) {
const headers = { Authorization: 'Token ' + sf.token(), ...(opts.headers || {}) };
const r = await fetch('/sf' + path, { ...opts, headers });
if (r.status === 401) { store.del('sf_token'); throw new Error('Session expired, sign in again'); }
if (!r.ok) throw new Error((await r.text().catch(() => '')).slice(0, 200) || 'Request failed (' + r.status + ')');
const t = await r.text();
try { return JSON.parse(t); } catch { return t; }
},
async login(username, password, otp) {
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (otp) headers['X-SEAFILE-OTP'] = otp;
const r = await fetch('/sf/api2/auth-token/', { method: 'POST', headers, body: new URLSearchParams({ username, password }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
if (r.headers.get('x-seafile-otp') === 'required') throw Object.assign(new Error('Enter your 2FA code'), { otp: true });
throw new Error((j.non_field_errors || [j.detail || j.error || 'Sign in failed']).join(' '));
}
store.set('sf_token', j.token);
},
};
const toast = (m) => { const t = $('#toast'); t.textContent = m; t.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('on'), 2600); };
const ext = n => (n.split('.').pop() || '').toLowerCase();
const EDIT_EXT = new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt']);
const OFFICE_EXT =new Set(['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp', 'txt', 'pdf']);
const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + ' KB' : b < 1073741824 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1073741824).toFixed(1) + ' GB';
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' };
const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
const phone = matchMedia('(max-width: 767px)');
let activeTab = 'home';
function renderNav(active = activeTab) {
activeTab = active;
const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label} `;
const brand = `
NE Hub `;
if (phone.matches) {
const more = !PRIMARY.includes(active);
$('#nav').innerHTML = TABS.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
`${icon('more')}More `;
$('#more').onclick = e => { e.preventDefault(); openMore(); };
} else $('#nav').innerHTML = brand + TABS.map(tab).join('');
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const el = openSheet('More', '', ``);
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
let editor;
function route() {
const [, id = 'home', ...rest] = location.hash.split('/');
const name = routes[id] ? id : 'home';
try { editor?.destroyEditor(); } catch {}
editor = null;
document.body.classList.toggle('editing', name === 'edit');
renderNav(name === 'edit' ? 'files' : name);
routes[name](rest.map(decodeURIComponent));
$('#view').scrollTop = 0;
}
function tile(href, color, ic, title, sub, external) {
return `
${icon(ic)} ${esc(title)} ${esc(sub)} `;
}
// ---- Views
function viewHome() {
const h = new Date().getHours();
const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
const recents = store.get('recents', []).slice(0, 5);
const t = typeof cur === 'function' ? cur() : null;
$('#view').innerHTML = `${greet}
${icon('search')}Search files, music, libraries
${t ? `
Now playing ${esc(t.title)} ${esc(t.artist)} ` : ''}
${recents.length ? `
Recent files ${recents.map((r, i) => `${icon('file')} ${esc(r.name)} ${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')} `).join('')}
` : ''}
Apps
${tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile')}
${tile('#/music', '#e5a00d', 'music', 'Music', 'Plex')}
${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')}
${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')}
${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')}
${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}
Mail
${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
`;
const np = $('#hnp'); if (np) np.onclick = openNow;
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
});
if (typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today');
}
// Name search by listing each library recursively (cached for the session).
const treeCache = new Map();
async function walkSearch(repos, term) {
const out = [];
for (const r of repos.filter(x => !x.encrypted).slice(0, 12)) {
try {
let t = treeCache.get(r.id);
if (!t || Date.now() - t.at > 120e3) { t = { at: Date.now(), items: await sf.req(`/api2/repos/${encodeURIComponent(r.id)}/dir/?p=/&recursive=1`) }; treeCache.set(r.id, t); }
for (const it of t.items) if (it.name.toLowerCase().includes(term)) {
const parent = (it.parent_dir || '/').replace(/\/?$/, '/');
out.push({ repo_id: r.id, repo_name: r.name, name: it.name, fullpath: parent + it.name, is_dir: it.type === 'dir', size: it.size });
if (out.length >= 40) return out;
}
} catch {}
}
return out;
}
// ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others)
function viewSearch() {
$('#view').innerHTML = ``;
const q = $('#q'); q.focus();
let timer, seq = 0;
q.oninput = () => { clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
async function runSearch(term, mySeq) {
const res = $('#res');
if (term.length < 2) { res.innerHTML = ''; return; }
res.innerHTML = 'Searching…
';
const enc = encodeURIComponent;
const [files, libs, music] = await Promise.allSettled([
sf.token() ? sf.req(`/api2/search/?q=${enc(term)}&per_page=20`) : Promise.reject(new Error('signed out')),
sf.token() ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')),
jfetch('/api/plex/status').then(st => st.server ? px.get(`/hubs/search?query=${enc(term)}&limit=8`) : Promise.reject(new Error('no server'))),
]);
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const sec = (title, html) => html ? `${title} ${html}
` : '';
const lower = term.toLowerCase();
const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
`${icon('files')} ${esc(r.name)} Library `).join('') : '';
let found = files.status === 'fulfilled' ? files.value.results || [] : null;
if (!found && libs.status === 'fulfilled') found = await walkSearch(libs.value, lower); // free Seafile has no search index; scan names instead
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const fileHtml = found ? found.map(f => {
const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
return f.is_dir ? `${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 = '';
if (music.status === 'fulfilled') {
for (const hub of music.value.Hub || []) for (const m of hub.Metadata || []) {
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 note = !found && sf.token() ? 'Could not search Seafile files right now.
' : '';
res.innerHTML = sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + note || 'No results
';
if (!libHtml && !fileHtml && !musicHtml) res.innerHTML = 'No results
' + note;
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, d] = JSON.parse(b.dataset.f); openFile(r, p, n, d); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
}
}
// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
async function loadAgenda(days) {
const start = new Date(); start.setHours(0, 0, 0, 0);
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 fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const evRow = e => `${esc(e.title)}
${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}
`;
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'); if (!cals.length) return;
const { byDay } = await loadAgenda(1);
const list = byDay.get(ymd(new Date())) || [];
if (!document.body.contains(el)) return;
el.innerHTML = `Today ${list.length ? list.slice(0, 6).map(evRow).join('') : '
Nothing on the calendar today
'}
`;
} catch {}
}
async function viewCalendar() {
const range = store.get('cal_range', 7);
const v = $('#view');
v.innerHTML = `Calendar ${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `
${l} `).join('')}
Manage calendars
`;
v.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
$('#mc').onclick = manageCalendars;
try {
const cals = await jfetch('/api/cal/list');
if (!cals.length) { $('#ag').innerHTML = 'No calendars yet. Add a private calendar link to see your events here.
Add a calendar
'; $('#ac').onclick = addCalendar; return; }
const { byDay, errors } = await loadAgenda(range);
if (!location.hash.startsWith('#/calendar')) return;
const keys = [...byDay.keys()].sort();
$('#ag').innerHTML = (keys.map(k => `${esc(dayLabel(k))} ${byDay.get(k).map(evRow).join('')}
`).join('') || 'Nothing coming up
')
+ errors.map(e => `${esc(e)}
`).join('');
} catch (e) { $('#ag').innerHTML = `${esc(e.message)}
`; }
}
function addCalendar() {
const el = openSheet('Add a calendar', '', ``);
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 = await jfetch('/api/cal/list');
const el = openSheet('Calendars', '', `${cals.map(c => `
${esc(c.name)}
Remove
`).join('') || '
No calendars
'}
Add a calendar Close
`);
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#ac').onclick = addCalendar;
el.querySelectorAll('button[data-id]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); closeSheet(); viewCalendar(); });
}
function viewMail() {
$('#view').innerHTML = `Mail
Opens each provider's own webmail in a new tab. Nothing is stored here.
Inboxes
Compose `;
}
function viewOffice() {
const url = cfg.onlyoffice;
$('#view').innerHTML = `Office
OnlyOffice at ${esc(new URL(url).host)}. To edit a file from Seafile, open it in Files.
If the frame above is blank, your OnlyOffice server blocks embedding. Use the button, or allow this app's origin in its frame-ancestors / X-Frame-Options.
`;
}
// ---- 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: '' });
function viewAudio() {
const url = cfg.audiobooks, c = abCreds();
$('#view').innerHTML = `Audio
${esc(new URL(url).host)}
`;
const copy = async (v, what) => { try { await navigator.clipboard.writeText(v); toast(what + ' copied. Paste it into the sign-in box.'); } catch { toast('Could not copy'); } };
const cu = $('#cu'), cp = $('#cp');
if (cu) cu.onclick = () => copy(c.u, 'Username');
if (cp) cp.onclick = () => copy(c.p, 'Password');
}
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
const jfetch = async (path, opts) => {
const r = await fetch(path, opts); const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
return j;
};
const px = {
sec: 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==',
async section() {
if (px.sec) return px.sec;
const d = (await px.get('/library/sections')).Directory || [];
const m = d.find(x => x.type === 'artist');
if (!m) throw new Error('No music library found on this Plex server');
return (px.sec = m.key);
},
};
const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
const mapTrack = (t, mc) => {
const part = t.Media?.[0]?.Part?.[0];
return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '',
album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 };
};
async function viewMusic(args) {
const v = $('#view');
v.innerHTML = '';
try {
const st = await jfetch('/api/plex/status');
if (!location.hash.startsWith('#/music')) return;
if (!st.signedIn) return plexSignIn();
if (!st.server) return plexPickServer();
await musicRoute(args, st);
} catch (e) {
if (e.auth) return plexSignIn();
v.innerHTML = `Music ${esc(e.message)}
Change server
`;
$('#rs').onclick = plexPickServer;
}
}
function plexSignIn(msg = '') {
$('#view').innerHTML = `Music Play your Plex music library here.
You'll sign in on plex.tv in a new tab. This app never sees your Plex password.
Sign in with Plex
${esc(msg)}
`;
$('#pl').onclick = async () => {
const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
try {
const pin = await jfetch('/api/plex/pin', { method: 'POST' });
if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `Tap to open Plex sign-in `;
for (let i = 0; i < 150; i++) {
await new Promise(r => setTimeout(r, 2000));
if (!location.hash.startsWith('#/music')) return;
const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
if (r.ok) { try { w?.close(); } catch {} return route(); }
}
$('#pe').textContent = 'Timed out waiting for Plex. Try again.';
} catch (e) { $('#pe').textContent = e.message; }
};
}
async function plexPickServer() {
const v = $('#view');
v.innerHTML = 'Music Finding your Plex servers…
';
try {
const servers = await jfetch('/api/plex/servers');
if (!servers.length) { v.innerHTML = 'Music No Plex servers found on this account.
'; return; }
v.innerHTML = `Music Choose a server
${servers.map(s => `
${icon('music')}
${esc(s.name)} ${s.owned ? 'Yours' : 'Shared with you'} ${icon('chev', 'chev')} `).join('')}
`;
v.querySelectorAll('button.row').forEach(b => b.onclick = async () => {
b.disabled = true; $('#pe').textContent = '';
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.sec = null; route(); }
catch (e) { b.disabled = false; $('#pe').textContent = e.message; }
});
} catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = ``; }
}
async function musicRoute([kind = 'albums', key], st) {
const v = $('#view'), sec = await px.section();
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)}
${seg}
${html || '
Nothing here
'}
`;
$('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
};
const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); }
if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio');
return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); }
if (kind === 'artist') {
const mc = await px.get(`/library/metadata/${key}/children`);
v.innerHTML = `${esc(mc.title2 || mc.parentTitle || 'Artist')}
Albums ${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}
`;
return;
}
if (kind === 'album' || kind === 'playlist') {
const album = kind === 'album';
const [mc, meta] = await Promise.all([px.get(album ? `/library/metadata/${key}/children` : `/playlists/${key}/items`),
px.get(album ? `/library/metadata/${key}` : `/playlists/${key}`)]);
const info = (meta.Metadata || [])[0] || {};
const tracks = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean);
const title = info.title || mc.title2 || '', sub = album ? (info.parentTitle || mc.title1 || '') : tracks.length + ' tracks';
const thumb = info.thumb || info.composite || mc.thumb;
v.innerHTML = `
${icon('play')}Play ${icon('shuffle')}Shuffle
${tracks.map((t, i) => `
${i + 1}
${esc(t.title)} ${album ? '' : `${esc(t.artist)} `}${fmtDur(t.dur)}
${icon('more')}
`).join('') || '
No playable tracks
'}
`;
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m]));
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0);
markPlaying();
return;
}
location.hash = '#/music/albums';
}
// ---- Player: one element that lives outside the views, so music keeps playing as you move around.
const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; };
const pl = { q: [], i: -1, a: new Audio() };
const cur = () => pl.q[pl.i];
function playQueue(q, i = 0) { pl.q = q; pl.i = i; loadCur(); }
function loadCur() {
const t = cur(); if (!t) return;
pl.a.src = t.src; pl.a.play().catch(() => toast('Tap play to start'));
if ('mediaSession' in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
renderMini(); syncUI(); markPlaying(); saveState();
}
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); toast('Playing next'); };
const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); toast('Added to queue'); };
function trackSheet(t) {
actionSheet(t.title, t.artist, [
{ label: 'Play next', icon: 'next', run: () => playNextTrack(t) },
{ label: 'Add to queue', icon: 'files', run: () => queueTrack(t) },
{ label: 'Play now', icon: 'play', run: () => playQueue([t], 0) },
]);
}
// Restore the last queue and position on launch (paused, so nothing starts by itself).
function restorePlayer() {
const st = store.get('plex_state'); if (!st || !st.q || !st.q[st.i]) return;
pl.q = st.q; pl.i = st.i; pl.a.src = pl.q[pl.i].src;
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
renderMini(); syncUI();
}
function openQueue() {
const upcoming = pl.q.map((t, i) => ({ t, i }));
const el = openSheet('Up next', pl.q.length + ' tracks', `${upcoming.map(({ t, i }) => `
${i === pl.i ? '▶' : i + 1} ${esc(t.title)} ${esc(t.artist)}
${i === pl.i ? '' : `${icon('trash')} `}
`).join('')}
Close
`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(); closeSheet(); });
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); });
}
const next = () => { if (pl.i < pl.q.length - 1) { pl.i++; loadCur(); } else { pl.a.pause(); } };
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else { pl.i--; loadCur(); } };
const toggle = () => (pl.a.paused ? pl.a.play() : pl.a.pause());
pl.a.addEventListener('ended', next);
let lastSave = 0;
pl.a.addEventListener('timeupdate', () => { if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); } });
addEventListener('pagehide', saveState);
['play', 'pause', 'timeupdate', 'loadedmetadata'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
pl.a.addEventListener('error', () => toast('Could not play this track'));
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
}
function markPlaying() {
const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
}
function renderMini() {
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
el.hidden = false;
el.innerHTML = `
${esc(t.title)} ${esc(t.artist)}
${icon('next')} `;
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mo').onclick = openNow;
}
function syncUI() {
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
const ic = icon(a.paused ? 'play' : 'pause');
const mt = $('#mt'); if (mt && mt.dataset.s !== String(a.paused)) { mt.innerHTML = ic; mt.dataset.s = a.paused; }
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
const nt = $('#npt'); if (nt && nt.dataset.s !== String(a.paused)) { nt.innerHTML = ic; nt.dataset.s = a.paused; }
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
}
function openNow() {
const t = cur(); if (!t) return;
const el = openSheet('', '', `${esc(t.title)}
${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}
0:00 0:00
${icon('prev')}
${icon('next')}
Queue · ${pl.q.length - pl.i - 1} up next
`);
const sk = el.querySelector('#npk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#npq').onclick = openQueue;
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
syncUI();
}
function viewSettings() {
const signed = !!sf.token();
$('#view').innerHTML = `Settings
Passkeys
Add a passkey Sign out of NE Hub
Connections
Seafile ${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}
Plex Checking… Sign out
OnlyOffice ${esc(cfg.onlyoffice)}
${signed ? '
Sign out of Seafile
' : ''}
Mail accounts Your address for each account, so its inbox and new-message buttons open the right mailbox. Stored only in this browser. Passwords are never saved here.
${MAIL.filter(m => m.id !== 'icloud').map(m => `
${esc(m.name)}
`).join('')}
iCloud Mail can't take a hint; use Keychain autofill or stay signed in.
Audiobooks sign-in Saved only in this browser, in plain text, so anyone with access to this device can read it. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.
Username
Password
Install iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.
`;
document.querySelectorAll('input[data-id]').forEach(i => i.oninput = () => {
const h = mailHints(); const v = i.value.trim(); if (v) h[i.dataset.id] = v; else delete h[i.dataset.id]; store.set('mail_hints', h);
});
const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
$('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb;
jfetch('/api/plex/status').then(st => {
const el = $('#plexst'); if (!el) return;
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server : 'Signed in · no server chosen') : 'Signed out';
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
const loadPks = async () => {
try {
const pks = await (await fetch('/api/auth/passkeys')).json();
$('#pks').innerHTML = pks.map(k => `${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); }
});
$('#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, ...parts]) {
const path = '/' + parts.join('/'), name = parts[parts.length - 1] || '';
const back = '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
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 (!location.hash.startsWith('#/edit/')) return;
$('#oomsg').remove();
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
editor = new DocsAPI.DocEditor('oo', conf);
} catch (e) { fail(e.message); }
}
// ---- Files
function viewFiles([repo, ...parts]) {
if (!sf.token()) return filesLogin();
const dir = '/' + parts.join('/');
$('#view').innerHTML = ``;
loadLibs(repo);
if (repo) loadDir(repo, dir);
}
function filesLogin(msg = '', needOtp = false) {
$('#view').innerHTML = `Files Sign in to ${esc(new URL(cfg.seafile).host)}
`;
$('#lf').onsubmit = async e => {
e.preventDefault();
const f = new FormData(e.target);
try { await sf.login(f.get('u'), f.get('p'), f.get('o')); route(); }
catch (err) { filesLogin(err.message, !!err.otp || needOtp); }
};
}
async function loadLibs(active) {
try {
const repos = (await sf.req('/api2/repos/')).sort((a, b) => a.name.localeCompare(b.name));
$('#libs').innerHTML = `${icon('newdir')}New library
Libraries `;
$('#nl').onclick = () => textSheet('New library', 'Library name', 'Create', async name => {
try { const r = await sf.req('/api2/repos/', { method: 'POST', body: new URLSearchParams({ name }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); location.hash = '#/files/' + encodeURIComponent(r.repo_id); }
catch (e) { toast(e.message); }
});
if (!active) return;
const r = repos.find(x => x.id === active);
if (r && r.encrypted) $('#fmain').innerHTML = 'This library is encrypted. Unlock it in the Seafile web UI first.
';
} catch (e) { $('#libs').innerHTML = `${esc(e.message)}
`; if (!sf.token()) setTimeout(route, 800); }
}
async function loadDir(repo, dir) {
const main = $('#fmain');
const enc = encodeURIComponent;
const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join('');
const segs = dir.split('/').filter(Boolean);
const crumbs = [`Library `].concat(segs.map((s, i) =>
`/ ${esc(s)} `)).join('');
main.innerHTML = `
${crumbs}
`;
const create = async (path, op, extra = {}, kind = 'file') => {
try { await sf.req(`/api2/repos/${enc(repo)}/${kind}/?p=${enc(path)}`, { method: 'POST', body: new URLSearchParams({ operation: op, ...extra }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); loadDir(repo, dir); }
catch (e) { toast(e.message); }
};
$('#mk').onclick = () => actionSheet('Create new', '', [
{ label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir')) },
...[['Document', 'docx'], ['Spreadsheet', 'xlsx'], ['Presentation', 'pptx'], ['Text file', 'txt']].map(([label, x]) => ({ label, icon: 'doc',
run: () => textSheet('New ' + label.toLowerCase(), 'File name', 'Create', async n => {
const name = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
await create(joinP(dir, name), 'create');
if (EDIT_EXT.has(x) && cfg.editor && x !== 'txt') openFile(repo, joinP(dir, name), name, dir);
}) })),
]);
$('#up').onchange = async e => {
const files = [...e.target.files]; if (!files.length) return;
try {
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
const u = new URL(link, cfg.seafile);
for (const f of files) {
toast('Uploading ' + f.name + '…');
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', f);
const r = await fetch('/sf' + u.pathname + '?ret-json=1', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload failed: ' + f.name);
}
toast('Uploaded'); loadDir(repo, dir);
} catch (err) { toast(err.message); }
};
try {
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
$('#items').innerHTML = items.length ? `${items.map((it, i) => it.type === 'dir'
? `
`
: `
${icon('file')} ${esc(it.name)} ${fmtSize(it.size)} · ${fmtDate(it.mtime)} ${icon('more')}
`).join('')}
`
: 'This folder is empty
';
const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
$('#items').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => itemSheet(items[b.dataset.i]));
$('#items').querySelectorAll('button[data-open]').forEach(b => b.onclick = () => openFile(repo, joinP(dir, items[b.dataset.open].name), items[b.dataset.open].name, dir));
} catch (e) { $('#items').innerHTML = `${esc(e.message)}
`; }
}
const joinP = (d, n) => (d === '/' ? '' : d) + '/' + n;
// ---- Bottom sheet (dialog on tablet/desktop). Body content is built by the caller from trusted/escaped HTML.
function openSheet(title, sub, bodyHtml) {
closeSheet();
const el = document.createElement('div');
el.id = 'sheet';
el.innerHTML = `
${esc(title)} ${sub ? `
${esc(sub)}
` : ''}${bodyHtml}
`;
document.body.appendChild(el);
el.querySelector('.scrim').onclick = closeSheet;
requestAnimationFrame(() => el.classList.add('on'));
return el;
}
function closeSheet() { document.getElementById('sheet')?.remove(); }
addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); });
function actionSheet(title, sub, actions) {
const el = openSheet(title, sub, `${actions.map((a, i) => `
${icon(a.icon)}${esc(a.label)} `).join('')}
Cancel
`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); actions[b.dataset.i].run(); });
}
function textSheet(title, label, okLabel, onOk, initial = '') {
const el = openSheet(title, '', ``);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('form').onsubmit = e => { e.preventDefault(); const v = el.querySelector('#tv').value.trim(); if (v) { closeSheet(); onOk(v); } };
setTimeout(() => { const t = el.querySelector('#tv'); t.focus(); const d = initial.lastIndexOf('.'); t.setSelectionRange(0, d > 0 ? d : initial.length); }, 50);
}
function confirmSheet(title, sub, okLabel, onOk) {
const el = openSheet(title, sub, `Cancel
${esc(okLabel)}
`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#so').onclick = () => { closeSheet(); onOk(); };
}
// ---- File actions
const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac' };
const previewKind = n => Object.keys(PREVIEW).find(k => PREVIEW[k].split(' ').includes(ext(n)));
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
const encP = p => p.split('/').map(encodeURIComponent).join('/');
const fileUrl = async (repo, path) => { const u = new URL(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), cfg.seafile); return '/sf' + u.pathname; };
// Recently opened files, shown on Home (kept in this browser only).
function addRecent(repo, path, name) {
const r = store.get('recents', []).filter(x => !(x.repo === repo && x.path === path));
r.unshift({ repo, path, name, t: Date.now() }); store.set('recents', r.slice(0, 12));
}
// Default tap on a file: edit documents, preview media, otherwise show the actions.
function openFile(repo, path, name, dir) {
if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').map(s => '/' + encodeURIComponent(s)).join(''); }
else if (previewKind(name)) previewFile(repo, path, name);
else fileSheet(repo, path, name, dir);
}
async function previewFile(repo, path, name) {
addRecent(repo, path, name);
try {
const url = (await fileUrl(repo, path)) + '?hubinline=1', kind = previewKind(name);
const media = kind === 'img' ? ` `
: kind === 'pdf' ? ``
: kind === 'video' ? ` `
: ` `;
const el = openSheet(name, '', `${media}`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
} catch (e) { toast(e.message); }
}
async function shareLink(repo, path, isDir) {
const p = isDir ? path.replace(/\/?$/, '/') : path;
try {
let link;
try { link = (await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORM, body: new URLSearchParams({ repo_id: repo, path: p }) })).link; }
catch { link = (await sf.req(`/api/v2.1/share-links/?repo_id=${encodeURIComponent(repo)}&path=${encodeURIComponent(p)}`))[0]?.link; }
if (!link) throw new Error('Could not create a share link');
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || '', `
${icon('copy')}Copy ${navigator.share ? 'Share… ' : ''}
`);
el.querySelector('#cp1').onclick = async () => { try { await navigator.clipboard.writeText(link); toast('Link copied'); closeSheet(); } catch { el.querySelector('#sl').select(); toast('Press copy on the selected link'); } };
const sh = el.querySelector('#sh1'); if (sh) sh.onclick = () => navigator.share({ url: link }).catch(() => {});
} catch (e) { toast(e.message); }
}
function renameItem(repo, path, name, dir, isDir) {
textSheet('Rename', 'New name', 'Rename', async n => {
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${isDir ? 'dir' : 'file'}/?p=${encodeURIComponent(path)}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'rename', newname: n }) }); toast('Renamed'); loadDir(repo, dir); }
catch (e) { toast(e.message); }
}, name);
}
// Folder picker for Move (same library).
function moveItem(repo, name, dir) {
let at = dir;
const el = openSheet('Move to…', name, '
');
const draw = async () => {
const box = el.querySelector('#pf');
box.innerHTML = 'Loading…
';
try {
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(at)}`)).filter(x => x.type === 'dir' && joinP(at, x.name) !== joinP(dir, name));
box.innerHTML = `${esc(at)}
${at !== '/' ? `${icon('back')}Up ` : ''}
${subs.map(x => `${icon('files')}${esc(x.name)} ${icon('chev', 'chev')} `).join('')}
Cancel Move here
`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { at = joinP(at, b.dataset.d); draw(); });
const up = box.querySelector('[data-up]'); if (up) up.onclick = () => { at = at.slice(0, at.lastIndexOf('/')) || '/'; draw(); };
box.querySelector('#sx').onclick = closeSheet;
box.querySelector('#mv').onclick = async () => {
try { await sf.req('/api/v2.1/repos/sync-batch-move-item/', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ src_repo_id: repo, src_parent_dir: dir, dst_repo_id: repo, dst_parent_dir: at, src_dirents: [name] }) });
closeSheet(); toast('Moved'); loadDir(repo, dir); } catch (e) { toast(e.message); }
};
} catch (e) { box.innerHTML = `${esc(e.message)}
`; }
};
draw();
}
function fileSheet(repo, path, name, dir) {
const enc = encodeURIComponent, acts = [];
if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) });
if (cfg.editor && EDIT_EXT.has(ext(name))) acts.push({ label: 'Edit here', icon: 'edit', run: () => { addRecent(repo, path, name); location.hash = '#/edit/' + enc(repo) + path.split('/').map(s => '/' + enc(s)).join(''); } });
if (OFFICE_EXT.has(ext(name))) acts.push({ label: 'Open in Seafile', icon: 'doc', run: () => window.open(`${cfg.seafile}/lib/${enc(repo)}/file${path.split('/').map(enc).join('/')}`, '_blank', 'noopener') });
acts.push({ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, false) });
acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
acts.push({ label: 'Move', icon: 'files', run: () => moveItem(repo, name, dir) });
acts.push({ label: 'Download', icon: 'dl', run: async () => {
try { const url = await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(path)}&reuse=1`); window.open(url, '_blank', 'noopener'); }
catch (e) { toast(e.message); } } });
acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () =>
confirmSheet('Delete file?', name, 'Delete', async () => {
try { await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(path)}`, { method: 'DELETE' }); toast('Deleted'); loadDir(repo, dir); }
catch (e) { toast(e.message); } }) });
actionSheet(name, '', acts);
}
function dirSheet(repo, path, name, dir) {
actionSheet(name, 'Folder', [
{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) },
{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) },
{ label: 'Move', icon: 'files', run: () => moveItem(repo, name, dir) },
{ label: 'Delete', icon: 'trash', danger: true, run: () => confirmSheet('Delete folder?', name + ' and everything in it', 'Delete', async () => {
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(path)}`, { method: 'DELETE' }); toast('Deleted'); loadDir(repo, dir); }
catch (e) { toast(e.message); } }) },
]);
}
// ---- Boot
(async () => {
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
addEventListener('hashchange', route);
restorePlayer();
route();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
})();