From 2bde697955e82687744839f371f49db5eb36be2e Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:18:48 -0400 Subject: [PATCH] Office: search, kind filter, sorting, pinned and recent documents, saved list, upload and drag-and-drop, document menu; Movies & TV mini player keeps its controls visible Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +- public/app.js | 2 +- public/index.html | 1 + public/office.js | 99 +++++++++++++++++++++++++++++++++++++++++++++++ public/palette.js | 1 + public/style.css | 34 ++++++++++++---- public/tasks.js | 40 ------------------- public/video.js | 20 +++++++--- settings.js | 2 +- 9 files changed, 147 insertions(+), 56 deletions(-) create mode 100644 public/office.js diff --git a/README.md b/README.md index e3d9149..a8c4a20 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ 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 Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as " with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal. -- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. **Mini player**: leave the player page (to any tab) and the video keeps playing in a small window at the bottom right, with play/pause, back to the full player and close; music and video pause each other. Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting. +- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. **Mini player**: leave the player page (to any tab) and the video keeps playing in a small window at the bottom right, with a seek bar, time, back and forward 10 s, play/pause, previous and next episode, back to the full player and close, always visible (like the music mini player); music and video pause each other. Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting. - **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 Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page, @@ -79,7 +79,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke - **File version history**: on any Seafile file, "Version history" lists earlier versions with who and when, and can download or restore one (restoring keeps the current version in the history). This uses Seafile's history API and has not been tried against a live Seafile server yet. - **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`. - **Podcast** opens `PODCAST_URL` (a home-network address) in its own tab. **Podcast Admin** is no longer a menu entry: it is a built-in link on the **Links** page (opens `VERSE_URL` in a new tab, cannot be edited or deleted) for people who have the Podcast Admin permission in Settings -> People; the old `#/verse` address goes to Links. -- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing. +- **Office**: make, find and open Word, Excel and PowerPoint files kept in Seafile (they open in OnlyOffice inside the app). New document / spreadsheet / presentation buttons, search, a filter by kind, sorting (newest, name, size), **Pinned** and **Recently opened** at the top, an Upload button and drop-files-on-the-page (with the same Replace / Keep both / Skip choice as Files), a three-dots menu on every document (share link, download, version history, rename, move, delete), and a saved copy of the list that shows at once while the real one loads (and stays if Seafile cannot be reached). Pins are saved with your settings and follow you across devices. The Ctrl/Cmd-K palette can create a new document, spreadsheet or presentation. - **Mail**: several mail accounts per person in one place: **Gmail**, **iCloud Mail** and (optionally) **Outlook.com** (personal). Folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB) and a From choice, reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab (all accounts added up) and a Home card with the latest unread across accounts. Each person adds their own accounts (Mail tab, or Settings -> Mail -> Add an account); everything is private to them and stored encrypted in their own folder. - **Gmail and iCloud** use IMAP/SMTP with an **app password**, so there is nothing to set up on the server. Gmail: turn on 2-Step Verification, then make an app password at myaccount.google.com/apppasswords. iCloud: appleid.apple.com -> Sign-In and Security -> App-Specific Passwords. The password is checked with the provider before it is saved, and wrong ones are explained in plain words. iCloud does not keep a copy of mail sent over SMTP, so NE Hub files one in Sent itself (not yet confirmed against a real iCloud account). - **Outlook.com** goes through Microsoft Graph (Outlook.com no longer allows IMAP passwords), which needs the one-time Microsoft app below; without it the Outlook.com button simply is not offered. diff --git a/public/app.js b/public/app.js index e7ae436..8cb5bce 100644 --- a/public/app.js +++ b/public/app.js @@ -10,7 +10,7 @@ const $ = s => document.querySelector(s); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different // device, or the separate storage of the iOS home-screen app. Only the music queue stays local. -const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']); +const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']); const outbox = new Map(); let outTimer = null, warned = false; function flushSettings() { outTimer = null; diff --git a/public/index.html b/public/index.html index 68830bf..038ba73 100644 --- a/public/index.html +++ b/public/index.html @@ -67,6 +67,7 @@ + diff --git a/public/office.js b/public/office.js new file mode 100644 index 0000000..13531e0 --- /dev/null +++ b/public/office.js @@ -0,0 +1,99 @@ +'use strict'; +// Office: make, find and open documents, spreadsheets and presentations kept in Seafile (they open in OnlyOffice inside the app). +// Search, a filter by kind, sorting, pinned and recently opened documents, a saved copy of the list that shows at once, and upload by button or by dropping files. +const OFFICE_KINDS = { + doc: { label: 'Documents', one: 'Document', color: '#3d6bf0', exts: ['docx', 'doc', 'odt', 'rtf'] }, + sheet: { label: 'Spreadsheets', one: 'Spreadsheet', color: '#2f9e6e', exts: ['xlsx', 'xls', 'ods', 'csv'] }, + slide: { label: 'Presentations', one: 'Presentation', color: '#e0742b', exts: ['pptx', 'ppt', 'odp'] }, +}; +const ofKind = n => Object.keys(OFFICE_KINDS).find(k => OFFICE_KINDS[k].exts.includes(ext(n))) || 'doc'; +const ofIsDoc = n => EDIT_EXT.has(ext(n)) && ext(n) !== 'txt'; +const ofState = { q: '', kind: 'all', sort: store.get('office_sort', 'mtime'), limit: 40, docs: [], loc: null }; +const ofKey = loc => 'oc:' + (ME ? ME.id : '') + ':' + loc.repo + ':' + loc.dir; +const ofPins = () => { const p = store.get('office_pins', []); return Array.isArray(p) ? p : []; }; +const ofId = (repo, path) => repo + '|' + path; +const ofPath = f => (f.parent_dir || '/').replace(/\/?$/, '/') + f.name; + +function ofOpen(repo, path, name) { + addRecent(repo, path, name); + if (cfg.editor) location.hash = officeHash(repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(repo)}/file${encP(path)}`, '_blank', 'noopener'); +} +function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `${icon('doc')}${esc(ext(name).slice(0, 4).toUpperCase())}`; } + +function viewOffice(args = []) { + if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true }); + const signed = !!sf.token(); + $('#view').innerHTML = `

