345 lines
17 KiB
JavaScript
345 lines
17 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; Projekte; Suche across all of them. 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;
|
|
// A project's own window (desktop, docs/projects.md): it remembers nothing for the others.
|
|
const soloWindow = /[?&]project=\d+/.test(location.search);
|
|
|
|
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},
|
|
// Projects (docs/projects.md, projects.js): a tab beside Home · Lesen · Hören on a phone,
|
|
// at the foot of the sidebar and of the wide bar ("side"), above Suche.
|
|
{id: 'projekte', group: 'projekte', label: 'Projekte', icon: '◇', panel: 'projekte', account: true, side: true},
|
|
// The search across diora (docs/search.md, search.js): at the foot, above Abos & Quellen;
|
|
// on a phone a magnifier in the bar on top (the apps' section bars, the page's narrow bar).
|
|
{id: 'suche', group: 'more', label: 'Suche', icon: '⌕', panel: 'suche', account: true, footer: true, transient: true},
|
|
// Managing every subscription (docs/sources.md): at the foot of the sidebar, in the app
|
|
// menu on a phone — not a tab of its own.
|
|
{id: 'sources', group: 'more', label: 'Abos & Quellen', icon: '⚙', panel: 'sources', account: true, footer: true},
|
|
];
|
|
const GROUPS = [
|
|
{id: 'home', label: 'Home', icon: '⌂'},
|
|
{id: 'lesen', label: 'Lesen', icon: '▤'},
|
|
{id: 'hoeren', label: 'Hören', icon: '◉'},
|
|
{id: 'projekte', label: 'Projekte', icon: '◇'},
|
|
];
|
|
const byId = {};
|
|
SECTIONS.forEach(s => { byId[s.id] = s; });
|
|
|
|
// The icons (style B: bold and geometric, 2.4 px, square ends — at home with the monospace
|
|
// type and crisp on e-ink). A 24 grid; the parts marked "f" fill in when the section is
|
|
// the one shown (.active / .nui-active / .on around the icon).
|
|
const ICONS = {
|
|
home: '<path class="f" d="M6 10v10h12V10l-6-5z"/><path d="M4 11l8-7 8 7"/><path d="M6 10v10h12V10"/><path d="M10 20v-5h4v5"/>',
|
|
books: '<rect class="f" x="4" y="4" width="4" height="16" rx="1"/><rect x="4" y="4" width="4" height="16" rx="1"/>' +
|
|
'<rect x="9" y="6" width="4" height="14" rx="1"/><path d="M14.6 7.4l3.8-1 3 12.6-3.8 1z"/>',
|
|
magazines: '<rect class="f" x="5" y="3" width="14" height="5"/><rect x="5" y="3" width="14" height="18" rx="1.5"/>' +
|
|
'<path d="M5 8h14"/><path d="M8 12h8M8 15.5h8M8 19h4"/>',
|
|
news: '<path class="f" d="M7 8h7v4H7z"/><path d="M4 5h13v14H6a2 2 0 0 1-2-2z"/><path d="M17 9h3v8a2 2 0 0 1-2 2"/><path d="M7 8h7v4H7zM7 15h7"/>',
|
|
blogs: '<path class="f" d="M15 4l5 5-9 9H6v-5z"/><path d="M15 4l5 5-9 9H6v-5z"/><path d="M13 6l5 5"/><path d="M6 21h12"/>',
|
|
audiobooks: '<path d="M5 18v-6a7 7 0 0 1 14 0v6"/><rect class="f" x="3.5" y="13" width="4.5" height="7" rx="1.5"/>' +
|
|
'<rect x="3.5" y="13" width="4.5" height="7" rx="1.5"/><rect class="f" x="16" y="13" width="4.5" height="7" rx="1.5"/>' +
|
|
'<rect x="16" y="13" width="4.5" height="7" rx="1.5"/>',
|
|
podcasts: '<rect class="f" x="9" y="3" width="6" height="11" rx="3"/><rect x="9" y="3" width="6" height="11" rx="3"/>' +
|
|
'<path d="M5.5 11a6.5 6.5 0 0 0 13 0"/><path d="M12 17.5V21M9 21h6"/>',
|
|
radio: '<rect class="f" x="3" y="9" width="18" height="11" rx="2"/><rect x="3" y="9" width="18" height="11" rx="2"/>' +
|
|
'<circle cx="15.5" cy="14.5" r="2.6"/><path d="M6.5 13h4.5M6.5 16h4.5M7 9l9-5"/>',
|
|
ambient: '<path class="f" d="M3 14.5c2-2.6 4-2.6 6 0s4 2.6 6 0 4-2.6 6 0V20H3z"/>' +
|
|
'<path d="M3 9.5c2-2.6 4-2.6 6 0s4 2.6 6 0 4-2.6 6 0"/><path d="M3 14.5c2-2.6 4-2.6 6 0s4 2.6 6 0 4-2.6 6 0"/>',
|
|
focus: '<circle class="f" cx="12" cy="13" r="8"/><circle cx="12" cy="13" r="8"/><path d="M12 9v4l3 2M9 3h6"/>',
|
|
sources: '<circle class="f" cx="6" cy="18" r="2"/><circle cx="6" cy="18" r="1.6"/><path d="M5 11a8 8 0 0 1 8 8"/><path d="M5 5a14 14 0 0 1 14 14"/>',
|
|
projekte: '<path class="f" d="M4 7h16v13H4z"/><path d="M4 7h16v13H4z"/><path d="M9 7V4h6v3"/><path d="M4 12h16"/>',
|
|
suche: '<circle class="f" cx="10.5" cy="10.5" r="6.5"/><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5L21 21"/>',
|
|
menu: '<rect class="f" x="10.5" y="4" width="3" height="3"/><rect x="10.5" y="4" width="3" height="3"/>' +
|
|
'<rect x="10.5" y="10.5" width="3" height="3"/><rect x="10.5" y="17" width="3" height="3"/>',
|
|
};
|
|
ICONS.lesen = ICONS.books;
|
|
ICONS.hoeren = ICONS.audiobooks;
|
|
|
|
function icon(name) {
|
|
const body = ICONS[name];
|
|
return body ? '<svg class="dnav-ic" viewBox="0 0 24 24" aria-hidden="true">' + body + '</svg>' : '';
|
|
}
|
|
|
|
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
|
|
// The apps' sidebar: a "side" section stands at its foot, not as a group.
|
|
const sidebar = () => html.classList.contains('nui-sidebar');
|
|
// 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' && s.id !== 'sources') 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, svg: icon(s.id),
|
|
footer: !!s.footer || (!!s.side && sidebar())}));
|
|
}
|
|
|
|
function groups() {
|
|
const list = sections();
|
|
return GROUPS.map(g => ({id: g.id, label: g.label, icon: g.icon, svg: icon(g.id),
|
|
sections: list.filter(s => s.group === g.id && !s.footer).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 && !s.footer);
|
|
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];
|
|
if (!soloWindow) {
|
|
if (!s.transient) save('diora_nav_section', id); // the app opens where one was, not in the search
|
|
if (!s.footer) 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');
|
|
// A place inside the section (#projekte/12) stays: the section's script reads it.
|
|
const now = location.hash.slice(1);
|
|
if (now !== hash && now.indexOf(current + '/') !== 0) 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();
|
|
const foot = sections().filter(s => s.footer);
|
|
if (tabs && !nativeUi()) {
|
|
// Wide: every section under its group, the "side" ones at the end with the footer's;
|
|
// narrow: the groups (their sections on top).
|
|
const side = id => !!byId[id].side;
|
|
const footWide = sections().filter(s => side(s.id)).concat(foot);
|
|
tabs.innerHTML =
|
|
'<div class="nav-wide">' + gs.filter(g => !g.sections.every(side)).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('') +
|
|
(footWide.length ? '<span class="nav-foot">' + footWide.map(s => button(s.id, s.label, 'tab-btn', current === s.id)).join('') + '</span>' : '') +
|
|
'</div>' +
|
|
'<div class="nav-narrow">' + gs.map(g =>
|
|
button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') +
|
|
foot.map(s => '<button type="button" class="tab-btn nav-icon' + (current === s.id ? ' active' : '') +
|
|
'" data-nav="' + s.id + '" aria-label="' + esc(s.label) + '">' + icon(s.id) + '</button>').join('') + '</div>';
|
|
}
|
|
if (segs) {
|
|
const g = cur ? gs.filter(x => x.id === cur.group)[0] : null;
|
|
// Not where the bar has every section already (the sidebar, diora Reader's bar).
|
|
const show = !!g && g.sections.length > 1 && html.classList.contains('nav-bars') &&
|
|
!html.classList.contains('nav-flat') && !html.classList.contains('nui-sidebar');
|
|
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() && !readerApp()) { // diora Reader brings its own (feeds.js)
|
|
html.classList.add('nav-bars'); // the page's own bar knows the sections
|
|
tabs.addEventListener('click', onClick);
|
|
}
|
|
render();
|
|
// The section was restored while the page was still loading (99-init.js), before the
|
|
// areas' scripts (feeds.js, audiobooks.js …) were there to fill it: once more, now.
|
|
if (current) go(current);
|
|
}
|
|
|
|
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,
|
|
// A section's or group's icon as SVG (also 'menu'); the bars draw it.
|
|
icon,
|
|
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-foot { margin-left: auto; }',
|
|
'#tabs .nav-narrow .nav-icon { margin-left: auto; }',
|
|
'.dnav-ic { width: 22px; height: 22px; flex: none; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: square; stroke-linejoin: miter; vertical-align: middle; }',
|
|
'.dnav-ic .f { fill: none; stroke: none; }',
|
|
'.active .dnav-ic .f, .nui-active .dnav-ic .f, .on .dnav-ic .f { fill: currentColor; }',
|
|
'#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, html.nav-flat #nav-segments { display: none; }',
|
|
].join('\n');
|
|
document.head.appendChild(style);
|
|
})();
|