Files like Finder, richer mini player, in-app Back/Forward, Audio tab renamed Audiobooks

- Files: multi-select, batch move/copy/delete, rename checks, sort, filter, shortcuts, drag in (files and folders) to upload with progress and retries, drag to move, drag a file out to download
- Mini player: previous, seek bar with buffered range, time, shuffle/repeat, buffering spinner, swipe for next/previous, close
- Back/Forward bar that knows what history is possible
- Audio tab is now Audiobooks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 08:37:58 -04:00
1 parent bc3710807d
commit de1430928a
6 files changed
+468 -120

No files matched your search

+9 -4
View File
@@ -24,12 +24,17 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
Office files open in Seafile's own OnlyOffice integration.
- **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile.
- **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume).
- **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audio, Podcast, Podcast Admin, Mail,
- **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audiobooks, Podcast, Podcast Admin, Mail,
Calendar, Tasks, Search; Home and Settings are always on), and give them a one-time invite code to create their own passkey. Everyone's tasks, NE Hub events,
settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users/<id>/`). Limits are enforced by the server, not just hidden.
You can change a person's tabs, sign them out everywhere, turn them off, or delete them and their data. An existing single-user install becomes the admin
automatically on first start (the original files are left in place as a backup).
- **Files**: also rename, move, share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation.
- **Files**: a Finder/Explorer-style browser. Tap **Select** (or long-press on a phone, Cmd/Ctrl-click or Shift-click on a computer) to pick several items and
Move, Copy (to any folder in any library, with a New folder button in the picker) or Delete them together. Rename checks for clashes, sort by name/date/size/kind
(folders stay on top), filter a folder, right-click for the item menu, and keys: Ctrl/Cmd-A, Delete, F2, Enter, Esc. **Drag files or whole folders from your computer
onto the page** (or onto a folder in the list) to upload; uploads show progress, retry on a bad connection, and can be cancelled. **Drag items onto a folder or a
breadcrumb** to move them (hold Alt/Option to copy). **Drag a single file out to your desktop** to download it (Chrome and Edge only; browsers cannot drag out folders
or several files). Also share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation.
- **Calendar**: month grid and agenda. Add, edit and delete events:
- **NE Hub calendar**: events you create are stored in `/data/events.json` and published as a private subscribe link
(Manage calendars -> copy link) so Outlook, Google or Apple Calendar can show them (read-only there).
@@ -42,9 +47,9 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser.
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the queue (play next / add to queue) and position are remembered.
- **Audio**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons.
- **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons.
Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks
the person may use the Audio tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET`
(and optionally `AUDIOBOOKS_SSO_URL`, default `<AUDIOBOOKS_URL>/sso`) for short-lived signed links: `?token=<body>.<sig>`, where `body` is base64url JSON
`{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied.
+413 -111
View File
@@ -62,6 +62,7 @@ const ICONS = {
file: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5"/>',
dl: '<path d="M12 4v12M7 11l5 5 5-5M5 20h14"/>',
audio: '<path d="M4 14v-2a8 8 0 0116 0v2"/><rect x="3" y="14" width="4" height="7" rx="1.5"/><rect x="17" y="14" width="4" height="7" rx="1.5"/>',
sort: '<path d="M7 4v16M4 17l3 3 3-3M17 20V4M14 7l3-3 3 3"/>',
refresh: '<path d="M20 12a8 8 0 11-2.3-5.7M20 4v5h-5"/>',
full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
ext: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"/>',
@@ -70,6 +71,8 @@ const ICONS = {
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
spin: '<path d="M12 3a9 9 0 109 9" class="spin"/>',
x: '<path d="M6 6l12 12M18 6L6 18"/>',
prev: '<path d="M18 5L8 12l10 7zM6 5v14"/>',
repeat: '<path d="M4 11V9a3 3 0 013-3h12M16 3l3 3-3 3M20 13v2a3 3 0 01-3 3H5M8 21l-3-3 3-3"/>',
shuffle: '<path d="M3 7h3c4 0 5 10 9 10h6M3 17h3c1.5 0 2.5-1.5 3.5-3M15 7h6M18 4l3 3-3 3M18 14l3 3-3 3"/>',
@@ -136,7 +139,7 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' };
const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
const phone = matchMedia('(max-width: 767px)');
@@ -164,7 +167,31 @@ function openMore() {
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
let editor;
// In-app Back / Forward (an installed app has no browser buttons). Each history entry remembers its position so we know what is possible.
let navIdx = 0, navMax = 0, navStarted = false;
const parentHash = () => { const p = location.hash.split('/'); return p.length > 3 ? p.slice(0, -1).join('/') : ''; }; // e.g. #/files/lib/a/b -> #/files/lib/a
function trackNav() {
const s = history.state;
if (s && s.i != null) navIdx = s.i;
else { navIdx = navStarted ? navIdx + 1 : 0; navMax = Math.max(navMax, navIdx); if (navStarted) navMax = navIdx; try { history.replaceState({ ...(s || {}), i: navIdx }, ''); } catch {} }
navStarted = true; navMax = Math.max(navMax, navIdx);
}
function drawNav() {
const el = $('#navbar'); if (!el) return;
const canBack = navIdx > 0 || !!parentHash(), canFwd = navIdx < navMax;
if (!el.dataset.on) {
el.dataset.on = 1;
el.innerHTML = `<button class="ibtn" id="nvb" aria-label="Back" title="Back">${icon('back')}</button><button class="ibtn" id="nvf" aria-label="Forward" title="Forward">${icon('chev')}</button><span id="nvt"></span>`;
$('#nvb').onclick = () => { if (navIdx > 0) history.back(); else if (parentHash()) location.hash = parentHash(); };
$('#nvf').onclick = () => history.forward();
}
$('#nvb').disabled = !canBack; $('#nvf').disabled = !canFwd;
const t = TABS.find(x => x[0] === activeTab); $('#nvt').textContent = t ? t[1] : '';
el.hidden = false;
}
addEventListener('popstate', e => { if (e.state && e.state.i != null) { navIdx = e.state.i; drawNav(); } });
function route() {
trackNav();
const [, id = 'home', ...rest] = location.hash.split('/');
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
@@ -174,6 +201,7 @@ function route() {
document.body.classList.remove('inedit', 'onaudio');
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
renderNav(name === 'edit' ? 'files' : name);
drawNav();
routes[name](rest.map(decodeURIComponent));
$('#view').scrollTop = 0;
}
@@ -201,7 +229,7 @@ function viewHome() {
${allowed('tasks') ? tile('#/tasks', '#4f7cff', 'check', 'Tasks', 'To-do lists') : ''}
${allowed('calendar') ? tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda') : ''}
${allowed('office') ? tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice') : ''}
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks') : ''}
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen') : ''}
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts') : ''}
@@ -748,7 +776,7 @@ async function officeLocation(ask) {
}
// Pick a library and folder in Seafile (same structure as the Files tab). Resolves null if cancelled.
function chooseFolder(title, start) {
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, '', '<div id="cf"></div>');
@@ -763,14 +791,23 @@ function chooseFolder(title, start) {
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button></div>`;
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; repoName = repos.find(r => r.id === repo).name; dir = '/'; draw(); });
} else {
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).filter(x => x.type === 'dir').sort((a, b) => a.name.localeCompare(b.name));
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 = `<p class="sub" style="word-break:break-all">${esc(locLabel({ repoName, dir }))}</p><div class="list" style="background:var(--panel2);border:0;max-height:45dvh;overflow:auto">
<button class="row" data-up="1">${icon('back')}<span class="grow"><b>${dir === '/' ? 'All libraries' : 'Up'}</b></span></button>
${subs.map(x => `<button class="row" data-d="${esc(x.name)}">${icon('files')}<span class="grow"><b>${esc(x.name)}</b></span>${icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="cs" style="flex:1;justify-content:center">Save here</button></div>`;
<div class="bar"><button class="btn ghost" id="cx" style="flex:1;justify-content:center">Cancel</button>${opts.newFolder ? `<button class="btn ghost" id="cn" aria-label="New folder">${icon('newdir')}</button>` : ''}<button class="btn" id="cs" style="flex:1;justify-content:center" ${blocked ? 'disabled' : ''}>${esc(opts.okLabel || 'Save here')}</button></div>`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = joinP(dir, b.dataset.d); draw(); });
box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); };
box.querySelector('#cs').onclick = () => finish({ repo, repoName, dir });
const cn = box.querySelector('#cn'); if (cn) cn.onclick = () => {
cn.disabled = true; const f = document.createElement('form'); f.style.marginTop = '10px';
f.innerHTML = '<input type="text" id="cmn" placeholder="Folder name" autocomplete="off" autocapitalize="off" required><div class="err" id="cme" style="margin-top:6px"></div>';
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 = `<p class="err">${esc(e.message)}</p>`; }
@@ -1052,9 +1089,9 @@ function setShuffle(on) {
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();
pl.shuffle = on; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); saveState(); tcKick(); updateNow(); modeUI();
}
function setRepeat(r) { pl.repeat = r; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); updateNow(); }
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 });
@@ -1202,20 +1239,47 @@ function markPlaying() {
function renderMini() {
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
el.hidden = false;
el.innerHTML = `<div class="mini"><div class="bar2"><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button>
<button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button></div>`;
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mo').onclick = openNow;
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
<button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
$('#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;
// 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() {
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(); closeSheet(); markPlaying();
}
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
function modeUI() {
document.querySelectorAll('#ms, #nps').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
document.querySelectorAll('#mr, #npr').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 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 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 !== String(a.paused)) { nt.innerHTML = ic; nt.dataset.s = a.paused; }
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);
@@ -1226,9 +1290,7 @@ function updateNow() {
$('#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;
const sh = $('#nps'), rp = $('#npr');
if (sh) { sh.classList.toggle('on', pl.shuffle); sh.setAttribute('aria-pressed', pl.shuffle); }
if (rp) { rp.classList.toggle('on', pl.repeat !== 'off'); rp.dataset.r = pl.repeat === 'one' ? '1' : ''; rp.setAttribute('aria-label', 'Repeat: ' + pl.repeat); }
modeUI();
}
function openNow() {
const t = cur(); if (!t) return;
@@ -1332,7 +1394,7 @@ function viewSettings() {
<div class="card" style="max-width:none">${MAIL.filter(m => m.id !== 'icloud').map(m => `<label for="h-${m.id}">${esc(m.name)}</label>
<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}">`).join('')}
<p class="sub" style="margin-top:12px">iCloud Mail can't take a hint; use Keychain autofill or stay signed in.</p></div>
</div><div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.</p>
</div><div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.</p>
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
<label for="ab-p">Password</label>
@@ -1427,7 +1489,7 @@ function viewFiles([repo, ...parts]) {
const dir = '/' + parts.join('/');
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
<div class="libs" id="libs"><p class="empty">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
loadLibs(repo);
libsP = loadLibs(repo);
if (repo) loadDir(repo, dir);
}
@@ -1448,6 +1510,7 @@ function filesLogin(msg = '', needOtp = false) {
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 = `<div class="bar" style="margin-top:0"><button class="btn ghost" id="nl">${icon('newdir')}New library</button></div><h2 style="margin-top:8px">Libraries</h2><div class="list">${repos.map(r => `
<a class="row" href="#/files/${encodeURIComponent(r.id)}" ${r.id === active ? 'style="background:var(--panel2)"' : ''}>
<span class="ic" style="background:#2f9e6e">${icon('files')}</span>
@@ -1462,62 +1525,333 @@ async function loadLibs(active) {
} catch (e) { $('#libs').innerHTML = `<p class="err">${esc(e.message)}</p>`; if (!sf.token()) setTimeout(route, 800); }
}
async function loadDir(repo, dir) {
const main = $('#fmain');
// ---- 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 = `<div class="upm"><b>Uploading ${esc(live.length === 1 ? cur.file.name : live.length + ' files')}</b><small>${pct}%</small><i style="width:${pct}%"></i></div><button class="btn ghost" id="upx">Cancel</button>`;
$('#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 = `<div class="upm"><b>${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed</b><small>${esc(bad[0].err || '')}</small></div><button class="btn" id="upr">Retry</button><button class="btn ghost" id="upd">Dismiss</button>`;
$('#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 = '<p class="empty">Loading…</p>';
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 = [`<button onclick="location.hash='#/files/${enc(repo)}'">Library</button>`].concat(segs.map((s, i) =>
`<span>/</span><button onclick="location.hash='${hash('/' + segs.slice(0, i + 1).join('/'))}'">${esc(s)}</button>`)).join('');
main.innerHTML = `<div class="bar">
<a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a>
<label class="btn ghost" style="margin:0;color:var(--text)">${icon('up')}Upload<input id="up" type="file" multiple hidden></label>
<button class="btn ghost" id="mk">${icon('newdir')}New…</button></div>
<div class="crumbs">${crumbs}</div><div id="items"><p class="empty">Loading…</p></div>`;
const crumbs = `<button data-go="/">${esc(libNames[repo] || 'Library')}</button>` + segs.map((s, i) => `<span>/</span><button data-go="/${esc(segs.slice(0, i + 1).join('/'))}">${esc(s)}</button>`).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 = `<div id="ftop"></div><div class="crumbs">${crumbs}</div>
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
const go = p => { location.hash = hash(p); };
const drawTop = () => {
const n = v.sel.size, one = n === 1 ? selItems()[0] : null;
$('#ftop').innerHTML = v.selecting
? `<div class="selbar"><div class="bar"><b class="selcount">${n ? n + ' selected' : 'Select items'}</b>
<button class="btn ghost" data-a="all">${n && n === visible().length ? 'None' : 'All'}</button><button class="btn" data-a="done">Done</button></div>
<div class="bar">${ro ? '' : `<button class="btn ghost" data-a="move" ${n ? '' : 'disabled'}>${icon('files')}Move</button>`}
<button class="btn ghost" data-a="copy" ${n ? '' : 'disabled'}>${icon('copy')}Copy</button>
${ro ? '' : `<button class="btn ghost" data-a="del" ${n ? '' : 'disabled'} style="color:var(--danger)">${icon('trash')}Delete</button>`}
<button class="btn ghost" data-a="more" aria-label="More" ${one ? '' : 'disabled'}>${icon('more')}</button></div></div>`
: `<div class="bar"><a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a>
${ro ? '' : `<button class="btn ghost" data-a="up">${icon('up')}Upload</button><button class="btn ghost" data-a="new">${icon('newdir')}New…</button>`}
<button class="btn ghost" data-a="sel">${icon('check')}Select</button>
<button class="btn ghost" data-a="sort" aria-label="Sort">${icon('sort')}</button>
<button class="btn ghost" data-a="refresh" aria-label="Refresh">${icon('refresh')}</button>
<input id="upf" type="file" multiple hidden><input id="upd2" type="file" webkitdirectory hidden></div>`;
};
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 `<div class="row it fr${on ? ' sel' : ''}" data-n="${esc(it.name)}" ${ro ? '' : 'draggable="true"'}>
${v.selecting ? `<span class="ck${on ? ' on' : ''}" aria-hidden="true">${on ? icon('check') : ''}</span>` : ''}
<button class="rmain" aria-label="${d ? 'Open folder ' : 'Open '}${esc(it.name)}"><span class="ic" style="background:${d ? '#e0a82b' : OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon(d ? 'files' : 'file')}</span>
<span class="grow"><b>${esc(it.name)}</b><small>${d ? fmtDate(it.mtime) : fmtSize(it.size) + ' · ' + fmtDate(it.mtime)}</small></span></button>
${v.selecting ? '' : `<button class="ibtn" data-more aria-label="Actions for ${esc(it.name)}">${icon('more')}</button>`}</div>`; };
box.innerHTML = list.length ? `<div class="list">${list.map(row).join('')}</div>`
: `<p class="empty">${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}</p>`;
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) });
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: { 'Content-Type': 'application/x-www-form-urlencoded' } }); loadDir(repo, dir); }
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); }
};
$('#mk').onclick = () => actionSheet('Create new', '', [
{ label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir')) },
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);
loadDir(repo, dir);
if (x !== 'txt') openFile(repo, joinP(dir, name), name, dir);
} catch (e) { toast(e.message); }
}) })),
]);
$('#up').onchange = async e => {
const files = [...e.target.files]; if (!files.length) return;
try {
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
const u = new URL(link, cfg.seafile);
for (const f of files) {
toast('Uploading ' + f.name + '…');
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', f);
const r = await fetch('/sf' + u.pathname + '?ret-json=1', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload failed: ' + f.name);
}
toast('Uploaded'); loadDir(repo, dir);
} catch (err) { toast(err.message); }
try { const 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 = '<p class="empty">Loading…</p>';
try {
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
$('#items').innerHTML = items.length ? `<div class="list">${items.map((it, i) => it.type === 'dir'
? `<div class="row it"><a class="rmain" href="${hash(joinP(dir, it.name))}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(it.name)}</b><small>${fmtDate(it.mtime)}</small></span></a><button class="ibtn" data-i="${i}" aria-label="Actions for ${esc(it.name)}">${icon('more')}</button></div>`
: `<div class="row it"><button class="rmain" data-open="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(it.name)}</b><small>${fmtSize(it.size)} · ${fmtDate(it.mtime)}</small></span></button><button class="ibtn" data-i="${i}" aria-label="Actions for ${esc(it.name)}">${icon('more')}</button></div>`).join('')}</div>`
: '<p class="empty">This folder is empty</p>';
const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
$('#items').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => itemSheet(items[b.dataset.i]));
$('#items').querySelectorAll('button[data-open]').forEach(b => b.onclick = () => openFile(repo, joinP(dir, items[b.dataset.open].name), items[b.dataset.open].name, dir));
} catch (e) { $('#items').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
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 = `<p class="err">${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}</p><div class="bar"><button class="btn ghost" id="fretry">Try again</button></div>`;
$('#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) {
@@ -1542,12 +1876,17 @@ function actionSheet(title, sub, actions) {
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); actions[b.dataset.i].run(); });
}
function textSheet(title, label, okLabel, onOk, initial = '') {
function textSheet(title, label, okLabel, onOk, initial = '', validate) {
const el = openSheet(title, '', `<form><label>${esc(label)}</label><input type="text" id="tv" autocapitalize="off" autocomplete="off" required value="${esc(initial)}">
<div class="err" id="te" style="margin-top:6px"></div>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button>
<button class="btn" style="flex:1;justify-content:center">${esc(okLabel)}</button></div></form>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('form').onsubmit = e => { e.preventDefault(); const v = el.querySelector('#tv').value.trim(); if (v) { closeSheet(); onOk(v); } };
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);
}
@@ -1605,65 +1944,28 @@ async function shareLink(repo, path, isDir) {
} catch (e) { toast(e.message); }
}
function renameItem(repo, path, name, dir, isDir) {
textSheet('Rename', 'New name', 'Rename', async n => {
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${isDir ? 'dir' : 'file'}/?p=${encodeURIComponent(path)}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'rename', newname: n }) }); toast('Renamed'); loadDir(repo, dir); }
catch (e) { toast(e.message); }
}, name);
}
// Folder picker for Move (same library).
function moveItem(repo, name, dir) {
let at = dir;
const el = openSheet('Move to…', name, '<div id="pf"></div>');
const draw = async () => {
const box = el.querySelector('#pf');
box.innerHTML = '<p class="empty">Loading…</p>';
try {
const subs = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(at)}`)).filter(x => x.type === 'dir' && joinP(at, x.name) !== joinP(dir, name));
box.innerHTML = `<p class="sub" style="word-break:break-all">${esc(at)}</p><div class="list" style="background:var(--panel2);border:0;max-height:40dvh;overflow:auto">
${at !== '/' ? `<button class="row" data-up="1">${icon('back')}<span class="grow"><b>Up</b></span></button>` : ''}
${subs.map(x => `<button class="row" data-d="${esc(x.name)}">${icon('files')}<span class="grow"><b>${esc(x.name)}</b></span>${icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="mv" style="flex:1;justify-content:center"${at === dir ? ' disabled' : ''}>Move here</button></div>`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { at = joinP(at, b.dataset.d); draw(); });
const up = box.querySelector('[data-up]'); if (up) up.onclick = () => { at = at.slice(0, at.lastIndexOf('/')) || '/'; draw(); };
box.querySelector('#sx').onclick = closeSheet;
box.querySelector('#mv').onclick = async () => {
try { await sf.req('/api/v2.1/repos/sync-batch-move-item/', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ src_repo_id: repo, src_parent_dir: dir, dst_repo_id: repo, dst_parent_dir: at, src_dirents: [name] }) });
closeSheet(); toast('Moved'); loadDir(repo, dir); } catch (e) { toast(e.message); }
};
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
draw();
}
function fileSheet(repo, path, name, dir) {
const enc = encodeURIComponent, acts = [];
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))) 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 (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: '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); } }) });
acts.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, path) });
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) },
{ 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); } }) },
...(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]) }]),
]);
}
+1 -1
View File
@@ -49,7 +49,7 @@
<body>
<div id="shell">
<nav id="nav" aria-label="Main"></nav>
<div id="main"><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
<div id="main"><div id="navbar" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
</div>
<div id="toast" role="status"></div>
<script src="/webauthn.js"></script>
+43 -2
View File
@@ -169,8 +169,19 @@ input[type=search] { width: 100%; min-height: 44px; padding: 0 14px; border-radi
.ibtn:active { background: var(--panel2); }
.ibtn.big { width: 64px; height: 64px; background: var(--accent); color: var(--accent-t); }
.ibtn.big svg { width: 30px; height: 30px; }
.mini .bar2 { position: absolute; left: 0; right: 0; top: 0; height: 2px; background: var(--line); }
.mini .bar2 i { display: block; height: 100%; background: var(--accent); width: 0; }
.mini .bar2 { position: absolute; left: 0; right: 0; top: 0; height: 4px; background: var(--line); cursor: pointer; touch-action: none; }
.mini .bar2::before { content: ''; position: absolute; left: 0; right: 0; top: -10px; bottom: -8px; } /* a taller area to grab */
.mini .bar2 u { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--muted); opacity: .45; }
.mini .bar2 i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); width: 0; }
.mini .mtime { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; display: none; }
.mini .wide-only { display: none; }
.mini .ibtn.on { color: var(--accent); }
.mini .ibtn { position: relative; flex: none; }
.mini .ibtn[data-r="1"]::after { content: '1'; position: absolute; font-size: 10px; font-weight: 700; right: 8px; bottom: 6px; }
.mini { touch-action: pan-y; }
@media (min-width: 768px) { .mini .mtime { display: block; } .mini .wide-only { display: grid; } .mini .bar2 { height: 5px; } }
svg .spin, .spin { transform-origin: 12px 12px; animation: sp .8s linear infinite; }
@keyframes sp { to { transform: rotate(360deg); } }
.np { text-align: center; }
.np .art { width: min(72vw, 300px); aspect-ratio: 1; border-radius: 16px; object-fit: cover; background: var(--panel2); margin: 8px auto 16px; display: block; }
.np input[type=range] { width: 100%; accent-color: var(--accent); }
@@ -260,3 +271,33 @@ input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user
.np .ibtn.on { color: var(--accent); }
.np .ibtn[data-r="1"]::after { content: '1'; position: absolute; font-size: 10px; font-weight: 700; margin: 0 0 -14px 14px; }
.np .ibtn { position: relative; }
/* Files: selection, drag and drop, upload progress */
.selbar { position: sticky; top: 0; z-index: 3; background: var(--bg); padding-bottom: 4px; }
.selbar .bar { margin: 0 0 8px; align-items: center; }
.selbar .selcount { margin-right: auto; }
.selbar .bar { gap: 6px; }
.selbar .btn { padding-left: 10px; padding-right: 10px; }
.row.fr { cursor: default; }
.row.fr.sel { background: var(--panel2); }
.row .ck { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--line); display: grid; place-items: center; flex: none; margin-left: 4px; }
.row .ck.on { background: var(--accent); border-color: var(--accent); color: var(--accent-t); }
.row .ck svg { width: 14px; height: 14px; }
.dropt { outline: 2px dashed var(--accent); outline-offset: -2px; background: var(--panel2); }
#fmain.dropt { border-radius: 16px; }
#upbar { position: fixed; z-index: 15; left: 50%; transform: translateX(-50%); top: calc(var(--sat) + 10px); width: min(520px, calc(100vw - 24px)); display: flex; align-items: center; gap: 8px;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 10px 12px; box-shadow: 0 8px 28px rgba(0,0,0,.25); }
#upbar .upm { flex: 1; min-width: 0; position: relative; padding-bottom: 6px; }
#upbar .upm b, #upbar .upm small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#upbar .upm small { color: var(--muted); }
#upbar .upm i { position: absolute; left: 0; bottom: 0; height: 3px; border-radius: 2px; background: var(--accent); transition: width .2s; }
#fmain { min-height: 240px; }
/* Back / Forward bar */
#navbar { display: flex; align-items: center; gap: 2px; padding: calc(var(--sat) + 4px) calc(var(--sar) + 8px) 0 calc(var(--sal) + 8px); }
#navbar[hidden], body.editing #navbar { display: none; }
#navbar .ibtn { width: 40px; height: 40px; }
#navbar .ibtn:disabled { opacity: .3; cursor: default; }
#navbar #nvt { color: var(--muted); font-size: 13px; margin-left: 6px; }
body:not(.editing) #view { padding-top: 8px; }
@media (min-width: 768px) { #navbar { padding-left: 24px; } body:not(.editing) #view { padding-top: 12px; } }
+1 -1
View File
@@ -7,7 +7,7 @@ class FakeAudio{constructor(){this.paused=true;this.currentTime=0;this.duration=
addEventListener(e,f){(this._l[e]=this._l[e]||[]).push(f)} fire(e){(this._l[e]||[]).forEach(f=>f())}
play(){this.paused=false;this.fire('play');return Promise.resolve()} pause(){if(!this.paused){this.paused=true;this.fire('pause')}} getAttribute(){return this.src||null} load(){}}
const toasts=[];const store={d:{},get(k,d){return this.d[k]??d},set(k,v){this.d[k]=v},del(k){delete this.d[k]}};
const el=()=>({classList:{toggle(){},add(){}},setAttribute(){},dataset:{},style:{},querySelector:()=>null,querySelectorAll:()=>[],set innerHTML(v){},set textContent(v){}});
const el=()=>({classList:{toggle(){},add(){}},setAttribute(){},dataset:{},style:{},querySelector:()=>el(),querySelectorAll:()=>[],set innerHTML(v){},set textContent(v){}});
const ctx={Audio:FakeAudio,store,toast:m=>toasts.push(m),px:{art:()=>'x'},icon:()=>'',esc:s=>s,fmtDur:()=>'',$:()=>el(),document:{querySelectorAll:()=>[]},
navigator:{onLine:true},addEventListener:(e,f)=>{listeners[e]=f},setTimeout,clearTimeout,fetch:()=>new Promise(()=>{}),URL:{createObjectURL:()=>'blob:x',revokeObjectURL(){}},
Image:class{},MediaMetadata:class{},openSheet:()=>({}),closeSheet(){},actionSheet(){},Response,URLSearchParams,AbortController,Promise,Set,Map,Date,Math,saveStateHook:0,location:{hash:''}};
+1 -1
View File
@@ -13,7 +13,7 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'calendar', 'tasks', 'search'];
const ALWAYS = ['home', 'settings'];
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audio', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' };
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' };
let cache = null;
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };