From 838457f6f9a669e34dfe93bbf42748d8c4af6c0e Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 16 Oct 2026 14:31:58 +0200 Subject: [PATCH] =?UTF-8?q?Navigation:=20Lesen=20und=20H=C3=B6ren=20(Phase?= =?UTF-8?q?=201,=20noch=20nicht=20ver=C3=B6ffentlicht)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Bereiche nach dem, was man tut (docs/navigation.md): Home; Lesen mit Bücher, Zeitschriften, Nachrichten, Blogs; Hören mit Hörbücher, Podcasts, Radio, Ambient. static/js/nav.js ist die eine Definition für alle Plattformen (DioraNav: Abschnitte, Gruppen, go, Hash, zuletzt gewählter Abschnitt je Gruppe). Die Webseite zeichnet ihre Leiste daraus: breit alle Abschnitte unter ihrer Gruppe, schmal Home · Lesen · Hören mit einer Abschnittszeile oben. - Hörbücher haben ein eigenes Panel (#tab-audiobooks), nicht mehr über den E-Books. - Zeitschriften: die Bücher im Ordner „Zeitschriften“ (oder „Magazine“). - Nachrichten/Blogs wählt die Navigation; der eigene Umschalter verschwindet dann. - Ein Link auf einen Abschnitt (#news …) schlägt die Startseite. Die Apps zeichnen ihre Leiste (native-radio.js, diora-android) bald ebenfalls daraus; bis dahin bleibt das hier unveröffentlicht. Co-Authored-By: Claude Opus 5.5 --- docs/navigation.md | 87 +++++++++++ e2e/navigation.spec.js | 61 ++++++++ static/js/app/20-tabs.js | 13 +- static/js/app/52-book-list.js | 34 ++++- static/js/app/99-init.js | 5 +- static/js/audiobooks.js | 23 +-- static/js/feeds.js | 9 ++ static/js/nav.js | 271 ++++++++++++++++++++++++++++++++++ static/js/podcasts-web.js | 2 + static/js/sw.js | 3 +- templates/radio/player.html | 3 + 11 files changed, 489 insertions(+), 22 deletions(-) create mode 100644 docs/navigation.md create mode 100644 e2e/navigation.spec.js create mode 100644 static/js/nav.js diff --git a/docs/navigation.md b/docs/navigation.md new file mode 100644 index 0000000..627de39 --- /dev/null +++ b/docs/navigation.md @@ -0,0 +1,87 @@ +# Navigation: Lesen and Hören + +diora's areas are sorted by what one does, not where things come from: + +| Group | Sections | +|-------|----------| +| — | Home | +| Lesen | Bücher · Zeitschriften · Nachrichten · Blogs | +| Hören | Hörbücher · Podcasts · Radio · Ambient | +| — | Abos & Quellen (managing all subscriptions; phase 2) | + +- **Desktop / wide window (sidebar):** every section is its own entry, under + its group's heading. Nothing inside a section repeats the choice. +- **Phone (tab bar):** three tabs, **Home · Lesen · Hören**. Lesen and Hören + show a row of their sections at the top (segments); each group remembers + the section last used. The mini player stays below as it is. +- **diora Reader** (tolino, e-readers): only Lesen, without Home. **TV:** Hören. + +Anything new goes into one of the two groups. Managing (subscribing, settings, +folders) moves out of the everyday views into "Abos & Quellen" (phase 2). + +## One definition: `static/js/nav.js` (diora-web) + +Loaded by the page on every platform, before the other area scripts. It owns +the sections, which panel shows each, switching between them, the URL hash +and the remembered choice. The bars are drawn from it. + +``` +window.DioraNav = { + sections(), // [{id, group, label, icon, available}] in order (only those the page has) + groups(), // [{id: 'home'|'lesen'|'hoeren', label, icon, sections: [id…]}] + current(), // the section id shown now + go(id), // show a section ('lesen'/'hoeren': that group's last section) + onChange(fn), // fn(sectionId) after every change +} +``` + +| Section id | Group | Label | Panel | View inside the panel | +|------------|-------|-------|-------|-----------------------| +| `home` | — | Home | `#tab-home` | | +| `books` | lesen | Bücher | `#tab-books` | e-books (not magazines) | +| `magazines` | lesen | Zeitschriften | `#tab-books` | magazines | +| `news` | lesen | Nachrichten | `#tab-feeds` | news | +| `blogs` | lesen | Blogs | `#tab-feeds` | blogs | +| `audiobooks` | hoeren | Hörbücher | `#tab-audiobooks` | (audiobooks.js) | +| `podcasts` | hoeren | Podcasts | `#tab-podcasts` | | +| `radio` | hoeren | Radio | `#tab-radio` | | +| `ambient` | hoeren | Ambient | `#tab-ambient` | | + +`go()` calls the page's `showTab(panel)` as before (so everything hooked onto +showTab keeps working) and then the panel's view hook: +`window.__dioraBooksView('books'|'magazines')`, +`window.__dioraFeedsView('news'|'blogs')`. The panels' own switches for that +choice (feeds.js News/Blogs) are hidden. `_currentTopTab` stays the panel name. + +Hash: `#books`, `#magazines`, `#news`, `#blogs`, `#audiobooks`, … (old hashes +`#feeds`, `#radio/saved` keep working). Remembered: `localStorage` +`diora_nav_section` and `diora_nav_last_`. + +**Zeitschriften** (phase 1): the books in a library folder called +"Zeitschriften" (or "Magazine"); a per-book mark follows with phase 3. + +### The segment row (phone) + +nav.js draws `#nav-segments` above the panels: the current group's sections, +when the layout is the tab bar (not `html.nui-sidebar`) and the group has more +than one available section. Styled with the theme tokens; the native CSS may +restyle it. + +## Who draws what + +| Where | Bar | Drawn by | +|-------|-----|----------| +| Android (phone) | `#nui-tabbar`: Home · Lesen · Hören | native-radio.js, from `DioraNav.groups()` | +| Android tablet, desktop (`html.nui-sidebar`) | rail: Home, headings Lesen/Hören with their sections, Menü | native-radio.js, from `DioraNav.sections()` | +| Web page | `#tabs` (narrow: groups + segments; wide: all sections under headings) | nav.js | + +A tab/rail button calls `DioraNav.go(id)`; the active state comes from +`DioraNav.onChange`. + +## Phases + +1. Navigation (this document), Hörbücher as their own panel, Zeitschriften by folder. +2. "Abos & Quellen": podcasts, YouTube channels, news sources, blogs and the + audiobook mark in one place; the everyday views without managing. +3. Library: audiobook series (e.g. Das Kapital, Band 1–3) and folders, + magazines marked per book. diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js new file mode 100644 index 0000000..817602b --- /dev/null +++ b/e2e/navigation.spec.js @@ -0,0 +1,61 @@ +// The navigation (docs/navigation.md): Home, Lesen (Bücher, Zeitschriften, Nachrichten, +// Blogs), Hören (Hörbücher, Podcasts, Radio, Ambient), on the web page's own bar. +const { test, expect } = require('@playwright/test'); +const path = require('path'); + +test.use({ storageState: path.join(__dirname, '.auth', 'user.json') }); + +const visiblePanel = page => page.evaluate(() => + [...document.querySelectorAll('section.tab-panel')].filter(p => p.style.display !== 'none').map(p => p.id)); + +test('wide: every section in the bar under its group, each shows its panel and view', async ({ page }) => { + await page.setViewportSize({ width: 1200, height: 800 }); + await page.goto('/#books'); + const wide = page.locator('#tabs .nav-wide'); + await expect(wide.locator('.nav-group-label')).toHaveText(['Lesen', 'Hören']); + await expect(wide.locator('[data-nav]')).toHaveText( + ['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus']); + + await wide.locator('[data-nav="blogs"]').click(); + await expect(page).toHaveURL(/#blogs$/); + expect(await visiblePanel(page)).toEqual(['tab-feeds']); + expect(await page.evaluate(() => localStorage.getItem('diora_feeds_view'))).toBe('blogs'); + await expect(page.locator('#tab-feeds .fd-seg')).toBeHidden(); // the bar chooses, not the tab + await expect(page.locator('#nav-segments')).toBeHidden(); + + await wide.locator('[data-nav="audiobooks"]').click(); + expect(await visiblePanel(page)).toEqual(['tab-audiobooks']); + await expect(page.locator('#audiobook-shelf')).toBeVisible(); + await expect(wide.locator('[data-nav="audiobooks"]')).toHaveClass(/active/); +}); + +test('narrow: three groups, the sections as a row on top, each group remembers its last', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await page.goto('/#news'); + const narrow = page.locator('#tabs .nav-narrow'); + await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören']); + await expect(narrow.locator('[data-nav="lesen"]')).toHaveClass(/active/); + const segs = page.locator('#nav-segments'); + await expect(segs.locator('[data-nav]')).toHaveText(['Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs']); + await expect(segs.locator('[data-nav="news"]')).toHaveClass(/active/); + + await narrow.locator('[data-nav="hoeren"]').click(); + await segs.locator('[data-nav="radio"]').click(); + expect(await visiblePanel(page)).toEqual(['tab-radio']); + await narrow.locator('[data-nav="lesen"]').click(); + await expect(page).toHaveURL(/#news$/); // back where Lesen was left + await narrow.locator('[data-nav="hoeren"]').click(); + await expect(page).toHaveURL(/#radio/); +}); + +test('old links and direct panel switches land in their section', async ({ page }) => { + await page.setViewportSize({ width: 1200, height: 800 }); + await page.goto('/#feeds'); + expect(await visiblePanel(page)).toEqual(['tab-feeds']); + await expect(page.locator('#tabs .nav-wide [data-nav].active')).toHaveText(/Nachrichten|Blogs/); + await page.evaluate(() => showTab('podcasts')); + await expect(page.locator('#tabs [data-nav="podcasts"]')).toHaveClass(/active/); + await expect(page).toHaveURL(/#podcasts$/); + await page.reload(); + expect(await visiblePanel(page)).toEqual(['tab-podcasts']); // remembered +}); diff --git a/static/js/app/20-tabs.js b/static/js/app/20-tabs.js index a0facca..6ea6a78 100644 --- a/static/js/app/20-tabs.js +++ b/static/js/app/20-tabs.js @@ -4,7 +4,7 @@ // Tabs // --------------------------------------------------------------------------- -const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient']; +const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks']; const RADIO_SUB_TABS = ['search', 'saved', 'history']; // --------------------------------------------------------------------------- @@ -17,7 +17,7 @@ let _routingFromHash = false; let _currentTopTab = 'radio'; function _syncHash() { - if (_routingFromHash) return; + if (_routingFromHash || window.DioraNav) return; // the navigation keeps the hash (static/js/nav.js) let hash = _currentTopTab; if (_currentTopTab === 'radio') { hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved'); @@ -26,6 +26,7 @@ function _syncHash() { } function _routeFromHash() { + if (window.DioraNav) return; // nav.js routes (#news, #audiobooks …) const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean); if (!TOP_TABS.includes(tab)) return; _routingFromHash = true; @@ -52,9 +53,11 @@ function showTab(name) { if (panel) panel.style.display = (p === name) ? '' : 'none'; }); - document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => { - btn.classList.toggle('active', TOP_TABS[i] === name); - }); + if (!window.DioraNav) { // else nav.js marks its own buttons + document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => { + btn.classList.toggle('active', TOP_TABS[i] === name); + }); + } localStorage.setItem('diora_active_tab', name); _currentTopTab = name; diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index 3205b2a..32f4f79 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -205,6 +205,27 @@ async function toggleBookRead(bookId, currentlyRead) { // free in /api/sync/. One level only: folders don't nest. let _currentBookFolder = null; // null = root view (folder tiles + unfiled books) +// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: the +// magazines are the books in a folder called "Zeitschriften" (or "Magazine"). +let _bookSection = 'books'; +const _MAGAZINE_FOLDER = /^(zeitschriften|magazine|magazines)$/i; +const _isMagazine = b => _MAGAZINE_FOLDER.test((b.folder || '').trim()); + +// The books of the section shown (all of them where there are no sections). +function _bookSectionFilter(books) { + if (!window.DioraNav) return books; + return books.filter(b => (_bookSection === 'magazines') === _isMagazine(b)); +} +window._bookSectionFilter = _bookSectionFilter; + +window.__dioraBooksView = function (view) { + const next = view === 'magazines' ? 'magazines' : 'books'; + if (next === _bookSection) return; + _bookSection = next; + _currentBookFolder = null; + if (_lastBookListData) renderBookList(_lastBookListData); +}; + function _recentlyReadBooks(books) { return books .filter(b => b.last_read) @@ -265,13 +286,18 @@ function _openBookFolder(name) { renderBookList(_lastBookListData); } -function renderBookList(books) { +function renderBookList(allBooks) { const listEl = $('book-list'); if (!listEl) return; - _lastBookListData = books; + _lastBookListData = allBooks; + const books = _bookSectionFilter(allBooks); if (!books.length) { - listEl.innerHTML = ''; + listEl.innerHTML = window.DioraNav && allBooks.length + ? (_bookSection === 'magazines' + ? '

Noch keine Zeitschriften. Hefte, die in einem Ordner „Zeitschriften“ liegen, stehen hier (⋮ → Ordner zuweisen).

' + : '

Alle Bücher liegen bei den Zeitschriften.

') + : ''; return; } @@ -299,7 +325,7 @@ function renderBookList(books) { const grouped = new Map(); for (const b of visible) { - const key = b.folder || ''; + const key = _bookSection === 'magazines' && _isMagazine(b) ? '' : (b.folder || ''); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(b); } diff --git a/static/js/app/99-init.js b/static/js/app/99-init.js index de390d6..e19794d 100644 --- a/static/js/app/99-init.js +++ b/static/js/app/99-init.js @@ -71,7 +71,10 @@ // except when offline, where books (usable offline) always takes over. const offlineOverride = !navigator.onLine && IS_AUTHENTICATED; const hashTab = (location.hash || '').replace(/^#/, '').split('/')[0]; - if (!offlineOverride && TOP_TABS.includes(hashTab)) { + if (window.DioraNav) { // sections (static/js/nav.js) + DioraNav.restore(offlineOverride); + showRadioTab(localStorage.getItem('diora_active_radio_tab') || 'saved'); + } else if (!offlineOverride && TOP_TABS.includes(hashTab)) { _routeFromHash(); } else { const savedTab = localStorage.getItem('diora_active_tab') || 'radio'; diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js index 7b258b6..2fab3fd 100644 --- a/static/js/audiobooks.js +++ b/static/js/audiobooks.js @@ -1,6 +1,6 @@ /* - * Audiobooks in the library (docs/audiobooks.md): podcast subscriptions marked as a - * Hörbuch or Hörspiel are listened to like a book, next to the e-books in "Bücher" — + * Audiobooks, the section "Hörbücher" of Hören (docs/audiobooks.md, docs/navigation.md): + * podcast subscriptions marked as a Hörbuch or Hörspiel are listened to like a book — * "Weiterhören", one progress per book, the chapters in order, bookmarks with a note, * the sleep timer to the end of the chapter. The server knows the books * (/podcasts/audiobooks/), the podcast host plays them (podcastPlayBook: the chapters in @@ -9,9 +9,8 @@ */ (function () { 'use strict'; - const tab = document.getElementById('tab-books'); - const list = document.getElementById('book-list'); - if (!tab || !list) return; + const tab = document.getElementById('tab-audiobooks'); + if (!tab) return; if (typeof IS_AUTHENTICATED !== 'undefined' && !IS_AUTHENTICATED) return; // The podcast host: the app's own on Android, podcasts-web.js elsewhere. @@ -33,10 +32,7 @@ const shelf = document.createElement('div'); shelf.id = 'audiobook-shelf'; - // Above the e-books, and above their "Gelesene anzeigen" switch (it is about them). - const filter = list.previousElementSibling; - const before = filter && filter.classList.contains('book-list-filter') ? filter : list; - list.parentNode.insertBefore(shelf, before); + tab.appendChild(shelf); // ---- helpers ------------------------------------------------------------------------ @@ -173,7 +169,12 @@ const books = st.books || []; const state = h ? podcastState() : null; const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : []; - if (!h || (!books.length && !pods.length)) { shelf.innerHTML = ''; return; } + if (!h) { shelf.innerHTML = ''; return; } + if (!books.length && !pods.length) { + shelf.innerHTML = '

Hörbücher und Hörspiele sind hier Podcasts, die wie ein Buch gehört ' + + 'werden: Kapitel in Reihenfolge, Lesezeichen. Erst einen Podcast abonnieren (Podcasts → Suche), dann hier markieren.

'; + return; + } let html = ''; const go = books.filter(b => b.next && b.last_played_at)[0]; if (go) { @@ -528,7 +529,7 @@ if (typeof showTab === 'function') { window.showTab = function (name) { const r = showTab.apply(this, arguments); - if (name === 'books') loadList(); + if (name === 'audiobooks') loadList(); else if (st.open != null) closeBook(); return r; }; diff --git a/static/js/feeds.js b/static/js/feeds.js index 54e7fdf..6780b41 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -994,6 +994,14 @@ // ---- showing the tab let shownAt = 0; + // Nachrichten and Blogs are sections of Lesen (static/js/nav.js): it chooses, before the + // tab is shown; its own switch is then hidden. + window.__dioraFeedsView = function (view) { + if (view !== 'news' && view !== 'blogs' || view === st.view) return; + st.view = view; + try { localStorage.setItem('diora_feeds_view', view); } catch (err) {} + shownAt = 0; // shown next: loaded for this view + }; window.__dioraFeedsShow = function () { // Back after a while: fresh state (new briefing); otherwise as it was. if (!st.state || Date.now() - shownAt > 5 * 60000) load(); @@ -1078,6 +1086,7 @@ '#tab-feeds .fd-seg { display: flex; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; overflow: hidden; }', '#tab-feeds .fd-seg button { background: none; border: none; color: inherit; font: inherit; padding: 9px 14px; cursor: pointer; }', '#tab-feeds .fd-seg button.on { background: var(--accent, #e63946); color: var(--on-accent, #fff); }', + 'html.nav-bars #tab-feeds .fd-seg { display: none; }', '#tab-feeds .fd-count { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px; background: var(--raised, rgba(255,255,255,0.18)); font-size: 11px; text-align: center; }', '#tab-feeds .fd-actions { display: flex; gap: 4px; }', '.fd-icon { background: none; border: none; color: inherit; font: inherit; font-size: 20px; min-width: 40px; min-height: 40px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }', diff --git a/static/js/nav.js b/static/js/nav.js new file mode 100644 index 0000000..d37733a --- /dev/null +++ b/static/js/nav.js @@ -0,0 +1,271 @@ +/* + * The navigation (docs/navigation.md): diora's sections, sorted by what one does — + * Home; Lesen: Bücher, Zeitschriften, Nachrichten, Blogs; Hören: Hörbücher, Podcasts, + * Radio, Ambient. One definition for every platform: the web page's #tabs are drawn + * here, the apps' tab bar and sidebar (native-radio.js) from DioraNav.groups() and + * DioraNav.sections(). A section is a panel (showTab, as before) plus a view in it. + * On a phone, Lesen and Hören show their sections as a row on top (#nav-segments). + * Loaded before the app scripts (99-init.js restores the section through it). + * Chrome-61-safe: no ?. / ??. + */ +(function () { + 'use strict'; + const html = document.documentElement; + const authed = typeof IS_AUTHENTICATED === 'undefined' || !!IS_AUTHENTICATED; + + const SECTIONS = [ + {id: 'home', group: 'home', label: 'Home', icon: '⌂', panel: 'home', account: true}, + {id: 'books', group: 'lesen', label: 'Bücher', icon: '▤', panel: 'books', view: 'books'}, + {id: 'magazines', group: 'lesen', label: 'Zeitschriften', icon: '▥', panel: 'books', view: 'magazines', account: true}, + {id: 'news', group: 'lesen', label: 'Nachrichten', icon: '☰', panel: 'feeds', view: 'news', account: true}, + {id: 'blogs', group: 'lesen', label: 'Blogs', icon: '✎', panel: 'feeds', view: 'blogs', account: true}, + {id: 'audiobooks', group: 'hoeren', label: 'Hörbücher', icon: '◐', panel: 'audiobooks', account: true, needsPodcasts: true}, + {id: 'podcasts', group: 'hoeren', label: 'Podcasts', icon: '◎', panel: 'podcasts'}, + {id: 'radio', group: 'hoeren', label: 'Radio', icon: '◉', panel: 'radio'}, + {id: 'ambient', group: 'hoeren', label: 'Ambient', icon: '∿', panel: 'ambient'}, + // The focus timer: the web page's own (the apps never had it). + {id: 'focus', group: 'hoeren', label: 'Fokus', icon: '◷', panel: 'focus', webOnly: true}, + ]; + const GROUPS = [ + {id: 'home', label: 'Home', icon: '⌂'}, + {id: 'lesen', label: 'Lesen', icon: '▤'}, + {id: 'hoeren', label: 'Hören', icon: '◉'}, + ]; + const byId = {}; + SECTIONS.forEach(s => { byId[s.id] = s; }); + + const nativeUi = () => html.classList.contains('nui-radio'); // the apps' own bars (native-radio.js) + const readerApp = () => html.classList.contains('nui-reader-app'); + const noRead = () => html.classList.contains('nui-noread'); // TV + // Audiobooks need the podcast player: the page has them where it loads audiobooks.js + // (diora Reader's build leaves it out). Known before the player's script has run. + const podcastHost = () => !!document.querySelector('script[src$="/static/js/audiobooks.js"]'); + + function available(s) { + if (!document.getElementById('tab-' + s.panel)) return false; + if (s.account && !authed) return false; + if (s.webOnly && nativeUi()) return false; + if (s.needsPodcasts && !podcastHost()) return false; + if (readerApp() && s.group !== 'lesen') return false; // diora Reader: reading only + if (noRead() && s.group === 'lesen') return false; // TV: no reading + return true; + } + + function sections() { + return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon})); + } + + function groups() { + const list = sections(); + return GROUPS.map(g => ({id: g.id, label: g.label, icon: g.icon, + sections: list.filter(s => s.group === g.id).map(s => s.id)})).filter(g => g.sections.length); + } + + function load(key) { + try { return localStorage.getItem(key); } catch (e) { return null; } + } + + function save(key, value) { + try { localStorage.setItem(key, value); } catch (e) {} + } + + let current = null; + const listeners = []; + + // A section of `group`: the one used last there, else its first. + function lastOf(group) { + const list = sections().filter(s => s.group === group); + const last = load('diora_nav_last_' + group); + const found = list.filter(s => s.id === last)[0]; + return (found || list[0] || {}).id || null; + } + + // What a name means: a section, a group (its last section), or a panel (its section). + function resolve(name) { + if (byId[name] && available(byId[name])) return name; + if (GROUPS.some(g => g.id === name)) return lastOf(name); + const inPanel = sections().filter(s => byId[s.id].panel === name); + if (!inPanel.length) return null; + const last = lastOf(byId[inPanel[0].id].group); + return inPanel.some(s => s.id === last) ? last : inPanel[0].id; + } + + function applyView(s) { + if (s.panel === 'books' && window.__dioraBooksView) window.__dioraBooksView(s.view); + if (s.panel === 'feeds' && window.__dioraFeedsView) window.__dioraFeedsView(s.view); + } + + function setCurrent(id) { + current = id; + const s = byId[id]; + save('diora_nav_section', id); + save('diora_nav_last_' + s.group, id); + syncHash(); + render(); + listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} }); + } + + let going = false; + function go(name) { + wrapShowTab(); + const id = resolve(name); + if (!id) return false; + const s = byId[id]; + applyView(s); // first: the panel then shows the chosen view + going = true; + try { window.showTab(s.panel); } finally { going = false; } // with everything hooked onto it + setCurrent(id); + return true; + } + + // Anything that shows a panel directly (Home's cards, the offline switch to the books, + // a notification …): the section follows. Hooked on when first needed: this file comes + // before the app's scripts, which define showTab. + let wrapped = false; + function wrapShowTab() { + if (wrapped || typeof window.showTab !== 'function') return; + wrapped = true; + const showTab = window.showTab; + window.showTab = function (name) { + const r = showTab.apply(this, arguments); + if (!going) { + const cur = current && byId[current]; + if (!cur || cur.panel !== name) { + const id = resolve(name); + if (id) { + if (byId[id].view) applyView(byId[id]); + setCurrent(id); + } + } + } + return r; + }; + } + + // ---- the URL hash: #books, #news, #audiobooks … (old ones like #feeds, #radio/saved too) + + let routing = false; + function syncHash() { + if (routing) return; + let hash = current; + if (current === 'radio') hash += '/' + (load('diora_active_radio_tab') || 'saved'); + if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash); + } + + function route() { + const parts = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean); + if (!parts.length || !resolve(parts[0])) return false; + routing = true; + try { + go(parts[0]); + if (parts[0] === 'radio' && parts[1] && typeof showRadioTab === 'function') showRadioTab(parts[1]); + } finally { + routing = false; + } + return true; + } + + // At the start (99-init.js): the hash, else the section used last; offline the books. + function restore(offline) { + if (offline) { go('books'); return; } + if (route()) return; + const saved = load('diora_nav_section') || load('diora_active_tab') || 'radio'; + if (!go(saved)) go(sections()[0] ? sections()[0].id : 'radio'); + } + + // ---- the web page's bar and the segment row -------------------------------------------- + + function esc(s) { + return String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c])); + } + + function button(id, label, cls, on) { + return ''; + } + + let tabs = null; + let segs = null; + function render() { + const cur = current && byId[current]; + const gs = groups(); + if (tabs && !nativeUi()) { + // Wide: every section under its group; narrow: the groups (their sections on top). + tabs.innerHTML = + '' + + ''; + } + if (segs) { + const g = cur ? gs.filter(x => x.id === cur.group)[0] : null; + const show = !!g && g.sections.length > 1 && html.classList.contains('nav-bars'); + segs.hidden = !show; + segs.innerHTML = show ? g.sections.map(id => button(id, byId[id].label, 'nav-seg', current === id)).join('') : ''; + } + } + + function setup() { + wrapShowTab(); + tabs = document.getElementById('tabs'); + const first = document.querySelector('section.tab-panel'); + if (first) { + segs = document.createElement('nav'); + segs.id = 'nav-segments'; + segs.hidden = true; + first.parentNode.insertBefore(segs, first); + segs.addEventListener('click', onClick); + } + if (tabs && !nativeUi()) { + html.classList.add('nav-bars'); // the page's own bar knows the sections + tabs.addEventListener('click', onClick); + } + render(); + } + + function onClick(e) { + const b = e.target.closest('[data-nav]'); + if (!b) return; + e.preventDefault(); + go(b.dataset.nav); + window.scrollTo(0, 0); + } + + window.DioraNav = { + sections, + groups, + current: () => current, + go, + onChange(fn) { if (typeof fn === 'function') listeners.push(fn); }, + // For a bar drawn elsewhere (native-radio.js): it shows the sections itself. + barsReady() { html.classList.add('nav-bars'); render(); }, + route, + restore, + }; + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup); + else setup(); + window.addEventListener('popstate', route); + window.addEventListener('hashchange', route); + + // ---- styles ---------------------------------------------------------------------------- + + const style = document.createElement('style'); + style.textContent = [ + '#tabs .nav-wide, #tabs .nav-narrow { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }', + '#tabs .nav-narrow { display: none; }', + '#tabs .nav-group-label { margin: 0 4px 0 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }', + '@media (max-width: 720px) { #tabs .nav-wide { display: none; } #tabs .nav-narrow { display: flex; } }', + '#nav-segments { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 12px; scrollbar-width: none; }', + '#nav-segments[hidden] { display: none; }', + '#nav-segments::-webkit-scrollbar { display: none; }', + '.nav-seg { flex: none; min-height: 36px; padding: 0 14px; border-radius: 18px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }', + '.nav-seg.active { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }', + // Wide web page: every section is in the bar, the row would repeat it. + '@media (min-width: 721px) { html:not(.nui-radio) #nav-segments { display: none; } }', + // The sidebar (apps, wide): the sections are in it. + 'html.nui-sidebar #nav-segments { display: none; }', + ].join('\n'); + document.head.appendChild(style); +})(); diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index bad0ce3..6e230df 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -2063,6 +2063,8 @@ if (!start || typeof showTab !== 'function') return; const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null; if (nav && nav.type !== 'navigate') return; + // A link to a section (#news, #audiobooks …) is where one wanted to go. + if (window.DioraNav && location.hash.length > 1) return; const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio' : start === 'home' && document.getElementById('tab-home') ? 'home' : 'books'; if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab); diff --git a/static/js/sw.js b/static/js/sw.js index 1b3e3d8..ea59ca9 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v114'; +const CACHE = 'diora-v115'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', @@ -41,6 +41,7 @@ const SHELL = [ '/static/js/home.js', '/static/js/feeds.js', '/static/js/audiobooks.js', + '/static/js/nav.js', '/static/podcasts/native-podcast.js', '/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css', diff --git a/templates/radio/player.html b/templates/radio/player.html index 85a60ec..dcfef80 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -263,6 +263,8 @@ +{% comment %}Audiobooks (podcasts marked as one, docs/audiobooks.md): filled by static/js/audiobooks.js.{% endcomment %} +{% if user.is_authenticated %}{% endif %} {% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %} @@ -348,6 +350,7 @@ let USER_FOCUS_STATION = {{ focus_station_json|safe }}; {# app.js, split by area (docs/refactoring.md): classic scripts sharing one global scope, in this order. #} +