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:
1 parent
bc3710807d
commit
de1430928a
6 files changed
+468
-120
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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; } }
|
||||
@@ -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:''}};
|
||||
|
||||
@@ -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; };
|
||||
|
||||
Reference in new issue
Block a user