Office

${signed ? `` : ''}${icon('files')}All files
+
${Object.entries(OFFICE_KINDS).map(([k, o]) => ``).join('')}
+ ${cfg.editor ? '' : '

Editing inside the app is switched off on this server, so documents open in Seafile instead.

'} +

${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}

+ +
${signed ? '' : ''}
`; + $('#view').querySelectorAll('.octile').forEach(t => t.onclick = () => newOfficeDoc(OFFICE_KINDS[t.dataset.k].exts[0], OFFICE_KINDS[t.dataset.k].one.toLowerCase())); + if (!signed) return; + $('#ofup').onclick = () => $('#ofupf').click(); + $('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); }; + const wrap = $('#ofwrap'); + wrap.ondragover = e => { if ([...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); wrap.classList.add('dropt'); } }; + wrap.ondragleave = e => { if (!wrap.contains(e.relatedTarget)) wrap.classList.remove('dropt'); }; + wrap.ondrop = async e => { e.preventDefault(); wrap.classList.remove('dropt'); const files = (await droppedFiles(e.dataTransfer)).filter(f => ofIsDoc(f.file.name)); if (files.length) ofUpload(files); else toast('Drop Word, Excel or PowerPoint files here'); }; + $('#oq').oninput = e => { ofState.q = e.target.value; ofState.limit = 40; ofDraw(); }; + $('#osort').value = ofState.sort; $('#osort').onchange = e => { ofState.sort = e.target.value; store.set('office_sort', ofState.sort); ofDraw(); }; + ofLoad(); +} + +async function ofUpload(entries) { + const loc = ofState.loc || await officeLocation(true); if (!loc) return; + await enqueueUploads(loc.repo, loc.dir, entries); + const t = setInterval(() => { if (!upActive()) { clearInterval(t); if (location.hash.startsWith('#/office') && $('#odocs')) ofLoad(true); } }, 1200); + setTimeout(() => clearInterval(t), 10 * 60e3); +} + +async function ofLoad(quiet) { + try { + const loc = await officeLocation(false), el = $('#olib'); if (!el) return; + if (!loc) { + el.textContent = 'Choose where your documents are saved in Seafile.'; $('#odocs').innerHTML = '
'; + $('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); return; + } + ofState.loc = loc; + el.innerHTML = `Saved in ${esc(locLabel(loc))} · change`; + $('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); ofState.docs = []; viewOffice(); } }); }; + let saved = null; try { saved = JSON.parse(localStorage.getItem(ofKey(loc))); } catch {} + if (!quiet && saved && saved.docs) { ofState.docs = saved.docs; ofDraw(); } else if (!quiet) ofDraw(true); + const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`); + if (!location.hash.startsWith('#/office') || !$('#odocs')) return; + ofState.docs = all.filter(f => f.type === 'file' && ofIsDoc(f.name)).map(f => ({ name: f.name, parent_dir: f.parent_dir || '/', mtime: f.mtime, size: f.size || 0 })); + try { localStorage.setItem(ofKey(loc), JSON.stringify({ t: Date.now(), docs: ofState.docs.slice(0, 400) })); } catch {} + ofDraw(); + } catch (e) { + const el = $('#olib'); if (!el) return; + if (ofState.docs.length) { el.innerHTML += ` · ${esc(navigator.onLine ? e.message : 'offline')} (showing the saved list)`; ofDraw(); } + else { el.innerHTML = `${esc(e.message)} Try again`; $('#ofretry').onclick = ev => { ev.preventDefault(); ofLoad(); }; } + } +} + +function ofDraw(skeleton) { + const box = $('#odocs'), loc = ofState.loc; if (!box || !loc) return; + $('#oflt').hidden = !ofState.docs.length; + const pins = new Set(ofPins()), q = ofState.q.trim().toLowerCase(), counts = { all: ofState.docs.length }; + for (const k of Object.keys(OFFICE_KINDS)) counts[k] = ofState.docs.filter(d => ofKind(d.name) === k).length; + $('#okinds').innerHTML = [['all', 'All']].concat(Object.entries(OFFICE_KINDS).map(([k, o]) => [k, o.label])).filter(([k]) => k === 'all' || counts[k]).map(([k, l]) => `${l}`).join(''); + $('#okinds').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); ofState.kind = a.dataset.k; ofState.limit = 40; ofDraw(); }); + const cmp = { mtime: (a, b) => b.mtime - a.mtime, name: (a, b) => collator.compare(a.name, b.name), size: (a, b) => b.size - a.size }[ofState.sort] || ((a, b) => b.mtime - a.mtime); + const match = d => (ofState.kind === 'all' || ofKind(d.name) === ofState.kind) && (!q || (d.name + ' ' + d.parent_dir).toLowerCase().includes(q)); + const list = ofState.docs.filter(match).sort(cmp), shown = list.slice(0, ofState.limit), pinned = shown.filter(d => pins.has(ofId(loc.repo, ofPath(d)))); + const recents = !q && ofState.kind === 'all' ? store.get('recents', []).filter(r => r.repo === loc.repo && ofIsDoc(r.name)).slice(0, 6) : []; + const row = d => { const path = ofPath(d), on = pins.has(ofId(loc.repo, path)), i = shown.indexOf(d); + return `
+
`; }; + box.innerHTML = skeleton ? '

