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:
parent
12b651b772
commit
838457f6f9
11 changed files with 489 additions and 22 deletions
87
docs/navigation.md
Normal file
87
docs/navigation.md
Normal 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
61
e2e/navigation.spec.js
Normal 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
|
||||||
|
});
|
||||||
|
|
@ -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';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!window.DioraNav) { // else nav.js marks its own buttons
|
||||||
document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => {
|
document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => {
|
||||||
btn.classList.toggle('active', TOP_TABS[i] === name);
|
btn.classList.toggle('active', TOP_TABS[i] === name);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.setItem('diora_active_tab', name);
|
localStorage.setItem('diora_active_tab', name);
|
||||||
_currentTopTab = name;
|
_currentTopTab = name;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
271
static/js/nav.js
Normal 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 => ({'&': '&', '<': '<', '>': '>', '"': '"'}[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);
|
||||||
|
})();
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue