diora-web/static/js/feeds.js

1202 lines
64 KiB
JavaScript
Raw Normal View History

/*
* The feed reader (the server side is the feeds app, /feeds/…): the "Feeds" tab
* with two parts that work differently on purpose —
* Nachrichten: stories (the same event from several sources is one card),
* the "Seit letztem Mal" briefing on top, old news simply goes away;
* Blogs: a plain list of unread articles, per blog if wanted.
* Articles open in the page (full text from the server), the browser only on request.
* Runs in the web page, the desktop app and both Android apps (in diora Reader,
* which has no tab bar, it brings a small Bücher/Feeds bar). Chrome-61-safe:
* the e-reader app transpiles it (compat61), so no ?. / ?? here.
*/
(function () {
'use strict';
const tab = document.getElementById('tab-feeds');
if (!tab) return;
const READER_APP = document.documentElement.classList.contains('nui-reader-app');
const st = {
view: 'news', // news | blogs | manage
state: null, // /feeds/state/
briefing: null,
stories: [], storiesMore: false,
category: '',
items: [], itemsMore: false,
blogSource: '', blogUnread: true,
blogFav: false, // main feed: only the favorite blogs
rareFirst: true, // within a day: blogs that seldom post first
blogFolder: null, // null = all blogs, '' = blogs without a folder, else a folder name
found: null, // discover results
opml: null, // parsed OPML import
editing: null, // source id being edited
loading: false,
error: '',
};
try {
const saved = localStorage.getItem('diora_feeds_view');
if (saved === 'news' || saved === 'blogs') st.view = saved;
if (localStorage.getItem('diora_feeds_rare') === '0') st.rareFirst = false;
if (localStorage.getItem('diora_feeds_unread') === '0') st.blogUnread = false;
} catch (e) {}
const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
async function api(path, body) {
const opts = {credentials: 'same-origin', headers: {}};
if (body !== undefined) {
opts.method = 'POST';
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
let data = null;
try { data = await r.json(); } catch (e) {}
if (!r.ok) throw new Error((data && data.error) || ('Fehler ' + r.status));
return data;
}
// ---- time
function when(iso) {
if (!iso) return '';
const d = new Date(iso);
const mins = Math.round((Date.now() - d.getTime()) / 60000);
if (mins < 1) return 'gerade eben';
if (mins < 60) return 'vor ' + mins + ' Min.';
if (mins < 6 * 60) return 'vor ' + Math.round(mins / 60) + ' Std.';
const hm = ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2);
const day = dayLabel(d);
return (day === 'Heute' ? '' : day + ', ') + hm;
}
function dayLabel(d) {
const today = new Date(); today.setHours(0, 0, 0, 0);
const that = new Date(d.getTime()); that.setHours(0, 0, 0, 0);
const diff = Math.round((today - that) / 86400000);
if (diff === 0) return 'Heute';
if (diff === 1) return 'Gestern';
return d.toLocaleDateString('de-DE', {weekday: 'short', day: 'numeric', month: 'short'});
}
// ---- loading
async function loadState() {
st.state = await api('/feeds/state/');
}
async function loadNews(more) {
const q = [];
if (more && st.stories.length) q.push('before=' + encodeURIComponent(st.stories[st.stories.length - 1].last_at));
if (st.category) q.push('category=' + encodeURIComponent(st.category));
const [news, briefing] = await Promise.all([
api('/feeds/news/' + (q.length ? '?' + q.join('&') : '')),
more ? Promise.resolve(st.briefing) : api('/feeds/briefing/'),
]);
st.stories = more ? st.stories.concat(news.stories) : news.stories;
st.storiesMore = news.more;
st.briefing = briefing;
}
async function loadBlogs(more) {
const q = [];
if (st.blogSource) q.push('source=' + st.blogSource);
else if (st.blogFolder !== null) q.push('folder=' + encodeURIComponent(st.blogFolder));
if (st.blogUnread) q.push('unread=1');
if (st.blogFav && !st.blogSource && st.blogFolder === null) q.push('fav=1');
if (more && st.items.length) q.push('before=' + encodeURIComponent(st.items[st.items.length - 1].published));
const data = await api('/feeds/blogs/' + (q.length ? '?' + q.join('&') : ''));
st.items = more ? st.items.concat(data.items) : data.items;
st.itemsMore = data.more;
}
async function load(more) {
st.loading = true;
st.error = '';
render();
try {
if (!more) await loadState();
if (st.view === 'news') await loadNews(more);
else if (st.view === 'blogs') await loadBlogs(more);
} catch (e) {
st.error = e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message;
}
st.loading = false;
render();
}
// ---- rendering
function sources(kind) {
return ((st.state && st.state.sources) || []).filter(s => !kind || s.kind === kind);
}
function render() {
const s = st.state;
const newsN = s ? s.news_new : 0;
const blogN = s ? s.blogs_new : 0; // new = younger than a day, unread (hidden blogs not counted)
let body;
if (st.view === 'manage') body = renderManage();
else if (!s && st.loading) body = '<p class="fd-note">Lädt …</p>';
else if (st.view === 'news') body = renderNews();
else body = renderBlogs();
tab.innerHTML =
'<div class="fd">' +
'<div class="fd-top">' +
(st.view === 'manage'
? '<button type="button" class="fd-back" data-act="close-manage">‹ Zurück</button><h2 class="fd-h">Feeds verwalten</h2>'
: '<div class="fd-seg">' +
'<button type="button" data-view="news" class="' + (st.view === 'news' ? 'on' : '') + '">Nachrichten' +
(newsN ? ' <span class="fd-count">' + newsN + '</span>' : '') + '</button>' +
'<button type="button" data-view="blogs" class="' + (st.view === 'blogs' ? 'on' : '') + '">Blogs' +
(blogN ? ' <span class="fd-count">' + blogN + '</span>' : '') + '</button>' +
'</div>' +
'<div class="fd-actions">' +
'<button type="button" class="fd-icon" data-act="refresh" title="Jetzt abrufen" aria-label="Jetzt abrufen">' + (st.loading ? '…' : '↻') + '</button>' +
'<button type="button" class="fd-icon" data-act="manage" title="Feeds verwalten" aria-label="Feeds verwalten">⚙</button>' +
'</div>') +
'</div>' +
(st.error ? '<p class="fd-error">' + esc(st.error) + '</p>' : '') +
body + '</div>';
}
function storyMeta(x) {
const lead = x.lead || {};
const others = x.sources - 1;
return esc(lead.source_title) + (others > 0 ? ' <span class="fd-more-src">+' + others + '</span>' : '') +
' · ' + esc(when(x.last_at));
}
function storyRow(x) {
const lead = x.lead || {};
return '<button type="button" class="fd-row' + (x.unread ? '' : ' fd-read') + '" data-story="' + x.id + '">' +
'<span class="fd-row-text"><span class="fd-row-title">' + esc(lead.title) + '</span>' +
'<span class="fd-row-meta">' + storyMeta(x) + '</span></span>' +
(x.sources > 1 ? '<span class="fd-badge" title="' + x.sources + ' Quellen">' + x.sources + '</span>' : '') +
'</button>';
}
function renderBriefing() {
const b = st.briefing;
if (!b || !b.total) return '';
let h = '<section class="fd-brief">' +
'<div class="fd-brief-head"><h2>Seit letztem Mal</h2>' +
'<span class="fd-note">' + b.total + (b.total === 1 ? ' Meldung' : ' Meldungen') + ' seit ' + esc(when(b.since)) + '</span></div>';
if (b.top.length) {
h += '<h3>Top-Themen</h3>' + b.top.map(storyRow).join('');
}
b.sections.forEach(sec => {
h += '<h3>' + esc(sec.category) + ' <small>' + sec.count + '</small></h3>' + sec.stories.map(storyRow).join('');
if (sec.count > sec.stories.length) {
h += '<button type="button" class="fd-link" data-cat="' + esc(sec.category) + '">' +
(sec.count - sec.stories.length) + ' weitere in ' + esc(sec.category) + ' ›</button>';
}
});
h += '<button type="button" class="btn btn-primary fd-seen" data-act="seen">Alles gelesen ✓</button></section>';
return h;
}
function renderNews() {
if (!sources('news').length) {
return '<div class="fd-empty"><p>Noch keine Nachrichtenquellen.</p>' +
'<button type="button" class="btn btn-primary" data-act="manage">Feed hinzufügen</button></div>';
}
const cats = (st.state.categories || []);
let h = renderBriefing();
if (cats.length > 1) {
h += '<div class="fd-chips">' +
'<button type="button" class="fd-chip' + (st.category ? '' : ' on') + '" data-cat="">Alle</button>' +
cats.map(c => '<button type="button" class="fd-chip' + (st.category === c ? ' on' : '') + '" data-cat="' + esc(c) + '">' + esc(c) + '</button>').join('') +
'</div>';
}
if (!st.stories.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : 'Gerade keine Nachrichten.') + '</p>';
let day = '';
st.stories.forEach(x => {
const d = dayLabel(new Date(x.last_at));
if (d !== day) {
day = d;
h += '<h3 class="fd-day">' + esc(d) + '</h3>';
}
const lead = x.lead || {};
h += '<button type="button" class="fd-card' + (x.unread ? '' : ' fd-read') + '" data-story="' + x.id + '">' +
(lead.image_url ? '<img class="fd-thumb" loading="lazy" alt="" src="' + esc(lead.image_url) + '">' : '') +
'<span class="fd-card-text"><span class="fd-card-title">' + esc(lead.title) + '</span>' +
(lead.summary ? '<span class="fd-card-sum">' + esc(lead.summary) + '</span>' : '') +
'<span class="fd-row-meta">' + storyMeta(x) + '</span></span></button>';
});
if (st.storiesMore) h += '<button type="button" class="btn fd-loadmore" data-act="more">Ältere laden</button>';
return h;
}
// ---- blogs: days, favorites first, rare blogs first (after Kino's feed)
const NEW_MS = 24 * 3600000;
const FAV_PER_DAY = 5; // posts of one favorite blog that go first per section
function inFolderOf(b, f) {
const list = b.folders || [];
if (f === '') return !list.length;
const k = f.toLowerCase();
return list.some(x => x.toLowerCase() === k);
}
function isNew(i, src) {
return !i.read && Date.now() - new Date(i.published).getTime() < NEW_MS && !(src && src.hidden);
}
function readingTime(i) {
return i.words >= 120 ? Math.max(1, Math.round(i.words / 230)) + ' Min.' : '';
}
function section(iso) {
const d = new Date(iso);
const label = dayLabel(d);
if (label === 'Heute' || label === 'Gestern') return label;
const days = (Date.now() - d.getTime()) / 86400000;
if (days < 7) return 'Diese Woche';
if (days < 31) return 'Diesen Monat';
return 'Älter';
}
// Posts into sections (newest first); in each, up to FAV_PER_DAY posts per
// favorite blog first, then the rest — blogs that post seldom ahead of the
// busy ones ("Seltene zuerst"), or simply by time.
function blogSections(items, byId) {
const order = ['Heute', 'Gestern', 'Diese Woche', 'Diesen Monat', 'Älter'];
const groups = {};
items.forEach(i => { (groups[section(i.published)] = groups[section(i.published)] || []).push(i); });
const newest = (a, b) => new Date(b.published) - new Date(a.published);
return order.filter(k => groups[k]).map(k => {
const list = groups[k].slice().sort(newest);
const favCount = {};
const fav = [];
const rest = [];
list.forEach(i => {
const src = byId[i.source];
if (src && src.fav && (favCount[i.source] || 0) < FAV_PER_DAY) {
favCount[i.source] = (favCount[i.source] || 0) + 1;
fav.push(i);
} else {
rest.push(i);
}
});
if (st.rareFirst) {
const gap = i => { const src = byId[i.source]; return (src && src.interval_days) || 0; };
rest.sort((a, b) => (gap(b) - gap(a)) || newest(a, b));
}
return {label: k, items: fav.concat(rest)};
});
}
function renderBlogs() {
const blogs = sources('blog');
if (!blogs.length) {
return '<div class="fd-empty"><p>Noch keine Blogs abonniert.</p>' +
'<button type="button" class="btn btn-primary" data-act="manage">Feed hinzufügen</button></div>';
}
const byId = {};
blogs.forEach(b => { byId[b.id] = b; });
// Folders (a blog's category): chips with their unread count, a dot when something is new.
const folders = (st.state.folders || []);
const inFolder = f => blogs.filter(b => inFolderOf(b, f));
const sum = (list, key) => list.reduce((n, b) => n + b[key], 0);
const visible = blogs.filter(b => !b.hidden);
let h = '';
if (folders.length) {
const chip = (value, label, list) => '<button type="button" class="fd-chip' + (st.blogFolder === value ? ' on' : '') +
'" data-folder="' + (value === null ? '*' : esc(value)) + '">' +
(sum(list, 'new') ? '<span class="fd-dot"></span>' : '') + esc(label) +
(sum(list, 'unread') ? ' <small>' + sum(list, 'unread') + '</small>' : '') + '</button>';
h += '<div class="fd-chips">' + chip(null, 'Alle', visible) +
folders.map(f => chip(f, f, inFolder(f))).join('') +
(blogs.some(b => !(b.folders || []).length) ? chip('', 'Ohne Ordner', inFolder('')) : '') + '</div>';
}
const main = st.blogFolder === null;
const shown = main ? blogs : inFolder(st.blogFolder);
h += '<div class="fd-chips fd-modes">' +
'<button type="button" class="fd-chip' + (st.blogUnread ? ' on' : '') + '" data-act="mode-unread">Ungelesen</button>' +
'<button type="button" class="fd-chip' + (st.blogUnread ? '' : ' on') + '" data-act="mode-all">Alle</button>' +
(main ? '<button type="button" class="fd-chip' + (st.blogFav ? ' on' : '') + '" data-act="mode-fav">★ Favoriten</button>' : '') +
'<button type="button" class="fd-chip' + (st.rareFirst ? ' on' : '') + '" data-act="mode-rare" title="Blogs, die selten schreiben, zuerst">Seltene zuerst</button>' +
'</div>';
h += '<div class="fd-filter">' +
'<select class="search-input fd-select" data-act="blog-source"><option value="">' +
(main ? 'Alle Blogs' : 'Alle in ' + esc(st.blogFolder || 'Ohne Ordner')) + '</option>' +
shown.map(b => '<option value="' + b.id + '"' + (String(b.id) === String(st.blogSource) ? ' selected' : '') + '>' +
(b.fav ? '★ ' : '') + esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + (b.hidden ? ' – ausgeblendet' : '') +
'</option>').join('') + '</select>' +
(st.items.some(i => !i.read) ? '<button type="button" class="btn fd-allread" data-act="blogs-read">Alle gelesen</button>' : '') +
'</div>';
const one = st.blogSource ? byId[st.blogSource] : null;
if (one) {
// The selected blog's switches.
h += '<div class="fd-blogbar">' +
'<button type="button" class="fd-chip' + (one.fav ? ' on' : '') + '" data-flag="fav" data-id="' + one.id + '">' + (one.fav ? '★ Favorit' : '☆ Favorit') + '</button>' +
'<button type="button" class="fd-chip' + (one.hidden ? ' on' : '') + '" data-flag="hidden" data-id="' + one.id + '" title="Nicht unter „Alle“, nur im Ordner">' +
(one.hidden ? 'Im Hauptfeed ausgeblendet' : 'Im Hauptfeed ausblenden') + '</button>' +
'<button type="button" class="fd-chip' + (one.notify ? ' on' : '') + '" data-flag="notify" data-id="' + one.id + '" title="Benachrichtigung bei neuen Posts (App)">' +
(one.notify ? '🔔 Glocke an' : '🔕 Glocke') + '</button>' +
'<button type="button" class="fd-chip" data-edit-open="' + one.id + '">⚙</button></div>';
}
if (!st.items.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '</p>';
const reading = st.items.filter(i => !i.read && i.progress >= 0.05 && i.progress < 0.9)
.sort((a, b) => b.progress - a.progress).slice(0, 3);
if (reading.length) {
h += '<h3 class="fd-day">Weiterlesen</h3>' + reading.map(i =>
'<button type="button" class="fd-post fd-continue" data-item="' + i.id + '">' +
'<span class="fd-card-title">' + esc(i.title) + '</span>' +
'<span class="fd-row-meta">' + esc(i.source_title) + ' · ' + Math.round(i.progress * 100) + ' % gelesen</span>' +
'<span class="fd-prog"><span style="width:' + Math.round(i.progress * 100) + '%"></span></span></button>').join('');
}
blogSections(st.items, byId).forEach(sec => {
h += '<h3 class="fd-day">' + esc(sec.label) + '</h3>';
h += sec.items.map(i => {
const src = byId[i.source];
const rt = readingTime(i);
return '<button type="button" class="fd-post' + (i.read ? ' fd-read' : '') + (src && src.hidden ? ' fd-hidden' : '') + '" data-item="' + i.id + '">' +
'<span class="fd-card-title">' + (isNew(i, src) ? '<span class="fd-new">NEU</span> ' : '') + (i.starred ? '★ ' : '') + esc(i.title) + '</span>' +
'<span class="fd-row-meta">' + (src && src.fav ? '★ ' : '') + esc(i.source_title) + ' · ' + esc(when(i.published)) + (rt ? ' · ' + rt : '') + '</span>' +
(i.summary ? '<span class="fd-card-sum">' + esc(i.summary) + '</span>' : '') +
(!i.read && i.progress > 0.02 ? '<span class="fd-prog"><span style="width:' + Math.round(i.progress * 100) + '%"></span></span>' : '') +
'</button>';
}).join('');
});
if (st.itemsMore) h += '<button type="button" class="btn fd-loadmore" data-act="more">Ältere laden</button>';
return h;
}
function kindSelect(name, value) {
return '<select class="search-input" name="' + name + '">' +
'<option value="news"' + (value === 'news' ? ' selected' : '') + '>Nachrichten</option>' +
'<option value="blog"' + (value === 'blog' ? ' selected' : '') + '>Blog</option></select>';
}
function categoryInput(value) {
const cats = ((st.state && st.state.categories) || []).concat((st.state && st.state.folders) || []);
return '<input class="search-input" name="category" list="fd-cats" placeholder="z. B. Politik / Philosophie" value="' + esc(value || '') + '">' +
'<datalist id="fd-cats">' + cats.map(c => '<option value="' + esc(c) + '">').join('') + '</datalist>';
}
function renderManage() {
const s = st.state || {sources: [], settings: {}};
let h = '<section class="fd-box"><h3>Feed hinzufügen</h3>' +
'<form class="fd-add" data-form="discover"><input class="search-input" name="url" type="url" inputmode="url" ' +
'placeholder="Adresse einer Seite oder eines Feeds" required>' +
'<button class="btn btn-primary" type="submit">Suchen</button></form>';
if (st.found) {
h += st.found.map((f, n) =>
'<form class="fd-found" data-form="subscribe" data-n="' + n + '">' +
'<div class="fd-found-title">' + esc(f.title) + '</div><div class="fd-note fd-url">' + esc(f.url) + '</div>' +
(f.samples.length ? '<ul class="fd-samples">' + f.samples.slice(0, 3).map(t => '<li>' + esc(t) + '</li>').join('') + '</ul>' : '') +
(f.subscribed ? '<p class="fd-note">Schon abonniert.</p>' :
'<div class="fd-grid"><label>Art ' + kindSelect('kind', f.suggested_kind) + '</label>' +
'<label class="fd-cat-field">' + (f.suggested_kind === 'news' ? 'Ressort' : 'Ordner') + ' ' + categoryInput('') + '</label></div>' +
'<button class="btn btn-primary" type="submit">Abonnieren</button>') +
'</form>').join('');
}
h += '</section>';
const news = s.sources.filter(x => x.kind === 'news');
const blogs = s.sources.filter(x => x.kind === 'blog');
const group = (title, list) => !list.length ? '' :
'<section class="fd-box"><h3>' + title + ' <small>' + list.length + '</small></h3>' +
list.map(x => x.id === st.editing ? editForm(x) :
'<button type="button" class="fd-src" data-edit="' + x.id + '">' +
'<span class="fd-src-title">' + (x.fav ? '★ ' : '') + (x.notify ? '🔔 ' : '') + esc(x.title) + (x.hidden ? ' <small class="fd-note">ausgeblendet</small>' : '') + '</span>' +
'<span class="fd-row-meta">' + (x.kind === 'news' ? esc(x.category || 'Allgemein') + ' · ' : ((x.folders || []).length ? esc(x.folders.join(', ')) + ' · ' : '')) +
(x.unread ? x.unread + ' ungelesen' : '') +
(x.error_count >= 3 ? ' <span class="fd-warn" title="' + esc(x.last_error) + '">⚠ Abruf schlägt fehl</span>' : '') +
'</span></button>').join('') + '</section>';
h += group('Nachrichten', news.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.title.localeCompare(b.title)));
const firstFolder = b => ((b.folders || [])[0] || '~').toLowerCase();
h += group('Blogs', blogs.sort((a, b) => firstFolder(a).localeCompare(firstFolder(b)) || a.title.localeCompare(b.title)));
// The folders themselves: rename, dissolve (the blogs stay).
const folders = (s.folders || []);
if (folders.length) {
h += '<section class="fd-box"><h3>Ordner <small>' + folders.length + '</small></h3>' + folders.map(f =>
'<div class="fd-folder-row"><span>' + esc(f) + ' <small class="fd-note">' + blogs.filter(b => inFolderOf(b, f)).length + ' Blogs</small></span>' +
'<span class="fd-buttons"><button type="button" class="btn" data-act="folder-rename" data-name="' + esc(f) + '">Umbenennen</button>' +
'<button type="button" class="btn" data-act="folder-dissolve" data-name="' + esc(f) + '">Auflösen</button></span></div>').join('') +
'<p class="fd-note">Tipp: lange auf einen Ordner in der Blog-Ansicht drücken (oder Rechtsklick) öffnet dasselbe.</p></section>';
}
h += '<section class="fd-box"><h3>Import &amp; Einstellungen</h3>' +
'<label class="btn fd-file">OPML importieren …<input type="file" accept=".opml,.xml,text/xml,text/x-opml" data-act="opml" hidden></label> ' +
(window.DioraNative ? '' : '<a class="btn" href="/feeds/export.opml">OPML exportieren</a>');
if (st.opml) h += renderOpml();
const keep = (s.settings && s.settings.news_keep_days) || 2;
h += '<label class="fd-keep">Nachrichten behalten: <select class="search-input" data-act="keep">' +
[1, 2, 3, 5, 7, 14].map(d => '<option value="' + d + '"' + (d === keep ? ' selected' : '') + '>' + d + (d === 1 ? ' Tag' : ' Tage') + '</option>').join('') +
'</select></label><p class="fd-note">Ältere Nachrichten verschwinden von selbst (gemerkte bleiben). Blogartikel bleiben, bis sie gelesen sind.</p></section>';
return h;
}
function editForm(x) {
return '<form class="fd-edit" data-form="edit" data-id="' + x.id + '">' +
'<label>Name <input class="search-input" name="title" value="' + esc(x.title) + '"></label>' +
'<div class="fd-note fd-url">' + esc(x.url) + '</div>' +
(x.last_error ? '<p class="fd-warn">Letzter Fehler: ' + esc(x.last_error) + '</p>' : '') +
'<div class="fd-grid"><label>Art ' + kindSelect('kind', x.kind) + '</label>' +
(x.kind === 'news' ? '<label class="fd-cat-field">Ressort ' + categoryInput(x.category) + '</label>' : folderPicker(x)) +
(x.kind === 'blog' ? '<label class="fd-check"><input type="checkbox" name="fav"' + (x.fav ? ' checked' : '') + '> Favorit</label>' +
'<label class="fd-check"><input type="checkbox" name="hidden"' + (x.hidden ? ' checked' : '') + '> Im Hauptfeed ausblenden (nur im Ordner)</label>' +
'<label class="fd-check"><input type="checkbox" name="notify"' + (x.notify ? ' checked' : '') + '> Benachrichtigen bei neuen Posts</label>' : '') +
'<label>Ganzer Artikel <select class="search-input" name="fulltext">' +
[['auto', 'Automatisch'], ['always', 'Immer holen'], ['never', 'Nur Feed-Text']].map(o =>
'<option value="' + o[0] + '"' + (x.fulltext === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') +
'</select></label></div>' +
'<div class="fd-buttons"><button class="btn btn-primary" type="submit">Speichern</button>' +
'<button class="btn" type="button" data-act="cancel-edit">Abbrechen</button>' +
'<button class="btn btn-danger" type="button" data-act="delete" data-id="' + x.id + '">Abbestellen</button></div></form>' +
loginForm(x);
}
function folderPicker(x) {
const all = (st.state && st.state.folders) || [];
return '<fieldset class="fd-folders"><legend>Ordner</legend>' +
all.map(f => '<label class="fd-check"><input type="checkbox" name="folder" value="' + esc(f) + '"' +
(inFolderOf(x, f) ? ' checked' : '') + '> ' + esc(f) + '</label>').join('') +
'<input class="search-input" name="newfolder" maxlength="40" placeholder="Neuer Ordner"></fieldset>';
}
// A paywalled site (junge Welt …): the subscriber login, kept encrypted on the
// server, which then fetches whole articles. The password never comes back.
function loginForm(x) {
const l = x.login;
if (!l) return '';
let h = '<form class="fd-edit fd-login" data-form="login" data-id="' + x.id + '">' +
'<h4>Abo-Anmeldung bei ' + esc(l.site) + '</h4>';
if (l.username) {
h += '<p>' + (l.ok ? '✓ Angemeldet als ' : '⚠ Anmeldung klappt nicht: ') + esc(l.ok ? l.username : (l.error || l.username)) + '</p>' +
'<div class="fd-buttons"><button class="btn" type="button" data-act="login-delete" data-id="' + x.id + '">Zugangsdaten löschen</button></div>';
if (l.ok) return h + '</form>';
h += '<p class="fd-note">Neu eingeben:</p>';
}
return h +
'<label>Benutzername <input class="search-input" name="username" autocomplete="off" autocapitalize="none" required></label>' +
'<label>Passwort <input class="search-input" name="password" type="password" autocomplete="new-password" required></label>' +
'<p class="fd-note">Das Passwort liegt verschlüsselt auf dem diora-Server und wird nie wieder angezeigt. ' +
'Damit holt der Server die ganzen Artikel statt nur des freien Teils.</p>' +
'<div class="fd-buttons"><button class="btn btn-primary" type="submit">Anmelden</button></div></form>';
}
function renderOpml() {
const groups = st.opml;
const n = groups.reduce((a, g) => a + (g.kind === 'skip' ? 0 : g.feeds.length), 0);
return '<div class="fd-opml"><p class="fd-note">Was ist was? Pro Ordner:</p>' +
groups.map((g, i) =>
'<div class="fd-opml-row"><span>' + esc(g.name || 'Ohne Ordner') + ' <small>(' + g.feeds.length + ')</small></span>' +
'<select class="search-input" data-opml="' + i + '">' +
[['news', 'Nachrichten'], ['blog', 'Blogs'], ['skip', 'Nicht übernehmen']].map(o =>
'<option value="' + o[0] + '"' + (g.kind === o[0] ? ' selected' : '') + '>' + o[1] + '</option>').join('') +
'</select></div>').join('') +
'<button type="button" class="btn btn-primary" data-act="opml-import"' + (n ? '' : ' disabled') + '>' + n + ' Feeds übernehmen</button> ' +
'<button type="button" class="btn" data-act="opml-cancel">Abbrechen</button></div>';
}
// ---- OPML
function parseOpml(text) {
const doc = new DOMParser().parseFromString(text, 'text/xml');
const groups = {};
const order = [];
Array.prototype.forEach.call(doc.querySelectorAll('outline[xmlUrl], outline[xmlurl]'), o => {
const url = o.getAttribute('xmlUrl') || o.getAttribute('xmlurl');
const parent = o.parentNode && o.parentNode.nodeName.toLowerCase() === 'outline' ? o.parentNode : null;
const name = parent ? (parent.getAttribute('text') || parent.getAttribute('title') || '') : '';
if (!groups[name]) {
groups[name] = {name, feeds: [],
kind: /nachricht|news|politik|zeitung|presse|aktuell|welt|wirtschaft|tech/i.test(name) ? 'news' : 'blog'};
order.push(name);
}
groups[name].feeds.push({url, title: o.getAttribute('title') || o.getAttribute('text') || url});
});
return order.map(n => groups[n]);
}
// ---- article view
let reader = null; // the overlay element
let readerList = []; // what "next" walks through (blog items)
let readerStory = null;
let readerItem = null;
function fontSize() {
let v = 18;
try { v = parseInt(localStorage.getItem('diora_feeds_font') || '18', 10) || 18; } catch (e) {}
return Math.max(14, Math.min(26, v));
}
function ensureReader() {
if (reader) return reader;
reader = document.createElement('div');
reader.id = 'fd-article';
reader.hidden = true;
reader.addEventListener('click', onReaderClick);
reader.addEventListener('scroll', () => {
clearTimeout(progressTimer);
progressTimer = setTimeout(saveProgress, 1200);
}, {passive: true});
document.body.appendChild(reader);
return reader;
}
function readerOpen() {
return !!(reader && !reader.hidden);
}
function closeReader() {
if (!reader) return;
saveProgress();
reader.hidden = true;
reader.innerHTML = '';
document.documentElement.classList.remove('fd-reading');
readerItem = readerStory = null;
render();
}
// How far the open article is read (0-1); saved now and then and when leaving it.
let progressSent = 0;
let progressTimer = null;
function readProgress() {
if (!reader || reader.hidden) return 0;
const room = reader.scrollHeight - reader.clientHeight;
return room <= 40 ? 1 : Math.min(1, reader.scrollTop / room);
}
function saveProgress() {
const i = readerItem;
if (!i) return;
const p = Math.round(readProgress() * 100) / 100;
if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return;
progressSent = p;
i.progress = p;
st.items.forEach(x => { if (x.id === i.id) x.progress = p; });
if (p >= 0.9 && !i.read) {
i.read = true;
markLocal([i.id], true);
}
api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {});
}
async function openItem(id, story) {
ensureReader();
if (readerItem) saveProgress();
readerStory = story || null;
reader.hidden = false;
document.documentElement.classList.add('fd-reading');
reader.innerHTML = readerShell('<p class="fd-note">Lädt …</p>');
reader.scrollTop = 0;
try {
const data = await api('/feeds/items/' + id + '/');
readerItem = data.item;
reader.innerHTML = readerShell(articleHtml(readerItem));
reader.scrollTop = 0;
progressSent = readerItem.progress || 0;
if (readerItem.kind === 'news') {
// News is read once opened; a blog post once read to 90 % (the server keeps where).
if (!readerItem.read) {
readerItem.read = true;
markLocal([id], true);
api('/feeds/items/read/', {ids: [id], read: true}).catch(() => {});
}
} else {
if (progressSent > 0.02 && progressSent < 0.9) {
// Back where reading stopped (on any device).
requestAnimationFrame(() => { reader.scrollTop = progressSent * (reader.scrollHeight - reader.clientHeight); });
} else {
setTimeout(saveProgress, 1500); // short enough to fit: read at once
}
}
} catch (e) {
reader.innerHTML = readerShell('<p class="fd-error">' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '</p>');
}
}
function readerShell(inner) {
const i = readerItem;
return '<div class="fd-art-bar">' +
'<button type="button" class="fd-icon" data-r="close" aria-label="Zurück" title="Zurück">‹</button>' +
'<span class="fd-art-src">' + esc(i ? i.source_title : '') + '</span>' +
'<button type="button" class="fd-icon" data-r="font" aria-label="Schriftgröße" title="Schriftgröße">Aa</button>' +
(i ? '<button type="button" class="fd-icon' + (i.starred ? ' on' : '') + '" data-r="star" aria-label="Merken" title="Merken">' + (i.starred ? '★' : '☆') + '</button>' : '') +
(i && i.url ? '<a class="fd-icon" href="' + esc(i.url) + '" target="_blank" rel="noopener" aria-label="Im Browser öffnen" title="Im Browser öffnen">↗</a>' : '') +
'</div><div class="fd-art" style="font-size:' + fontSize() + 'px">' + inner + '</div>';
}
function articleHtml(i) {
let h = '';
const story = readerStory;
if (story && story.items.length > 1) {
h += '<div class="fd-sources"><span class="fd-note">' + story.items.length + ' Berichte:</span>' +
story.items.map(x => '<button type="button" class="fd-chip' + (x.id === i.id ? ' on' : '') + '" data-r="item" data-id="' + x.id + '">' +
esc(x.source_title) + '</button>').join('') + '</div>';
}
h += '<h1 class="fd-art-title">' + esc(i.title) + '</h1>' +
'<p class="fd-art-meta">' + [i.author, i.source_title, when(i.published)].filter(Boolean).map(esc).join(' · ') + '</p>';
const content = i.content || '';
if (i.image_url && content.indexOf(i.image_url) < 0) h += '<img class="fd-art-img" alt="" src="' + esc(i.image_url) + '">';
h += '<div class="fd-art-body">' + (content || '<p>' + esc(i.summary) + '</p>') + '</div>';
if (i.paywalled) {
h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
') holt diora den ganzen.</p>';
} else if (!i.content_full && i.url && (content.replace(/<[^>]+>/g, '').length < 700)) {
h += '<p class="fd-note">Der Feed bringt nur einen Anriss, der ganze Artikel ließ sich nicht laden.</p>';
}
if (story && story.items.length > 1) {
h += '<h3 class="fd-art-more">Weitere Berichte</h3>' + story.items.filter(x => x.id !== i.id).map(x =>
'<button type="button" class="fd-other" data-r="item" data-id="' + x.id + '">' +
'<span class="fd-other-title">' + esc(x.title) + '</span>' +
'<span class="fd-row-meta">' + esc(x.source_title) + ' · ' + esc(when(x.published)) + '</span></button>').join('');
}
h += '<div class="fd-art-foot">';
if (i.url) h += '<a class="btn" href="' + esc(i.url) + '" target="_blank" rel="noopener">Original öffnen ↗</a>';
const next = nextItem(i);
if (next) h += '<button type="button" class="btn btn-primary" data-r="item" data-id="' + next.id + '">Nächster Artikel ›</button>';
h += '</div>';
return h;
}
function nextItem(i) {
if (readerStory) return null;
const list = readerList;
const at = list.findIndex(x => x.id === i.id);
for (let n = at + 1; n < list.length; n++) if (!list[n].read) return list[n];
return null;
}
function markLocal(ids, read) {
st.items.forEach(i => { if (ids.indexOf(i.id) >= 0) i.read = read; });
st.stories.forEach(s => {
s.items.forEach(i => { if (ids.indexOf(i.id) >= 0) i.read = read; });
s.unread = s.items.filter(i => !i.read).length;
});
}
function onReaderClick(e) {
const b = e.target.closest('[data-r]');
if (!b) {
// Links in the article open outside (the app hands them to the browser).
const a = e.target.closest('.fd-art-body a[href]');
if (a) { a.target = '_blank'; a.rel = 'noopener'; }
return;
}
const r = b.dataset.r;
if (r === 'close') closeReader();
else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
else if (r === 'font') {
let v = fontSize() + 2;
if (v > 26) v = 14;
try { localStorage.setItem('diora_feeds_font', String(v)); } catch (err) {}
const art = reader.querySelector('.fd-art');
if (art) art.style.fontSize = v + 'px';
} else if (r === 'star' && readerItem) {
readerItem.starred = !readerItem.starred;
api('/feeds/items/' + readerItem.id + '/star/', {starred: readerItem.starred}).catch(() => {});
b.textContent = readerItem.starred ? '★' : '☆';
b.classList.toggle('on', readerItem.starred);
st.items.forEach(i => { if (i.id === readerItem.id) i.starred = readerItem.starred; });
}
}
async function openStory(id) {
let story = st.stories.find(x => x.id === id);
if (!story && st.briefing) {
const all = [].concat(st.briefing.top, ...st.briefing.sections.map(s => s.stories));
story = all.find(x => x.id === id);
}
if (!story) return;
readerList = [];
const lead = story.lead || story.items[0];
// Opening a story reads it: all its reports count as read.
const unread = story.items.filter(i => !i.read).map(i => i.id);
if (unread.length) {
markLocal(unread, true);
story.unread = 0;
api('/feeds/items/read/', {ids: unread, read: true}).catch(() => {});
}
openItem(lead.id, story);
}
// ---- folders: rename, dissolve
async function folderAction(act, name) {
if (act === 'folder-rename') {
const neu = typeof customPrompt === 'function' ? await customPrompt('Ordner „' + name + '“ umbenennen in:', name) : window.prompt('Neuer Name', name);
if (!neu || !neu.trim() || neu.trim() === name) return;
await api('/feeds/folders/rename/', {name, new: neu.trim()}).catch(() => {});
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = neu.trim().slice(0, 40);
} else {
const ok = typeof customConfirm === 'function'
? await customConfirm('Ordner „' + name + '“ auflösen? Die Blogs bleiben abonniert.')
: window.confirm('Auflösen?');
if (!ok) return;
await api('/feeds/folders/dissolve/', {name}).catch(() => {});
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = null;
}
await loadState().catch(() => {});
if (st.view === 'blogs') return load();
render();
}
tab.addEventListener('contextmenu', e => {
const chip = e.target.closest('[data-folder]');
if (!chip || chip.dataset.folder === '*' || chip.dataset.folder === '') return;
e.preventDefault();
folderSheet(chip.dataset.folder);
});
// The long-press menu of a folder: open, rename, dissolve.
function folderSheet(name) {
const old = document.getElementById('fd-sheet');
if (old) old.remove();
const sheet = document.createElement('div');
sheet.id = 'fd-sheet';
sheet.innerHTML = '<div class="fd-sheet-box"><div class="fd-sheet-title">' + esc(name) + '</div>' +
'<button type="button" data-s="open">Ordner öffnen</button>' +
'<button type="button" data-s="rename">Umbenennen …</button>' +
'<button type="button" data-s="dissolve">Auflösen</button>' +
'<button type="button" data-s="cancel">Abbrechen</button></div>';
sheet.addEventListener('click', e => {
const b = e.target.closest('[data-s]');
if (!b && e.target !== sheet) return;
sheet.remove();
const what = b ? b.dataset.s : 'cancel';
if (what === 'open') { st.blogFolder = name; st.blogSource = ''; load(); }
if (what === 'rename') folderAction('folder-rename', name);
if (what === 'dissolve') folderAction('folder-dissolve', name);
});
document.body.appendChild(sheet);
}
// ---- events
tab.addEventListener('click', async e => {
const t = e.target.closest('[data-view],[data-act],[data-story],[data-item],[data-cat],[data-edit],[data-folder],[data-flag],[data-edit-open]');
if (!t || t.tagName === 'SELECT' || (t.tagName === 'INPUT' && t.type !== 'button')) return;
if (t.dataset.view) {
st.view = t.dataset.view;
try { localStorage.setItem('diora_feeds_view', st.view); } catch (err) {}
load();
return;
}
if (t.dataset.flag) {
const src = sources().find(x => x.id === parseInt(t.dataset.id, 10));
if (!src) return;
src[t.dataset.flag] = !src[t.dataset.flag];
render();
await api('/feeds/sources/' + src.id + '/', {[t.dataset.flag]: src[t.dataset.flag]}).catch(() => {});
return load();
}
if (t.dataset.editOpen) {
st.view = 'manage';
st.editing = parseInt(t.dataset.editOpen, 10);
window.scrollTo(0, 0);
return render();
}
if (t.dataset.folder !== undefined) {
st.blogFolder = t.dataset.folder === '*' ? null : t.dataset.folder;
st.blogSource = '';
window.scrollTo(0, 0);
return load();
}
if (t.dataset.story) return openStory(parseInt(t.dataset.story, 10));
if (t.dataset.item) {
readerList = st.items;
return openItem(parseInt(t.dataset.item, 10), null);
}
if (t.dataset.cat !== undefined && !t.dataset.act) {
st.category = t.dataset.cat;
window.scrollTo(0, 0);
return load();
}
if (t.dataset.edit) {
st.editing = parseInt(t.dataset.edit, 10);
return render();
}
const act = t.dataset.act;
if (act === 'refresh') {
if (st.loading) return;
st.loading = true;
render();
try { await api('/feeds/refresh/', {}); } catch (err) {}
return load();
}
if (act === 'manage') {
st.view = 'manage';
st.found = null;
window.scrollTo(0, 0);
if (!st.state) await loadState().catch(() => {});
return render();
}
if (act === 'close-manage') {
try { st.view = localStorage.getItem('diora_feeds_view') || 'news'; } catch (err) { st.view = 'news'; }
st.editing = null;
st.opml = null;
return load();
}
if (act === 'seen') {
t.disabled = true;
try { await api('/feeds/briefing/seen/', {}); } catch (err) {}
return load();
}
if (act === 'more') return load(true);
if (act === 'mode-unread' || act === 'mode-all') {
st.blogUnread = act === 'mode-unread';
try { localStorage.setItem('diora_feeds_unread', st.blogUnread ? '1' : '0'); } catch (err) {}
return load();
}
if (act === 'mode-fav') {
st.blogFav = !st.blogFav;
return load();
}
if (act === 'mode-rare') {
st.rareFirst = !st.rareFirst;
try { localStorage.setItem('diora_feeds_rare', st.rareFirst ? '1' : '0'); } catch (err) {}
return render();
}
if (act === 'blogs-read') {
const ids = st.items.filter(i => !i.read).map(i => i.id);
if (!ids.length) return;
await api('/feeds/items/read/', {ids, read: true}).catch(() => {});
return load();
}
if (act === 'folder-rename' || act === 'folder-dissolve') return folderAction(act, t.dataset.name);
if (act === 'login-delete') {
await api('/feeds/logins/delete/', {source: parseInt(t.dataset.id, 10)}).catch(() => {});
await loadState().catch(() => {});
return render();
}
if (act === 'cancel-edit') {
st.editing = null;
return render();
}
if (act === 'delete') {
const src = sources().find(x => x.id === parseInt(t.dataset.id, 10));
const ok = typeof customConfirm === 'function'
? await customConfirm('„' + (src ? src.title : 'Feed') + '“ abbestellen? Seine Artikel verschwinden mit.', {danger: true})
: window.confirm('Abbestellen?');
if (!ok) return;
await api('/feeds/sources/' + t.dataset.id + '/delete/', {}).catch(() => {});
st.editing = null;
await loadState().catch(() => {});
return render();
}
if (act === 'opml-cancel') {
st.opml = null;
return render();
}
if (act === 'opml-import') {
const list = [];
st.opml.forEach(g => {
if (g.kind === 'skip') return;
g.feeds.forEach(f => list.push({url: f.url, title: f.title, kind: g.kind, category: g.name}));
});
t.disabled = true;
t.textContent = 'Wird übernommen …';
try {
const r = await api('/feeds/sources/bulk/', {sources: list});
st.opml = null;
st.error = '';
await loadState();
render();
if (typeof customAlert === 'function') customAlert(r.added + ' Feeds übernommen.');
} catch (err) {
st.error = err.message;
render();
}
}
});
tab.addEventListener('change', async e => {
const t = e.target;
if (t.dataset.act === 'blog-source') { st.blogSource = t.value; return load(); }
if (t.dataset.act === 'keep') {
await api('/feeds/settings/', {news_keep_days: parseInt(t.value, 10)}).catch(() => {});
return;
}
if (t.dataset.opml !== undefined) {
st.opml[parseInt(t.dataset.opml, 10)].kind = t.value;
return render();
}
if (t.dataset.act === 'opml' && t.files && t.files[0]) {
// FileReader rather than File.text(): the e-reader's Chrome 61 lacks the latter.
const text = await new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => reject(fr.error);
fr.readAsText(t.files[0]);
});
const groups = parseOpml(text);
st.opml = groups.length ? groups : null;
st.error = groups.length ? '' : 'In der Datei sind keine Feeds.';
render();
}
});
tab.addEventListener('submit', async e => {
e.preventDefault();
const f = e.target;
const btn = f.querySelector('[type=submit]');
if (btn) btn.disabled = true;
try {
if (f.dataset.form === 'discover') {
st.found = null;
st.error = '';
if (btn) btn.textContent = 'Sucht …';
const r = await api('/feeds/discover/', {url: f.url.value});
st.found = r.feeds;
} else if (f.dataset.form === 'subscribe') {
const found = st.found[parseInt(f.dataset.n, 10)];
if (btn) btn.textContent = 'Lädt Artikel …';
await api('/feeds/sources/add/', {url: found.url, title: found.title, kind: f.kind.value,
category: f.category.value});
found.subscribed = true;
await loadState();
} else if (f.dataset.form === 'login') {
if (btn) btn.textContent = 'Meldet an …';
const r = await api('/feeds/logins/', {source: parseInt(f.dataset.id, 10), username: f.username.value, password: f.password.value});
f.password.value = '';
await loadState();
if (typeof customAlert === 'function') {
customAlert('Angemeldet.' + (r.refetched ? ' ' + r.refetched + ' Artikel jetzt vollständig.' : ''));
}
} else if (f.dataset.form === 'edit') {
const change = {title: f.title.value, kind: f.kind.value, fulltext: f.fulltext.value};
if (f.category) change.category = f.category.value;
if (f.newfolder) {
change.folders = Array.prototype.filter.call(f.querySelectorAll('input[name=folder]'), c => c.checked).map(c => c.value);
if (f.newfolder.value.trim()) change.folders.push(f.newfolder.value.trim());
}
if (f.fav) change.fav = f.fav.checked;
if (f.hidden) change.hidden = f.hidden.checked;
if (f.notify) change.notify = f.notify.checked;
await api('/feeds/sources/' + f.dataset.id + '/', change);
st.editing = null;
await loadState();
}
st.error = '';
} catch (err) {
st.error = err.message;
}
render();
});
// The same field is the Ressort of news and the folder of a blog.
tab.addEventListener('input', e => {
if (e.target.name !== 'kind') return;
const form = e.target.form;
const cat = form && form.querySelector('.fd-cat-field');
if (cat && cat.firstChild) cat.firstChild.nodeValue = (e.target.value === 'news' ? 'Ressort' : 'Ordner') + ' ';
});
// ---- showing the tab
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 () {
// Back after a while: fresh state (new briefing); otherwise as it was.
if (!st.state || Date.now() - shownAt > 5 * 60000) load();
else render();
shownAt = Date.now();
};
// What other parts (Home) may show: the briefing and the unread blog posts.
window.__dioraFeedsSummary = async function () {
const [state, briefing] = await Promise.all([api('/feeds/state/'), api('/feeds/briefing/')]);
return {state, briefing};
};
// A notification about a new post was tapped (the app, MainActivity): open it.
window.__dioraFeedsOpenItem = function (id) {
showTab('feeds');
readerList = st.items;
openItem(id, null);
};
window.__dioraFeedsOpenStory = function (story) {
if (!st.stories.some(x => x.id === story.id)) st.stories.push(story);
openStory(story.id);
};
setInterval(() => {
if (tab.style.display !== 'none' && !readerOpen() && st.view !== 'manage' && !st.loading) load();
}, 10 * 60000);
// Back (Android): the article, then the management page, then (diora Reader) to the books.
const back = window.__dioraBack;
window.__dioraBack = function () {
if (readerOpen()) { closeReader(); return true; }
if (tab.style.display !== 'none' && st.view === 'manage') {
tab.querySelector('[data-act="close-manage"]').click();
return true;
}
if (back && back()) return true;
if (READER_APP && typeof _currentTopTab !== 'undefined' && _currentTopTab === 'feeds') {
showTab('books');
return true;
}
return false;
};
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && readerOpen()) closeReader();
});
// diora Reader has no tab bar of its own: a small one for books and feeds.
if (READER_APP && !document.getElementById('nui-tabbar')) {
const dock = document.createElement('div');
dock.id = 'nui-dock';
dock.innerHTML = '<nav id="nui-tabbar">' +
'<button type="button" data-tab="books"><span class="nui-tab-icon">▤</span>Bücher</button>' +
'<button type="button" data-tab="feeds"><span class="nui-tab-icon">☰</span>Feeds</button></nav>';
document.body.appendChild(dock);
const mark = () => dock.querySelectorAll('button').forEach(b =>
b.classList.toggle('nui-active', typeof _currentTopTab !== 'undefined' && b.dataset.tab === _currentTopTab));
dock.addEventListener('click', e => {
const b = e.target.closest('button');
if (!b) return;
showTab(b.dataset.tab);
window.scrollTo(0, 0);
});
const orig = window.showTab;
window.showTab = function (name) {
orig.apply(this, arguments);
mark();
};
document.documentElement.style.setProperty('--nui-dock-h', '56px');
const overlay = document.getElementById('reader-overlay');
if (overlay) {
new MutationObserver(() => document.documentElement.classList.toggle('nui-reading', overlay.style.display !== 'none'))
.observe(overlay, {attributes: true, attributeFilter: ['style']});
}
mark();
}
// ---- styles
const style = document.createElement('style');
style.textContent = [
'#tab-feeds .fd { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
'#tab-feeds .fd-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 4px 0 14px; }',
'#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.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-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.on { color: var(--accent, #e63946); }',
'#tab-feeds .fd-back { background: none; border: none; color: inherit; font: inherit; padding: 8px 0; cursor: pointer; }',
'#tab-feeds .fd-h { margin: 0; font-size: 15px; }',
'.fd-note { color: var(--nui-muted, #9a9a9a); font-size: 13px; font-weight: 400; }',
'.fd-error, .fd-warn { color: var(--accent, #e63946); font-size: 13px; }',
'#tab-feeds .fd-empty { text-align: center; padding: 40px 0; }',
'#tab-feeds h3 { margin: 18px 0 6px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
'#tab-feeds h3 small { font-size: 11px; }',
'#tab-feeds .fd-brief { border: 1px solid var(--border, #2a2a2a); border-radius: 6px; padding: 12px 14px 14px; margin-bottom: 18px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); }',
'#tab-feeds .fd-brief-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; flex-wrap: wrap; }',
'#tab-feeds .fd-brief-head h2 { margin: 0; font-size: 16px; color: var(--accent, #e63946); }',
'#tab-feeds .fd-seen { width: 100%; margin-top: 14px; min-height: 42px; }',
'#tab-feeds .fd-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-feeds .fd-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
'#tab-feeds .fd-row-title { font-size: 14px; line-height: 1.35; }',
'.fd-row-meta { font-size: 12px; font-weight: 400; color: var(--nui-muted, #9a9a9a); }',
'#tab-feeds .fd-more-src { color: var(--accent, #e63946); }',
'#tab-feeds .fd-badge { flex: none; min-width: 26px; height: 26px; border-radius: 13px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 12px; display: inline-flex; align-items: center; justify-content: center; }',
'#tab-feeds .fd-link { background: none; border: none; color: var(--nui-muted, #9a9a9a); font: inherit; font-size: 12px; padding: 8px 0; cursor: pointer; }',
'#tab-feeds .fd-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 10px; align-items: center; }',
// The reports of a story: one row that scrolls sideways, however many there are.
'.fd-sources { display: flex; gap: 6px; margin: 4px -18px 10px; padding: 0 18px; align-items: center; overflow-x: auto; white-space: nowrap; scrollbar-width: none; }',
'.fd-sources::-webkit-scrollbar { display: none; }',
'.fd-sources .fd-chip { flex: none; }',
'#fd-article .fd-art-more { font-family: var(--font); margin: 30px 0 4px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
'#fd-article .fd-other { display: flex; flex-direction: column; gap: 3px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 0.85em; text-align: left; cursor: pointer; }',
'#fd-article .fd-other-title { font-weight: 600; line-height: 1.35; }',
'.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }',
'.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }',
'.fd-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #e63946); margin-right: 6px; vertical-align: middle; }',
'#tab-feeds .fd-modes .fd-chip { font-size: 12px; padding: 5px 10px; }',
'#tab-feeds .fd-blogbar { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; }',
'#tab-feeds .fd-new { display: inline-block; padding: 1px 5px; border-radius: 3px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 10px; letter-spacing: 0.05em; vertical-align: 2px; }',
'#tab-feeds .fd-hidden { opacity: 0.6; }',
'#tab-feeds .fd-prog { display: block; height: 3px; margin-top: 4px; background: var(--raised, #2a2a2a); border-radius: 2px; overflow: hidden; }',
'#tab-feeds .fd-prog > span { display: block; height: 100%; background: var(--accent, #e63946); }',
'#tab-feeds .fd-folders { border: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; }',
'#tab-feeds .fd-folders legend { font-size: 12px; color: var(--nui-muted, #9a9a9a); margin-bottom: 4px; }',
'#tab-feeds .fd-folders input[name=newfolder] { flex-basis: 100%; }',
'#tab-feeds .fd-folder-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border, #2a2a2a); flex-wrap: wrap; }',
'#fd-sheet { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 995; background: var(--scrim, rgba(0,0,0,0.5)); display: flex; align-items: flex-end; justify-content: center; }',
'#fd-sheet .fd-sheet-box { width: 100%; max-width: 480px; background: var(--panel, #000); border: 1px solid var(--border, #2a2a2a); border-bottom: none; border-radius: 10px 10px 0 0; padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px)); }',
'#fd-sheet .fd-sheet-title { padding: 10px 18px; font-size: 13px; color: var(--nui-muted, #9a9a9a); }',
'#fd-sheet button { display: block; width: 100%; padding: 14px 18px; background: none; border: none; color: var(--fg, #fff); font: inherit; text-align: left; cursor: pointer; }',
'body.eink #tab-feeds .fd-prog > span { background: #000; }',
'body.eink #fd-sheet .fd-sheet-box { background: #fff; }',
'body.eink #fd-sheet button { color: #000; }',
'body.eink #tab-feeds .fd-new, body.eink .fd-dot { background: #000; }',
'#tab-feeds .fd-day { margin-top: 16px; }',
'#tab-feeds .fd-card { display: flex; gap: 12px; width: 100%; padding: 12px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-feeds .fd-thumb { width: 92px; height: 66px; flex: none; object-fit: cover; border-radius: 4px; background: var(--raised, #1a1a1a); order: 2; }',
'#tab-feeds .fd-card-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }',
'#tab-feeds .fd-card-title { font-size: 15px; font-weight: 700; line-height: 1.35; }',
'#tab-feeds .fd-card-sum { font-size: 13px; font-weight: 400; line-height: 1.4; color: var(--nui-muted, #bdbdbd); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }',
'#tab-feeds .fd-read .fd-card-title, #tab-feeds .fd-read .fd-row-title { font-weight: 400; color: var(--nui-muted, #9a9a9a); }',
'#tab-feeds .fd-post { display: flex; flex-direction: column; gap: 4px; width: 100%; padding: 12px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-feeds .fd-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }',
'#tab-feeds .fd-select { max-width: 60%; }',
'#tab-feeds .fd-check { font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }',
'#tab-feeds .fd-allread { margin-left: auto; }',
'#tab-feeds .fd-loadmore { width: 100%; margin-top: 14px; }',
'#tab-feeds .fd-box { border: 1px solid var(--border, #2a2a2a); border-radius: 6px; padding: 4px 14px 14px; margin-bottom: 14px; }',
'#tab-feeds .fd-add { display: flex; gap: 8px; }',
'#tab-feeds .fd-add input { flex: 1; min-width: 0; }',
'#tab-feeds .fd-found, #tab-feeds .fd-edit { border-top: 1px solid var(--border, #2a2a2a); margin-top: 12px; padding-top: 10px; display: flex; flex-direction: column; gap: 8px; }',
'#tab-feeds .fd-found-title { font-weight: 700; }',
'#tab-feeds .fd-url { word-break: break-all; }',
'#tab-feeds .fd-samples { margin: 0; padding-left: 18px; font-size: 13px; font-weight: 400; }',
'#tab-feeds .fd-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; }',
'#tab-feeds .fd-grid label, #tab-feeds .fd-edit > label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--nui-muted, #9a9a9a); }',
'#tab-feeds .fd-buttons { display: flex; flex-wrap: wrap; gap: 8px; }',
'#tab-feeds .fd-login h4 { margin: 0; font-size: 13px; }',
'#tab-feeds .fd-login p { margin: 0; font-size: 13px; }',
'#tab-feeds .fd-src { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-feeds .fd-opml { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }',
'#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }',
'#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }',
'#tab-feeds .fd-file { display: inline-block; }',
// The article: a page over everything, like the book reader.
'#fd-article { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; background: var(--bg, #000); color: var(--fg, #fff); overflow-y: auto; -webkit-overflow-scrolling: touch; }',
'#fd-article[hidden] { display: none; }',
'#fd-article .fd-art-bar { position: sticky; top: 0; display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: var(--bg, #000); border-bottom: 1px solid var(--border, #2a2a2a); padding-top: calc(4px + env(safe-area-inset-top, 0px)); }',
'#fd-article .fd-art-src { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--nui-muted, #9a9a9a); }',
'#fd-article .fd-art { max-width: 68ch; margin: 0 auto; padding: 12px 18px 48px; font-family: "Source Serif 4", Georgia, serif; font-weight: 380; line-height: 1.65; }',
'#fd-article .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
'#fd-article .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
'#fd-article .fd-art-img, #fd-article .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }',
'#fd-article .fd-art-body a { color: var(--accent, #e63946); }',
'#fd-article .fd-art-body figure { margin: 12px 0; }',
'#fd-article .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
'#fd-article .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
'#fd-article .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
'#fd-article .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
'#fd-article .fd-art-body td, #fd-article .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
'#fd-article .fd-sources { font-family: var(--font); }',
'#fd-article .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }',
'html.fd-reading #nui-dock { display: none; }',
'@media (hover: hover) { #tab-feeds .fd-card:hover, #tab-feeds .fd-row:hover, #tab-feeds .fd-post:hover, #tab-feeds .fd-src:hover { background: var(--hover, #111); } }',
// Bright pages and e-ink: black on white, no grey boxes, no thumbnails to flicker.
'body.bright-bg #tab-feeds .fd-brief, body.eink #tab-feeds .fd-brief { background: #fff; }',
'body.eink #tab-feeds .fd-thumb { display: none; }',
'body.eink #tab-feeds .fd-seg button.on, body.eink #tab-feeds .fd-badge { background: #000; color: #fff; }',
'body.eink .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }',
'body.eink #fd-article, body.eink #fd-article .fd-art-bar { background: #fff; color: #000; }',
'body.eink #fd-article .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }',
].join('\n');
document.head.appendChild(style);
})();