Navigation: Lesen und Hören (Phase 1, noch nicht veröffentlicht)

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 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-16 14:31:58 +02:00
parent 12b651b772
commit 838457f6f9
11 changed files with 489 additions and 22 deletions

87
docs/navigation.md Normal file
View file

@ -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_<group>`.
**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.

61
e2e/navigation.spec.js Normal file
View file

@ -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
});

View file

@ -4,7 +4,7 @@
// Tabs // 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']; const RADIO_SUB_TABS = ['search', 'saved', 'history'];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -17,7 +17,7 @@ let _routingFromHash = false;
let _currentTopTab = 'radio'; let _currentTopTab = 'radio';
function _syncHash() { function _syncHash() {
if (_routingFromHash) return; if (_routingFromHash || window.DioraNav) return; // the navigation keeps the hash (static/js/nav.js)
let hash = _currentTopTab; let hash = _currentTopTab;
if (_currentTopTab === 'radio') { if (_currentTopTab === 'radio') {
hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved'); hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved');
@ -26,6 +26,7 @@ function _syncHash() {
} }
function _routeFromHash() { function _routeFromHash() {
if (window.DioraNav) return; // nav.js routes (#news, #audiobooks …)
const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean); const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean);
if (!TOP_TABS.includes(tab)) return; if (!TOP_TABS.includes(tab)) return;
_routingFromHash = true; _routingFromHash = true;
@ -52,9 +53,11 @@ function showTab(name) {
if (panel) panel.style.display = (p === name) ? '' : 'none'; if (panel) panel.style.display = (p === name) ? '' : 'none';
}); });
document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => { if (!window.DioraNav) { // else nav.js marks its own buttons
btn.classList.toggle('active', TOP_TABS[i] === name); document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => {
}); btn.classList.toggle('active', TOP_TABS[i] === name);
});
}
localStorage.setItem('diora_active_tab', name); localStorage.setItem('diora_active_tab', name);
_currentTopTab = name; _currentTopTab = name;

View file

@ -205,6 +205,27 @@ async function toggleBookRead(bookId, currentlyRead) {
// free in /api/sync/. One level only: folders don't nest. // free in /api/sync/. One level only: folders don't nest.
let _currentBookFolder = null; // null = root view (folder tiles + unfiled books) 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) { function _recentlyReadBooks(books) {
return books return books
.filter(b => b.last_read) .filter(b => b.last_read)
@ -265,13 +286,18 @@ function _openBookFolder(name) {
renderBookList(_lastBookListData); renderBookList(_lastBookListData);
} }
function renderBookList(books) { function renderBookList(allBooks) {
const listEl = $('book-list'); const listEl = $('book-list');
if (!listEl) return; if (!listEl) return;
_lastBookListData = books; _lastBookListData = allBooks;
const books = _bookSectionFilter(allBooks);
if (!books.length) { if (!books.length) {
listEl.innerHTML = ''; listEl.innerHTML = window.DioraNav && allBooks.length
? (_bookSection === 'magazines'
? '<p class="muted">Noch keine Zeitschriften. Hefte, die in einem Ordner „Zeitschriften“ liegen, stehen hier (⋮ → Ordner zuweisen).</p>'
: '<p class="muted">Alle Bücher liegen bei den Zeitschriften.</p>')
: '';
return; return;
} }
@ -299,7 +325,7 @@ function renderBookList(books) {
const grouped = new Map(); const grouped = new Map();
for (const b of visible) { for (const b of visible) {
const key = b.folder || ''; const key = _bookSection === 'magazines' && _isMagazine(b) ? '' : (b.folder || '');
if (!grouped.has(key)) grouped.set(key, []); if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(b); grouped.get(key).push(b);
} }

View file

@ -71,7 +71,10 @@
// except when offline, where books (usable offline) always takes over. // except when offline, where books (usable offline) always takes over.
const offlineOverride = !navigator.onLine && IS_AUTHENTICATED; const offlineOverride = !navigator.onLine && IS_AUTHENTICATED;
const hashTab = (location.hash || '').replace(/^#/, '').split('/')[0]; 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(); _routeFromHash();
} else { } else {
const savedTab = localStorage.getItem('diora_active_tab') || 'radio'; const savedTab = localStorage.getItem('diora_active_tab') || 'radio';

View file

@ -1,6 +1,6 @@
/* /*
* Audiobooks in the library (docs/audiobooks.md): podcast subscriptions marked as a * Audiobooks, the section "Hörbücher" of Hören (docs/audiobooks.md, docs/navigation.md):
* Hörbuch or Hörspiel are listened to like a book, next to the e-books in "Bücher" — * 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, * "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 * 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 * (/podcasts/audiobooks/), the podcast host plays them (podcastPlayBook: the chapters in
@ -9,9 +9,8 @@
*/ */
(function () { (function () {
'use strict'; 'use strict';
const tab = document.getElementById('tab-books'); const tab = document.getElementById('tab-audiobooks');
const list = document.getElementById('book-list'); if (!tab) return;
if (!tab || !list) return;
if (typeof IS_AUTHENTICATED !== 'undefined' && !IS_AUTHENTICATED) return; if (typeof IS_AUTHENTICATED !== 'undefined' && !IS_AUTHENTICATED) return;
// The podcast host: the app's own on Android, podcasts-web.js elsewhere. // The podcast host: the app's own on Android, podcasts-web.js elsewhere.
@ -33,10 +32,7 @@
const shelf = document.createElement('div'); const shelf = document.createElement('div');
shelf.id = 'audiobook-shelf'; shelf.id = 'audiobook-shelf';
// Above the e-books, and above their "Gelesene anzeigen" switch (it is about them). tab.appendChild(shelf);
const filter = list.previousElementSibling;
const before = filter && filter.classList.contains('book-list-filter') ? filter : list;
list.parentNode.insertBefore(shelf, before);
// ---- helpers ------------------------------------------------------------------------ // ---- helpers ------------------------------------------------------------------------
@ -173,7 +169,12 @@
const books = st.books || []; const books = st.books || [];
const state = h ? podcastState() : null; const state = h ? podcastState() : null;
const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : []; 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 = '<p class="ab-empty">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.</p>';
return;
}
let html = ''; let html = '';
const go = books.filter(b => b.next && b.last_played_at)[0]; const go = books.filter(b => b.next && b.last_played_at)[0];
if (go) { if (go) {
@ -528,7 +529,7 @@
if (typeof showTab === 'function') { if (typeof showTab === 'function') {
window.showTab = function (name) { window.showTab = function (name) {
const r = showTab.apply(this, arguments); const r = showTab.apply(this, arguments);
if (name === 'books') loadList(); if (name === 'audiobooks') loadList();
else if (st.open != null) closeBook(); else if (st.open != null) closeBook();
return r; return r;
}; };

View file

@ -994,6 +994,14 @@
// ---- showing the tab // ---- showing the tab
let shownAt = 0; 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 () { window.__dioraFeedsShow = function () {
// Back after a while: fresh state (new briefing); otherwise as it was. // Back after a while: fresh state (new briefing); otherwise as it was.
if (!st.state || Date.now() - shownAt > 5 * 60000) load(); 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 { 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 { 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); }', '#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-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; }', '#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; }', '.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; }',

271
static/js/nav.js Normal file
View file

@ -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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
}
function button(id, label, cls, on) {
return '<button type="button" class="' + cls + (on ? ' active' : '') + '" data-nav="' + id + '">' + esc(label) + '</button>';
}
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 =
'<div class="nav-wide">' + gs.map(g => g.id === 'home'
? button('home', 'Home', 'tab-btn', current === 'home')
: '<span class="nav-group-label">' + esc(g.label) + '</span>' +
g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') + '</div>' +
'<div class="nav-narrow">' + gs.map(g =>
button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') + '</div>';
}
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);
})();

View file

@ -2063,6 +2063,8 @@
if (!start || typeof showTab !== 'function') return; if (!start || typeof showTab !== 'function') return;
const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null; const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null;
if (nav && nav.type !== 'navigate') return; 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' const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio'
: start === 'home' && document.getElementById('tab-home') ? 'home' : 'books'; : start === 'home' && document.getElementById('tab-home') ? 'home' : 'books';
if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab); if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab);

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v114'; const CACHE = 'diora-v115';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',
@ -41,6 +41,7 @@ const SHELL = [
'/static/js/home.js', '/static/js/home.js',
'/static/js/feeds.js', '/static/js/feeds.js',
'/static/js/audiobooks.js', '/static/js/audiobooks.js',
'/static/js/nav.js',
'/static/podcasts/native-podcast.js', '/static/podcasts/native-podcast.js',
'/static/podcasts/podcast-ui.css', '/static/podcasts/podcast-ui.css',
'/static/podcasts/podcasts-web.css', '/static/podcasts/podcasts-web.css',

View file

@ -263,6 +263,8 @@
<section class="tab-panel" id="tab-home" style="display:none;"></section> <section class="tab-panel" id="tab-home" style="display:none;"></section>
<!-- ===== FEEDS TAB ===== --> <!-- ===== FEEDS TAB ===== -->
{% comment %}Audiobooks (podcasts marked as one, docs/audiobooks.md): filled by static/js/audiobooks.js.{% endcomment %}
{% if user.is_authenticated %}<section class="tab-panel" id="tab-audiobooks" style="display:none;"></section>{% endif %}
{% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %} {% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %}
<section class="tab-panel" id="tab-feeds" style="display:none;"></section> <section class="tab-panel" id="tab-feeds" style="display:none;"></section>
@ -348,6 +350,7 @@
let USER_FOCUS_STATION = {{ focus_station_json|safe }}; let USER_FOCUS_STATION = {{ focus_station_json|safe }};
</script> </script>
{# app.js, split by area (docs/refactoring.md): classic scripts sharing one global scope, in this order. #} {# app.js, split by area (docs/refactoring.md): classic scripts sharing one global scope, in this order. #}
<script src="/static/js/nav.js"></script>
<script src="/static/js/app/01-core.js"></script> <script src="/static/js/app/01-core.js"></script>
<script src="/static/js/app/10-radio.js"></script> <script src="/static/js/app/10-radio.js"></script>
<script src="/static/js/app/14-focus.js"></script> <script src="/static/js/app/14-focus.js"></script>