diora-web/static/js/nav.js
mrwnslz c8fd139b44
Some checks failed
Build and push Docker image / build (push) Successful in 1m11s
Test / test (push) Successful in 10m32s
Test / e2e (push) Failing after 1m31s
Projekte: Radar – wöchentlich neue Papers (PubMed, arXiv, bioRxiv, medRxiv), von Claude im Batch sortiert, Entscheidungen verschlüsselt, → Quellen; Preset Neuropolitik aus F:\neuropolitik\tools; sw v145
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:43:43 +02:00

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 => ({'&': '&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();
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);
})();