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>
271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
/*
|
|
* 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);
|
|
})();
|