/* * 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: '', books: '' + '', magazines: '' + '', news: '', blogs: '', audiobooks: '' + '' + '', podcasts: '' + '', radio: '' + '', ambient: '' + '', focus: '', sources: '', projekte: '', suche: '', menu: '' + '', }; ICONS.lesen = ICONS.books; ICONS.hoeren = ICONS.audiobooks; function icon(name) { const body = ICONS[name]; return body ? '' : ''; } 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'); if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash); } function route() { const parts = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean); if (!parts.length || !resolve(parts[0])) return false; routing = true; try { go(parts[0]); if (parts[0] === 'radio' && parts[1] && typeof showRadioTab === 'function') showRadioTab(parts[1]); } finally { routing = false; } return true; } // At the start (99-init.js): the hash, else the section used last; offline the books. function restore(offline) { if (offline) { go('books'); return; } if (route()) return; const saved = load('diora_nav_section') || load('diora_active_tab') || 'radio'; if (!go(saved)) go(sections()[0] ? sections()[0].id : 'radio'); } // ---- the web page's bar and the segment row -------------------------------------------- function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c])); } function button(id, label, cls, on) { return ''; } let tabs = null; let segs = null; function render() { const cur = current && byId[current]; const gs = groups(); 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 = '' + ''; } 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); })();