Loading your documents…

' : (recents.length ? `

Recently opened

${recents.map((r, i) => ``).join('')}
` : '') + + (pinned.length && !q ? `

Pinned

${pinned.map(row).join('')}
` : '') + + (list.length ? `

${q ? 'Results' : ofState.kind === 'all' ? 'All documents' : OFFICE_KINDS[ofState.kind].label} ${list.length}

${shown.map(row).join('')}
${list.length > shown.length ? `
` : ''}` + : `

${ofState.docs.length ? 'Nothing matches.' : 'No documents here yet. Create one above, or drop files on this page.'}

`); + box.querySelectorAll('[data-o]').forEach(b => b.onclick = () => { const d = shown[+b.dataset.o]; if (d) ofOpen(loc.repo, ofPath(d), d.name); }); + box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const d = shown[+b.dataset.m]; if (d) fileSheet(loc.repo, ofPath(d), d.name, d.parent_dir === '/' ? '/' : d.parent_dir.replace(/\/$/, '')); }); + box.querySelectorAll('[data-pin]').forEach(b => b.onclick = () => { const d = shown[+b.dataset.pin]; if (!d) return; const id = ofId(loc.repo, ofPath(d)), cur = ofPins(); store.set('office_pins', cur.includes(id) ? cur.filter(x => x !== id) : [id, ...cur].slice(0, 50)); ofDraw(); }); + box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { const r = recents[+b.dataset.r]; if (r) ofOpen(r.repo, r.path, r.name); }); + const more = $('#ofmore'); if (more) more.onclick = () => { ofState.limit += 40; ofDraw(); }; +} diff --git a/public/palette.js b/public/palette.js index 8e2b3ca..c584980 100644 --- a/public/palette.js +++ b/public/palette.js @@ -56,6 +56,7 @@ function openPalette() { if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' }); if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' }); if (allowed('mail') && cfg.mail) out.push({ label: 'New email', hint: 'Create', ic: 'mail', run: () => { location.hash = '#/mail'; setTimeout(() => mailCompose(), 300); }, kw: 'compose write message' }); + if (allowed('office')) for (const [k, o] of Object.entries(OFFICE_KINDS)) out.push({ label: 'New ' + o.one.toLowerCase(), hint: 'Create', ic: 'doc', run: () => { location.hash = '#/office'; setTimeout(() => newOfficeDoc(o.exts[0], o.one.toLowerCase()), 250); }, kw: 'office word excel powerpoint ' + o.exts.join(' ') }); if (allowed('links')) out.push({ label: 'New link', hint: 'Create', ic: 'plus', run: () => linkSheet(), kw: 'add bookmark' }); out.push({ label: 'Hide or show the menu', hint: 'View', ic: 'menu', run: () => setNav({ hidden: !navPref().hidden }), kw: 'sidebar' }); out.push({ label: 'Refresh the app', hint: 'App', ic: 'refresh', run: () => location.reload(), kw: 'reload update' }); diff --git a/public/style.css b/public/style.css index e785245..12dc9e0 100644 --- a/public/style.css +++ b/public/style.css @@ -655,13 +655,33 @@ body.nav-mini .ngrp span { display: none; } @media (max-width: 700px) { .vtop .vtc { display: none; } .vnav .sgrp { margin-left: 0; } } /* Video mini player */ -#vmini { position: fixed; right: 14px; bottom: calc(var(--nav-h) + var(--sab) + 76px); width: 300px; max-width: calc(100vw - 28px); z-index: 45; border-radius: 12px; overflow: hidden; background: #000; box-shadow: 0 8px 28px rgba(0, 0, 0, .45); border: 1px solid var(--line); } +#vmini { position: fixed; right: 14px; bottom: calc(var(--nav-h) + var(--sab) + 76px); width: 340px; max-width: calc(100vw - 28px); z-index: 45; border-radius: 12px; overflow: hidden; background: #000; box-shadow: 0 8px 28px rgba(0, 0, 0, .45); border: 1px solid var(--line); } @media (min-width: 768px) { #vmini { bottom: 84px; } } #vmini[hidden] { display: none; } .vmslot { cursor: pointer; } .vmslot video { width: 100%; display: block; aspect-ratio: 16 / 9; background: #000; } -.vmover { position: absolute; left: 0; right: 0; top: 0; padding: 6px 8px; display: flex; align-items: center; gap: 8px; color: #fff; background: linear-gradient(rgba(0, 0, 0, .75), transparent); opacity: 0; transition: opacity .2s; } -#vmini:hover .vmover, #vmini:focus-within .vmover { opacity: 1; } -@media (hover: none) { .vmover { opacity: 1; } } -.vmtitle { flex: 1; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.vmbtns { display: flex; gap: 2px; } .vmbtns button { border: 0; background: rgba(255, 255, 255, .15); color: #fff; width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; cursor: pointer; } .vmbtns button svg { width: 18px; height: 18px; } -.vmprog { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255, 255, 255, .25); display: block; } .vmprog b { display: block; height: 100%; background: var(--accent); } + +/* Office */ +.ocreate { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-top: 4px; } +.octile { display: flex; align-items: center; gap: 12px; text-align: left; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--panel); min-width: 0; } +.octile:hover { border-color: var(--c, var(--accent)); } +.octile b, .octile small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .octile small { color: var(--muted); } +.octile > span:last-child { min-width: 0; } +.ofic { position: relative; } .ofic em { position: absolute; bottom: 2px; font-style: normal; font-size: 8px; font-weight: 700; letter-spacing: .02em; color: #fff; } +.ofic svg { margin-top: -4px; } +.ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; } +.ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; } +.orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; } +.octile.rc { padding: 10px 12px; } +.orow .rmain { min-width: 0; } +#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; } + +.vmctl { background: var(--panel); color: var(--text); padding: 6px 10px 8px; } +.vmhead { display: flex; align-items: center; gap: 4px; } +.vmtitle { flex: 1; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.vmctl input[type=range] { width: 100%; margin: 2px 0; accent-color: var(--accent); cursor: pointer; } +.vmrow { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; } +.vmmid { display: flex; align-items: center; gap: 2px; } +.vmb { border: 0; background: transparent; color: var(--text); min-width: 34px; height: 34px; border-radius: 8px; display: inline-grid; place-items: center; cursor: pointer; } +.vmb:hover { background: var(--panel2); } .vmb:disabled { opacity: .35; cursor: default; } .vmb[hidden] { display: none; } +.vmb svg { width: 20px; height: 20px; } .vmb.big { width: 40px; height: 40px; background: var(--accent); color: var(--accent-t); border-radius: 50%; } .vmb.big svg { width: 22px; height: 22px; } +.vmb .vskip em { font-size: 8px; top: 6px; } diff --git a/public/tasks.js b/public/tasks.js index 85241ab..b85bdc1 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -111,46 +111,6 @@ async function renderTasksCard(el) { const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join(''); -function viewOffice(args = []) { - if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true }); - $('#view').innerHTML = `

