Wire the new features into the app: routes, settings, Home card, styles, CI, roadmap; bump the app cache
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bcaedbab5c
commit
c51134e0e5
9 files changed
+48
-16
No files matched your search
@@ -30,6 +30,8 @@ jobs:
|
|||||||
- run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy
|
- run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy
|
||||||
- run: node tools/mail-imap-test.js # Gmail and iCloud mail over IMAP/SMTP: connect, read, send, reply, privacy
|
- run: node tools/mail-imap-test.js # Gmail and iCloud mail over IMAP/SMTP: connect, read, send, reply, privacy
|
||||||
- run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
|
- run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
|
||||||
|
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
|
||||||
|
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
||||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||||
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
||||||
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
|
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
|
||||||
|
|||||||
+11
-11
@@ -46,34 +46,34 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
|
|||||||
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
|
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
|
||||||
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
|
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
|
||||||
- [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family
|
- [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family
|
||||||
- [ ] **Meal planning (M):** a weekly plan from Recipes that builds the shopping list (and can flag what is already in the pantry)
|
- [~] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list. Not built: flagging what is already in the pantry.**
|
||||||
- [ ] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list
|
- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).**
|
||||||
|
|
||||||
## 3. Kids and safety
|
## 3. Kids and safety
|
||||||
- [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.**
|
- [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.**
|
||||||
- [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves
|
- [x] **Approvals (S):** **Done: requests.js ("Can I have...").** a child asks to add a book, app or extension and a parent approves
|
||||||
- [x] **Family device and profile picker (M):** a shared screen opens on the family board; kids tap their name, everyone else enters a PIN; it returns to the board when idle **Done: board.js, devices.js**
|
- [x] **Family device and profile picker (M):** a shared screen opens on the family board; kids tap their name, everyone else enters a PIN; it returns to the board when idle **Done: board.js, devices.js**
|
||||||
- [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents
|
- [x] **Simple mode (S):** **Done: simple-mode.js.** a big-button layout with few tabs for young kids or grandparents
|
||||||
|
|
||||||
## 4. Everyday convenience
|
## 4. Everyday convenience
|
||||||
- [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.**
|
- [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.**
|
||||||
- [ ] **Continue row (S):** the last audiobook, episode, book, movie and song on Home
|
- [x] **Continue row (S):** **Done: Home, today.js.** the last audiobook, episode, book, movie and song on Home
|
||||||
- [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.**
|
- [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.**
|
||||||
- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views
|
- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views
|
||||||
- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files
|
- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files
|
||||||
- [ ] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas
|
- [x] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas **Done: anniversaries show in the calendar every year; gift ideas on each contact.**
|
||||||
|
|
||||||
## 5. Media
|
## 5. Media
|
||||||
- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote
|
- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote
|
||||||
- [ ] **Podcast and audiobook stats (S):** listening history like Music has
|
- [x] **Podcast and audiobook stats (S):** listening history like Music has **Done: time listened per day, streak and audiobooks finished, as a Home card (progress.js).**
|
||||||
- [ ] **Reading goals (S):** a family reading challenge with streaks; export notes and highlights
|
- [~] **Reading goals (S):** **Done: a yearly goal for audiobooks finished, with a listening streak. Not built: a family-wide challenge, e-book goals, exporting notes and highlights.**
|
||||||
- [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.**
|
- [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.**
|
||||||
- [ ] **Casting (M):** pick AirPlay or Cast targets for audio
|
- [x] **Casting (M):** **Done: cast.js (AirPlay and the browser's cast picker).** pick AirPlay or Cast targets for audio
|
||||||
|
|
||||||
## 6. Reliability (the admin's side)
|
## 6. Reliability (the admin's side)
|
||||||
- [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface
|
- [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface
|
||||||
- [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage
|
- [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage
|
||||||
- [ ] **Backup and restore screen (S):** one-click restore, per-person export
|
- [x] **Backup and restore screen (S):** one-click restore, per-person export **Done: Settings > Admin restores a backup (old data kept aside); Settings > General downloads your own data.**
|
||||||
- [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels)
|
- [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels)
|
||||||
- [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries)
|
- [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries)
|
||||||
- [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion
|
- [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion
|
||||||
@@ -81,7 +81,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
|
|||||||
## 7. Platform (later)
|
## 7. Platform (later)
|
||||||
- [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates
|
- [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates
|
||||||
- [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping)
|
- [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping)
|
||||||
- [ ] **Themes (S):** accent colours and an AMOLED dark mode per person
|
- [x] **Themes (S):** accent colours and an AMOLED dark mode per person **Done: themes.js, Settings > General > Look.**
|
||||||
|
|
||||||
## Suggested order
|
## Suggested order
|
||||||
1. Family Chat phase 1, Family Home, Shared lists and notes, Chores
|
1. Family Chat phase 1, Family Home, Shared lists and notes, Chores
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ function viewHome() {
|
|||||||
inboxWire();
|
inboxWire();
|
||||||
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
||||||
if (typeof homeResume === 'function') homeResume($('#resume'));
|
if (typeof homeResume === 'function') homeResume($('#resume'));
|
||||||
|
if (typeof homeListening === 'function') homeListening($('#resume'));
|
||||||
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
||||||
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
||||||
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
<script src="/recipes-view.js"></script>
|
<script src="/recipes-view.js"></script>
|
||||||
<script src="/contacts-view.js"></script>
|
<script src="/contacts-view.js"></script>
|
||||||
<script src="/bible-view.js"></script>
|
<script src="/bible-view.js"></script>
|
||||||
|
<script src="/devotion-view.js"></script>
|
||||||
<script src="/save-to-files.js"></script>
|
<script src="/save-to-files.js"></script>
|
||||||
<script src="/vbv-view.js"></script>
|
<script src="/vbv-view.js"></script>
|
||||||
<script src="/lyrics-view.js"></script>
|
<script src="/lyrics-view.js"></script>
|
||||||
@@ -105,6 +106,7 @@
|
|||||||
<script src="/device-ui.js"></script>
|
<script src="/device-ui.js"></script>
|
||||||
<script src="/settings-family.js"></script>
|
<script src="/settings-family.js"></script>
|
||||||
<script src="/simple-mode.js"></script>
|
<script src="/simple-mode.js"></script>
|
||||||
|
<script src="/themes.js"></script>
|
||||||
<script src="/boot.js"></script>
|
<script src="/boot.js"></script>
|
||||||
<script src="/shell.js"></script>
|
<script src="/shell.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+7
-2
@@ -86,7 +86,7 @@ function viewSettings(args) {
|
|||||||
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
||||||
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
||||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
||||||
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2>${installHtml()}</div>
|
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<div id="themeset"></div><h2>Your data</h2><p class="sub">A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.</p><div class="bar"><a class="btn ghost" href="/api/export" download>Download my data</a></div><h2>Install</h2>${installHtml()}</div>
|
||||||
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
|
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
|
||||||
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
||||||
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
||||||
@@ -111,7 +111,8 @@ function viewSettings(args) {
|
|||||||
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||||
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||||
<h2>iPad and Screen Time</h2><p class="sub" style="white-space:normal">Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.</p><div class="bar"><button class="btn ghost" id="stbtn">Set up Screen Time</button></div>
|
<h2>iPad and Screen Time</h2><p class="sub" style="white-space:normal">Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.</p><div class="bar"><button class="btn ghost" id="stbtn">Set up Screen Time</button></div>
|
||||||
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
|
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. To restore, choose a backup file below.</p>
|
||||||
|
<div class="bar"><label class="btn ghost" for="bkfile" style="cursor:pointer">Restore from a backup…</label><input type="file" id="bkfile" accept=".gz,.tgz,application/gzip" hidden></div><p class="sub" id="bkmsg">Replaces the hub's accounts and data with the backup's. The current data is kept in a .pre-restore folder, and the hub restarts.</p>` : ''}
|
||||||
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
remindersInit();
|
remindersInit();
|
||||||
@@ -200,6 +201,10 @@ function viewSettings(args) {
|
|||||||
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
|
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
|
||||||
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
|
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
|
||||||
if (typeof famSettings === 'function') famSettings($('#famset'));
|
if (typeof famSettings === 'function') famSettings($('#famset'));
|
||||||
|
if (typeof themeSettings === 'function') themeSettings($('#themeset'));
|
||||||
|
const bkf = $('#bkfile'); if (bkf) bkf.onchange = () => { const f = bkf.files[0]; bkf.value = ''; if (!f) return; confirmSheet('Restore this backup?', `${f.name} replaces the accounts and data on this hub. The current data is kept aside, and the hub restarts.`, 'Restore', async () => {
|
||||||
|
const m = $('#bkmsg'); m.textContent = 'Uploading and restoring…';
|
||||||
|
try { const r = await fetch('/api/backup/restore', { method: 'POST', body: f }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not restore'); m.textContent = 'Restored. The hub is restarting; this page reloads in a moment.'; setTimeout(() => location.reload(), 6000); } catch (e) { m.textContent = e.message; toast(e.message); } }); };
|
||||||
$('#invpk').onclick = async () => {
|
$('#invpk').onclick = async () => {
|
||||||
try {
|
try {
|
||||||
const { code, minutes } = await authPost('/api/auth/invite');
|
const { code, minutes } = await authPost('/api/auth/invite');
|
||||||
|
|||||||
+7
-1
@@ -773,7 +773,7 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.vdeckc { position: relative; flex: none; }
|
.vdeckc { position: relative; flex: none; }
|
||||||
.vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; }
|
.vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; }
|
||||||
.vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); }
|
.vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); }
|
||||||
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { min-height: 40px; max-width: 48vw; }
|
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { width: auto; flex: 0 1 auto; min-height: 40px; max-width: 48vw; }
|
||||||
.vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; }
|
.vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; }
|
||||||
.vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); }
|
.vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); }
|
||||||
.vdeckc .vcard.wide { width: 250px; }
|
.vdeckc .vcard.wide { width: 250px; }
|
||||||
@@ -1095,3 +1095,9 @@ body.simple #shell { flex-direction: column; }
|
|||||||
body.simple #view { padding-bottom: 110px; }
|
body.simple #view { padding-bottom: 110px; }
|
||||||
/* while a sheet, the lyrics or the visualizer covers the screen, the floating Home and Ask buttons step aside so they never sit on its controls */
|
/* while a sheet, the lyrics or the visualizer covers the screen, the floating Home and Ask buttons step aside so they never sit on its controls */
|
||||||
body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simplehome, body:has(#lyr) #kidreq, body.vizopen #simplehome, body.vizopen #kidreq { display: none !important; }
|
body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simplehome, body:has(#lyr) #kidreq, body.vizopen #simplehome, body.vizopen #kidreq { display: none !important; }
|
||||||
|
|
||||||
|
.swatches { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0; } .swatches button { width: 40px; height: 40px; min-height: 0; padding: 0; border-radius: 50%; border: 3px solid var(--panel); box-shadow: 0 0 0 1px var(--line); } .swatches button.on { box-shadow: 0 0 0 3px var(--text); }
|
||||||
|
.dvverse { display: block; max-width: none; } .dvverse p { font-family: Georgia, 'Times New Roman', serif; font-size: 19px; line-height: 1.5; margin: 0 0 8px; } .dvverse small { color: var(--muted); } .dvread { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||||
|
.lsrow { display: flex; gap: 16px; } .lsrow span { flex: 1; min-width: 0; } .lsrow b { display: block; font-size: 22px; letter-spacing: -.01em; } .lsrow small { color: var(--muted); }
|
||||||
|
.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; }
|
||||||
|
.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); }
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v95';
|
const CACHE = 'hub-v102';
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
|
|||||||
@@ -28,3 +28,17 @@ async function homeResume(el) {
|
|||||||
el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
||||||
}
|
}
|
||||||
const today = { books: [] };
|
const today = { books: [] };
|
||||||
|
|
||||||
|
// Home: how much you listened this week (podcasts and audiobooks), the day streak, and a yearly goal for audiobooks finished. The goal is kept in your synced settings.
|
||||||
|
const lsTime = sec => { const m = Math.round(sec / 60); return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : ''); };
|
||||||
|
async function homeListening(after) {
|
||||||
|
if (!after || !(allowed('podcast') || allowed('audio'))) return;
|
||||||
|
let s; try { s = await jfetch('/api/listening/stats'); } catch { return; }
|
||||||
|
const goal = +store.get('read_goal', 0) || 0; if (!after.isConnected || (!s.week && !goal && !s.finishedYear)) return;
|
||||||
|
const max = Math.max(60, ...s.last14.map(d => d.s)), card = document.createElement('div'); card.id = 'listening';
|
||||||
|
card.innerHTML = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>audiobooks finished this year</small></span></div>
|
||||||
|
<div class="lsbars" role="img" aria-label="Minutes listened each of the last 14 days">${s.last14.map(d => `<i style="height:${Math.max(3, Math.round(d.s / max * 100))}%" title="${esc(d.day)}: ${lsTime(d.s)}"></i>`).join('')}</div>
|
||||||
|
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
|
||||||
|
after.insertAdjacentElement('afterend', card);
|
||||||
|
card.querySelector('#lsgoal').onclick = () => { const el = openSheet('Audiobooks to finish this year', '', `<form id="lsf"><label for="lsn">Goal</label><input type="number" id="lsn" min="0" max="500" value="${goal || ''}" placeholder="12" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#lsf').onsubmit = e => { e.preventDefault(); const n = Math.max(0, Math.min(500, Math.round(+el.querySelector('#lsn').value) || 0)); if (n) store.set('read_goal', n); else store.del('read_goal'); closeSheet(); card.remove(); homeListening(after); }; };
|
||||||
|
}
|
||||||
@@ -660,7 +660,9 @@ async function handleRequest(req, res) {
|
|||||||
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
||||||
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
if (pathname === '/api/listening/stats') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').stats())); }
|
||||||
|
if (pathname === '/api/export') return backup.handle(req, res, user.name);
|
||||||
|
if (pathname === '/api/backup' || pathname === '/api/backup/restore') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
||||||
if (pathname === '/api/kidsearch' || pathname.startsWith('/api/kidsearch/')) return require('./kidsearch').handle(req, res);
|
if (pathname === '/api/kidsearch' || pathname.startsWith('/api/kidsearch/')) return require('./kidsearch').handle(req, res);
|
||||||
if (pathname === '/api/ddg') { if (user.kid && user.kid.on && user.role !== 'admin') return forbidden(res, 'Web search is not available on a child account'); return ddgAnswer(req, res); }
|
if (pathname === '/api/ddg') { if (user.kid && user.kid.on && user.role !== 'admin') return forbidden(res, 'Web search is not available on a child account'); return ddgAnswer(req, res); }
|
||||||
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
|
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
|
||||||
|
|||||||
Reference in new issue
Block a user