- Floskeln je Quelle (Spendenboxen u. ä.) verschwinden aus dem Artikel - Überblick einer Meldung aus mehreren Quellen: Zusammenfassung (LexRank + MMR), was nur ein Bericht sagt, Wortwahl je Quelle, dieselbe Meldung in der anderen Sprache (GET /feeds/stories/<id>/overview/) - Themen-Chips über den Nachrichten (topics auf der ersten Seite, ?ids= filtert) - before= mit unkodiertem '+' gibt keinen 500 mehr - sw v133 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1359 lines
72 KiB
JavaScript
1359 lines
72 KiB
JavaScript
/*
|
||
* 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: '',
|
||
topics: [], // the day's topics (/feeds/news/ first page): [{label, ids}]
|
||
topic: null, // the one shown, or null
|
||
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, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
|
||
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 (st.topic) q.push('ids=' + st.topic.ids.join(','));
|
||
else {
|
||
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;
|
||
if (news.topics) st.topics = news.topics;
|
||
}
|
||
|
||
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.topics.length || st.topic) {
|
||
// Topics: stories of the last days about one matter (feeds/textproc.py).
|
||
const list = st.topic && !st.topics.some(t => t.label === st.topic.label) ? [st.topic].concat(st.topics) : st.topics;
|
||
h += '<div class="fd-chips fd-topics"><span class="fd-note">Themen</span>' +
|
||
list.map((t, n) => '<button type="button" class="fd-chip' + (st.topic && st.topic.label === t.label ? ' on' : '') +
|
||
'" data-topic="' + n + '">' + esc(t.label) + ' <small>' + t.ids.length + '</small></button>').join('') + '</div>';
|
||
st.topicList = list;
|
||
}
|
||
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('')) : '') +
|
||
(window.DioraFolders ? '<button type="button" class="fd-chip" data-act="folders-manage" title="Ordner verwalten" aria-label="Ordner verwalten">⋯</button>' : '') +
|
||
'</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>' +
|
||
(window.DioraFolders ? '<button type="button" class="fd-chip" data-act="blog-folders" data-id="' + one.id + '">▤ In Ordner …</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 & 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;
|
||
if (readerStory) loadOverview(readerStory);
|
||
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>';
|
||
if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>';
|
||
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;
|
||
}
|
||
|
||
// What the story's reports say together (GET /feeds/stories/<id>/overview/, feeds/textproc.py):
|
||
// a summary from all of them, what only one says, each source's words, the same story in the
|
||
// other language.
|
||
function overviewHtml(story) {
|
||
const o = story.overview || {};
|
||
let h = '';
|
||
if (o.summary && o.summary.length) {
|
||
h += '<details class="fd-ov-box" open><summary>Überblick aus ' + story.sources + ' Quellen</summary>' +
|
||
o.summary.map(x => '<p>' + esc(x.text) + ' <span class="fd-ov-src">' + esc(x.source) + '</span></p>').join('');
|
||
const novel = Object.keys(o.novel || {});
|
||
if (novel.length) {
|
||
h += '<h4>Nur in einem Bericht</h4>' + novel.map(src => o.novel[src].map(t =>
|
||
'<p>' + esc(t) + ' <span class="fd-ov-src">' + esc(src) + '</span></p>').join('')).join('');
|
||
}
|
||
const words = Object.keys(o.words || {});
|
||
if (words.length) {
|
||
h += '<h4>Wortwahl</h4>' + words.map(src => '<p class="fd-ov-words"><b>' + esc(src) + ':</b> ' +
|
||
o.words[src].map(esc).join(', ') + '</p>').join('');
|
||
}
|
||
h += '</details>';
|
||
}
|
||
if (o.related && o.related.length) {
|
||
h += '<div class="fd-ov-rel"><h4>In der anderen Sprache</h4>' + o.related.map(x => {
|
||
const lead = x.lead || {};
|
||
return '<button type="button" class="fd-other" data-r="story" data-id="' + x.id + '">' +
|
||
'<span class="fd-other-title">' + esc(lead.title) + '</span>' +
|
||
'<span class="fd-row-meta">' + esc(lead.source_title) + ' · ' + esc(when(x.last_at)) + '</span></button>';
|
||
}).join('') + '</div>';
|
||
}
|
||
return h;
|
||
}
|
||
|
||
async function loadOverview(story) {
|
||
if (story.overview) return;
|
||
try {
|
||
story.overview = await api('/feeds/stories/' + story.id + '/overview/');
|
||
} catch (e) {
|
||
return; // offline and never fetched: the article alone
|
||
}
|
||
const box = reader && !reader.hidden && readerStory === story && reader.querySelector('.fd-ov');
|
||
if (box) box.innerHTML = overviewHtml(story);
|
||
}
|
||
|
||
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 === 'story') {
|
||
const other = readerStory && readerStory.overview && (readerStory.overview.related || []).find(x => x.id === parseInt(b.dataset.id, 10));
|
||
if (other) openItem((other.lead || other.items[0]).id, other);
|
||
}
|
||
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-topic],[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.topic !== undefined) {
|
||
const topic = (st.topicList || [])[parseInt(t.dataset.topic, 10)];
|
||
st.topic = st.topic && topic && st.topic.label === topic.label ? null : topic || null;
|
||
window.scrollTo(0, 0);
|
||
return load();
|
||
}
|
||
if (t.dataset.cat !== undefined && !t.dataset.act) {
|
||
st.topic = null;
|
||
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();
|
||
}
|
||
// Folders the same way as everywhere (folders.js, docs/library.md).
|
||
if (act === 'folders-manage' && window.DioraFolders) {
|
||
const done = () => loadState().catch(() => {}).then(() => (st.view === 'blogs' ? load() : render()));
|
||
window.DioraFolders.manage({
|
||
title: 'Ordner der Blogs',
|
||
folders: (st.state && st.state.folders) || [],
|
||
onRename: (name, neu) => api('/feeds/folders/rename/', {name, new: neu}).catch(() => {}).then(() => {
|
||
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = neu;
|
||
return done();
|
||
}),
|
||
onDelete: name => api('/feeds/folders/dissolve/', {name}).catch(() => {}).then(() => {
|
||
if (st.blogFolder !== null && st.blogFolder.toLowerCase() === name.toLowerCase()) st.blogFolder = null;
|
||
return done();
|
||
}),
|
||
onOrder: order => api('/feeds/folders/order/', {order}).then(d => {
|
||
if (st.state && d && d.folders) st.state.folders = d.folders;
|
||
render();
|
||
}).catch(() => {}),
|
||
});
|
||
return;
|
||
}
|
||
if (act === 'blog-folders' && window.DioraFolders) {
|
||
const src = sources().find(x => x.id === parseInt(t.dataset.id, 10));
|
||
if (!src) return;
|
||
window.DioraFolders.assign({
|
||
title: 'Ordner für „' + src.title + '“',
|
||
folders: (st.state && st.state.folders) || [],
|
||
selected: src.folders || [],
|
||
onDone: async names => {
|
||
await api('/feeds/sources/' + src.id + '/', {folders: names}).catch(() => {});
|
||
await loadState().catch(() => {});
|
||
load();
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
if (act === 'manage') {
|
||
// Managing every subscription has its place (Abos & Quellen, docs/sources.md).
|
||
const nav = window.DioraNav;
|
||
if (nav && nav.sections().some(s => s.id === 'sources')) {
|
||
try { localStorage.setItem('diora_sources_filter', st.view === 'blogs' ? 'blog' : 'news'); } catch (err) {}
|
||
nav.go('sources');
|
||
return;
|
||
}
|
||
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;
|
||
if (st.fromSources && window.DioraNav) { st.fromSources = false; window.DioraNav.go('sources'); return; }
|
||
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;
|
||
// Abos & Quellen (sources.js): a source's editor (null: the management page itself, with
|
||
// the OPML import), and "the sources changed, load again".
|
||
window.__dioraFeedsEdit = async function (id) {
|
||
if (!st.state) await loadState().catch(() => {});
|
||
const src = id == null ? null : sources().find(x => x.id === Number(id));
|
||
const section = src && src.kind === 'blog' ? 'blogs' : 'news';
|
||
if (window.DioraNav) window.DioraNav.go(section); else showTab('feeds');
|
||
st.view = 'manage';
|
||
st.editing = src ? src.id : null;
|
||
st.found = null;
|
||
st.fromSources = true;
|
||
window.scrollTo(0, 0);
|
||
render();
|
||
};
|
||
window.__dioraFeedsStale = function () {
|
||
st.state = null;
|
||
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. With the navigation (nav.js) it
|
||
// holds the sections of Lesen itself — a row of them to swipe is no good on e-ink.
|
||
if (READER_APP && !document.getElementById('nui-tabbar')) {
|
||
const NAV = window.DioraNav;
|
||
const dock = document.createElement('div');
|
||
dock.id = 'nui-dock';
|
||
dock.innerHTML = '<nav id="nui-tabbar">' + (NAV
|
||
? NAV.sections().filter(s => !s.footer).map(s => '<button type="button" data-nav="' + s.id + '"><span class="nui-tab-icon">' +
|
||
(s.svg || esc(s.icon)) + '</span>' + esc(s.label) + '</button>').join('')
|
||
: '<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', NAV
|
||
? b.dataset.nav === NAV.current()
|
||
: typeof _currentTopTab !== 'undefined' && b.dataset.tab === _currentTopTab));
|
||
dock.addEventListener('click', e => {
|
||
const b = e.target.closest('button');
|
||
if (!b) return;
|
||
if (NAV) NAV.go(b.dataset.nav);
|
||
else showTab(b.dataset.tab);
|
||
window.scrollTo(0, 0);
|
||
});
|
||
if (NAV) {
|
||
document.documentElement.classList.add('nav-flat'); // every section in the bar: no row on top
|
||
NAV.onChange(mark);
|
||
NAV.barsReady();
|
||
}
|
||
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); }',
|
||
'#tab-feeds .fd-topics .fd-note { margin-right: 2px; }',
|
||
'#tab-feeds .fd-topics small { opacity: 0.6; }',
|
||
'#fd-article .fd-ov { font-size: 0.9em; }',
|
||
'#fd-article .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
|
||
'#fd-article .fd-ov-box summary { font-family: var(--font); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); cursor: pointer; }',
|
||
'#fd-article .fd-ov p { margin: 8px 0; line-height: 1.45; }',
|
||
'#fd-article .fd-ov h4 { font-family: var(--font); font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); margin: 14px 0 4px; }',
|
||
'#fd-article .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
|
||
'#fd-article .fd-ov-words { font-family: var(--font); font-size: 13px; }',
|
||
'#fd-article .fd-ov-rel { margin: 0 0 18px; }',
|
||
'#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-ov-box { border-color: #000; }',
|
||
'body.eink #fd-article .fd-ov-src, body.eink #fd-article .fd-ov h4, body.eink #fd-article .fd-ov-box summary { color: #333; }',
|
||
'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);
|
||
})();
|