Office

Create, open and edit documents without leaving this tab. Everything is saved in Seafile.

-
- - - -
-

${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}

-
-
Browse all files${sf.token() ? '' : 'Sign in'}
`; - $('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l)); - if (!sf.token()) return; - (async () => { - try { - const loc = await officeLocation(false), el = $('#olib'); if (!el) return; - if (!loc) { - el.textContent = 'Choose where your documents are saved in Seafile.'; - $('#odocs').innerHTML = '
'; - $('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); - return; - } - el.innerHTML = `Saved in ${esc(locLabel(loc))} · change`; - $('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); }; - const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`); - const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60); - if (!location.hash.startsWith('#/office') || !$('#odocs')) return; - $('#odocs').innerHTML = docs.length ? `

Documents

${docs.map((f, i) => { - const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b'; - return ``; }).join('')}
` - : '

No documents here yet. Create one above.

'; - $('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => { - const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name; - addRecent(loc.repo, path, f.name); - if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener'); - }); - } catch (e) { const el = $('#olib'); if (el) el.innerHTML = `${esc(e.message)}`; } - })(); -} - const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / ')); // The saved Office location, if it still exists (the library may have been deleted). async function officeLocation(ask) { diff --git a/public/video.js b/public/video.js index fce130e..fd5f15a 100644 --- a/public/video.js +++ b/public/video.js @@ -220,7 +220,9 @@ function vdUi() { if (r) { if (!drag) r.value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; const shown = drag ? r.value / 1000 * t : p; $('#vcur').textContent = vdClock(shown); $('#vrem').textContent = '-' + vdClock(Math.max(0, t - shown)); } const pp = $('#vpp'); if (pp) { pp.innerHTML = icon(el.paused ? 'play' : 'pause'); pp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); } const pv = $('#vprev'), nx = $('#vnextep'); if (pv) pv.disabled = !vp.sibs || vp.sibIdx <= 0; if (nx) nx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1; - const mp = $('#vmpp'); if (mp) mp.innerHTML = icon(el.paused ? 'play' : 'pause'); const pr = $('#vmprog b'); if (pr) pr.style.width = (t ? Math.min(100, 100 * p / t) : 0) + '%'; + const mp = $('#vmpp'); if (mp) { mp.innerHTML = icon(el.paused ? 'play' : 'pause'); mp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); } + const mr = $('#vmrange'); if (mr) { const d = vp.miniDrag; if (!d) mr.value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; const shown = d ? mr.value / 1000 * t : p; $('#vmcur').textContent = vdClock(shown); $('#vmrem').textContent = '-' + vdClock(Math.max(0, t - shown)); } + const mpv = $('#vmprev'), mnx = $('#vmnext'); if (mpv) mpv.disabled = !vp.sibs || vp.sibIdx <= 0; if (mnx) mnx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1; } /** The mini player: the video keeps playing in a small window while you use other tabs. */ function videoMini(on) { @@ -229,14 +231,22 @@ function videoMini(on) { if (!vp || !vp.el) return; if (!box) { box = document.createElement('div'); box.id = 'vmini'; box.setAttribute('role', 'region'); box.setAttribute('aria-label', 'Video mini player'); - box.innerHTML = `
`; + box.innerHTML = `
+
+ +
0:00
-0:00
`; document.body.appendChild(box); - box.querySelector('#vmpp').onclick = e => { e.stopPropagation(); const el = vp && vp.el; if (el) el.paused ? el.play().catch(() => {}) : el.pause(); }; - box.querySelector('#vmc').onclick = e => { e.stopPropagation(); videoStop(); }; + const q = s => box.querySelector(s), stop = f => e => { e.stopPropagation(); f(e); }; + q('#vmpp').onclick = stop(() => { const el = vp && vp.el; if (el) el.paused ? el.play().catch(() => {}) : el.pause(); }); + q('#vmc').onclick = stop(() => videoStop()); const open = () => { if (vp) location.hash = '#/video/watch/' + vp.id; }; - box.querySelector('#vmx').onclick = e => { e.stopPropagation(); open(); }; box.querySelector('.vmslot').onclick = open; + q('#vmx').onclick = stop(open); q('.vmslot').onclick = open; + q('#vmb10').onclick = stop(() => videoSkip(-10)); q('#vmf10').onclick = stop(() => videoSkip(10)); + q('#vmprev').onclick = stop(() => videoGo(-1)); q('#vmnext').onclick = stop(() => videoGo(1)); + const r = q('#vmrange'); r.oninput = () => { vp && (vp.miniDrag = true); vdUi(); }; r.onchange = () => { if (!vp) return; const t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0; videoSeek(r.value / 1000 * t); vp.miniDrag = false; }; } box.hidden = false; box.querySelector('.vmtitle').textContent = vdTitle(vp.m); + const ep = vp.m.type === 'episode'; box.querySelector('#vmprev').hidden = !ep; box.querySelector('#vmnext').hidden = !ep; vp.el.controls = false; box.querySelector('.vmslot').appendChild(vp.el); document.body.classList.remove('onvideo'); document.body.classList.remove('vfullwin'); const st = document.querySelector('.vstage.vfull'); if (st) st.classList.remove('vfull'); vdUi(); diff --git a/settings.js b/settings.js index 09c4a80..bce52b5 100644 --- a/settings.js +++ b/settings.js @@ -9,7 +9,7 @@ const users = require('./users'); const file = () => path.join(users.dir(), 'settings.json'); // each person has their own // Only these keys are accepted. (The music queue stays in the browser.) -const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']); +const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']); const SECRET = new Set(['ab_creds', 'sf_token']); const MAX_VALUE = 20000; // bytes of JSON per key