Feed-Reader: Tab „Feeds“ mit Nachrichten (Briefing, Stories) und Blogs
static/js/feeds.js für Web, Desktop und beide Android-Apps: - Nachrichten: „Seit letztem Mal“ (Top-Themen, dann nach Ressort, „Alles gelesen“), Stories nach Tag, Ressort-Filter - Blogs: Ungelesen-Liste, je Blog filterbar, „Alle gelesen“ - Artikel im eigenen Lesemodus (Volltext, Schriftgröße, merken, alle Berichte einer Story), Browser nur auf Wunsch - Feeds verwalten: Seite oder Feed suchen, als Nachrichten/Blog abonnieren, Ressort, Volltext-Regel, OPML-Import pro Ordner, Export, Aufbewahrung - diora Reader (ohne Tab-Leiste) bekommt eine kleine Leiste Bücher/Feeds Server: Feed-Namen ohne Slogan, Feeds mit einem Zeitstempel für alle Einträge (Deutschlandfunk) zählen ab dem ersten Sehen; sw v89 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
08baf970ca
commit
35701260e0
8 changed files with 894 additions and 6 deletions
|
|
@ -224,6 +224,16 @@ def discover(page_html, base):
|
||||||
return out, (urljoin(base, p.icon) if p.icon else '')
|
return out, (urljoin(base, p.icon) if p.icon else '')
|
||||||
|
|
||||||
|
|
||||||
|
def short_title(title):
|
||||||
|
"""A feed's name without the slogan: "tagesschau.de - die erste Adresse für …"
|
||||||
|
-> "tagesschau.de", "Aktuell - FAZ.NET" -> "FAZ.NET", "DIE ZEIT | Nachrichten, …" -> "DIE ZEIT"."""
|
||||||
|
parts = [p.strip() for p in re.split(r'\s+[-–—|:]\s+', title or '') if p.strip()]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return (title or '').strip()
|
||||||
|
branded = [p for p in parts if re.search(r'\w\.[a-z]{2,}$', p, re.I)]
|
||||||
|
return (branded or parts)[0]
|
||||||
|
|
||||||
|
|
||||||
def site_icon(site_url):
|
def site_icon(site_url):
|
||||||
if not site_url:
|
if not site_url:
|
||||||
return ''
|
return ''
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ news is bundled into stories, old news expires."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections import Counter
|
||||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
@ -41,7 +42,7 @@ def _apply(source, result, now, keep_days):
|
||||||
parsed = result.parsed
|
parsed = result.parsed
|
||||||
feed = parsed.feed
|
feed = parsed.feed
|
||||||
if feed.get('title') and (not source.title or source.title == source.url):
|
if feed.get('title') and (not source.title or source.title == source.url):
|
||||||
source.title = fetch.plain(feed.title)[:300]
|
source.title = fetch.short_title(fetch.plain(feed.title))[:300]
|
||||||
if feed.get('link'):
|
if feed.get('link'):
|
||||||
source.site_url = feed.link[:1000]
|
source.site_url = feed.link[:1000]
|
||||||
if not source.icon_url:
|
if not source.icon_url:
|
||||||
|
|
@ -64,8 +65,14 @@ def _apply(source, result, now, keep_days):
|
||||||
oldest_kept = source.items.order_by('published').values_list('published', flat=True).first()
|
oldest_kept = source.items.order_by('published').values_list('published', flat=True).first()
|
||||||
news_cutoff = now - timedelta(days=keep_days)
|
news_cutoff = now - timedelta(days=keep_days)
|
||||||
new = []
|
new = []
|
||||||
for entry in parsed.entries:
|
entries = [fetch.parse_entry(e, source.url) for e in parsed.entries]
|
||||||
fields = fetch.parse_entry(entry, source.url)
|
# Some feeds (Deutschlandfunk) give every entry the time the feed was made:
|
||||||
|
# then the dates say nothing, and the time an entry first shows up counts.
|
||||||
|
stamps = Counter(f['published'] for f in entries)
|
||||||
|
bogus_dates = len(entries) >= 5 and stamps.most_common(1)[0][1] >= 0.8 * len(entries)
|
||||||
|
for fields in entries:
|
||||||
|
if bogus_dates:
|
||||||
|
fields['published'] = now
|
||||||
if fields['guid_hash'] in known:
|
if fields['guid_hash'] in known:
|
||||||
continue
|
continue
|
||||||
known.add(fields['guid_hash'])
|
known.add(fields['guid_hash'])
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ class FetchTest(TestCase):
|
||||||
def test_drop_repeated_headline(self):
|
def test_drop_repeated_headline(self):
|
||||||
self.assertEqual(fetch._drop_title('<img src="x"><h1>Titel</h1><p>Text</p>'), '<img src="x"><p>Text</p>')
|
self.assertEqual(fetch._drop_title('<img src="x"><h1>Titel</h1><p>Text</p>'), '<img src="x"><p>Text</p>')
|
||||||
|
|
||||||
|
def test_short_title(self):
|
||||||
|
self.assertEqual(fetch.short_title('tagesschau.de - die erste Adresse für Nachrichten'), 'tagesschau.de')
|
||||||
|
self.assertEqual(fetch.short_title('Aktuell - FAZ.NET'), 'FAZ.NET')
|
||||||
|
self.assertEqual(fetch.short_title('DIE ZEIT | Nachrichten, News, Hintergründe'), 'DIE ZEIT')
|
||||||
|
self.assertEqual(fetch.short_title('Golem.de'), 'Golem.de')
|
||||||
|
self.assertEqual(fetch.short_title('Kotlin-Blog'), 'Kotlin-Blog')
|
||||||
|
|
||||||
def test_discover(self):
|
def test_discover(self):
|
||||||
feeds, icon = fetch.discover('<link rel="alternate" type="application/rss+xml" href="/feed" title="F">'
|
feeds, icon = fetch.discover('<link rel="alternate" type="application/rss+xml" href="/feed" title="F">'
|
||||||
'<link rel="icon" href="/fav.png">', 'https://ex.org/')
|
'<link rel="icon" href="/fav.png">', 'https://ex.org/')
|
||||||
|
|
@ -150,6 +157,12 @@ class ApiTest(TestCase):
|
||||||
refresh.refresh([source])
|
refresh.refresh([source])
|
||||||
self.assertEqual(Item.objects.filter(user=self.u, gone=False).count(), 0)
|
self.assertEqual(Item.objects.filter(user=self.u, gone=False).count(), 0)
|
||||||
|
|
||||||
|
def test_feed_wide_timestamp_means_first_seen(self):
|
||||||
|
stamp = (timezone.now() - timedelta(hours=5)).strftime('%a, %d %b %Y %H:%M:%S +0000')
|
||||||
|
self.add('news', rss([{'guid': f'g{n}', 'title': f'Meldung {n}', 'text': LONG, 'date': stamp} for n in range(6)]))
|
||||||
|
for i in Item.objects.filter(user=self.u):
|
||||||
|
self.assertLess((timezone.now() - i.published).total_seconds(), 60)
|
||||||
|
|
||||||
def test_switching_kind_moves_items(self):
|
def test_switching_kind_moves_items(self):
|
||||||
src = self.add('news', rss([{'guid': 'a', 'title': 'Etwas', 'text': LONG}]))
|
src = self.add('news', rss([{'guid': 'a', 'title': 'Etwas', 'text': LONG}]))
|
||||||
self.post(f'/feeds/sources/{src["id"]}/', {'kind': 'blog'})
|
self.post(f'/feeds/sources/{src["id"]}/', {'kind': 'blog'})
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ def _probe(url):
|
||||||
parsed = feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
|
parsed = feedparser.parse(r.content, response_headers={'content-location': r.url, **r.headers})
|
||||||
if parsed.get('bozo') and not parsed.entries and not parsed.feed.get('title'):
|
if parsed.get('bozo') and not parsed.entries and not parsed.feed.get('title'):
|
||||||
return None, ([], '')
|
return None, ([], '')
|
||||||
return (r.url, fetch.plain(parsed.feed.get('title') or '') or r.url, parsed), None
|
return (r.url, fetch.short_title(fetch.plain(parsed.feed.get('title') or '')) or r.url, parsed), None
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
|
|
|
||||||
|
|
@ -1179,7 +1179,7 @@ async function editNotes(pk, current) {
|
||||||
// Tabs
|
// Tabs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'ambient'];
|
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient'];
|
||||||
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
|
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1238,6 +1238,7 @@ function showTab(name) {
|
||||||
if (name === 'books') loadBookList();
|
if (name === 'books') loadBookList();
|
||||||
if (name === 'ambient') _ambientLoad();
|
if (name === 'ambient') _ambientLoad();
|
||||||
if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js
|
if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js
|
||||||
|
if (name === 'feeds' && window.__dioraFeedsShow) window.__dioraFeedsShow(); // static/js/feeds.js
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
850
static/js/feeds.js
Normal file
850
static/js/feeds.js
Normal file
|
|
@ -0,0 +1,850 @@
|
||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
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;
|
||||||
|
} 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 (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);
|
||||||
|
if (st.blogUnread) q.push('unread=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_unread : 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
let h = '<div class="fd-filter">' +
|
||||||
|
'<select class="search-input fd-select" data-act="blog-source"><option value="">Alle Blogs</option>' +
|
||||||
|
blogs.map(b => '<option value="' + b.id + '"' + (String(b.id) === String(st.blogSource) ? ' selected' : '') + '>' +
|
||||||
|
esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + '</option>').join('') + '</select>' +
|
||||||
|
'<label class="fd-check"><input type="checkbox" data-act="blog-unread"' + (st.blogUnread ? ' checked' : '') + '> Nur ungelesene</label>' +
|
||||||
|
(st.items.some(i => !i.read) ? '<button type="button" class="btn fd-allread" data-act="blogs-read">Alle gelesen</button>' : '') +
|
||||||
|
'</div>';
|
||||||
|
if (!st.items.length) return h + '<p class="fd-note">' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '</p>';
|
||||||
|
h += st.items.map(i =>
|
||||||
|
'<button type="button" class="fd-post' + (i.read ? ' fd-read' : '') + '" data-item="' + i.id + '">' +
|
||||||
|
'<span class="fd-card-title">' + (i.starred ? '★ ' : '') + esc(i.title) + '</span>' +
|
||||||
|
'<span class="fd-row-meta">' + esc(i.source_title) + ' · ' + esc(when(i.published)) + '</span>' +
|
||||||
|
(i.summary ? '<span class="fd-card-sum">' + esc(i.summary) + '</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) || [];
|
||||||
|
return '<input class="search-input" name="category" list="fd-cats" placeholder="Ressort, z. B. Politik" 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">Ressort (Nachrichten) ' + 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">' + esc(x.title) + '</span>' +
|
||||||
|
'<span class="fd-row-meta">' + (x.kind === 'news' ? esc(x.category || 'Allgemein') + ' · ' : '') +
|
||||||
|
(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)));
|
||||||
|
h += group('Blogs', blogs);
|
||||||
|
|
||||||
|
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>' +
|
||||||
|
'<label class="fd-cat-field">Ressort ' + categoryInput(x.category) + '</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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
document.body.appendChild(reader);
|
||||||
|
return reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readerOpen() {
|
||||||
|
return !!(reader && !reader.hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReader() {
|
||||||
|
if (!reader) return;
|
||||||
|
reader.hidden = true;
|
||||||
|
reader.innerHTML = '';
|
||||||
|
document.documentElement.classList.remove('fd-reading');
|
||||||
|
readerItem = readerStory = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openItem(id, story) {
|
||||||
|
ensureReader();
|
||||||
|
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 (!readerItem.read) {
|
||||||
|
readerItem.read = true;
|
||||||
|
markLocal([id], true);
|
||||||
|
api('/feeds/items/read/', {ids: [id], read: true}).catch(() => {});
|
||||||
|
}
|
||||||
|
} 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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- events
|
||||||
|
|
||||||
|
tab.addEventListener('click', async e => {
|
||||||
|
const t = e.target.closest('[data-view],[data-act],[data-story],[data-item],[data-cat],[data-edit]');
|
||||||
|
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.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 === '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 === '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.kind === 'news' ? 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 === 'blog-unread') { st.blogUnread = t.checked; 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.kind.value === 'news' ? f.category.value : ''});
|
||||||
|
found.subscribed = true;
|
||||||
|
await loadState();
|
||||||
|
} else if (f.dataset.form === 'edit') {
|
||||||
|
await api('/feeds/sources/' + f.dataset.id + '/', {title: f.title.value, kind: f.kind.value,
|
||||||
|
category: f.category.value, fulltext: f.fulltext.value});
|
||||||
|
st.editing = null;
|
||||||
|
await loadState();
|
||||||
|
}
|
||||||
|
st.error = '';
|
||||||
|
} catch (err) {
|
||||||
|
st.error = err.message;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Ressort field only means something for news.
|
||||||
|
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.style.display = e.target.value === 'news' ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- showing the tab
|
||||||
|
|
||||||
|
let shownAt = 0;
|
||||||
|
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};
|
||||||
|
};
|
||||||
|
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: #fff; }',
|
||||||
|
'#tab-feeds .fd-count { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px; background: 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: #0b0b0b; }',
|
||||||
|
'#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: #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); }',
|
||||||
|
'#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: #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-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, #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: #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);
|
||||||
|
})();
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v88';
|
const CACHE = 'diora-v89';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app.js',
|
'/static/js/app.js',
|
||||||
'/static/js/podcasts-web.js',
|
'/static/js/podcasts-web.js',
|
||||||
'/static/js/home.js',
|
'/static/js/home.js',
|
||||||
|
'/static/js/feeds.js',
|
||||||
'/static/podcasts/native-podcast.js',
|
'/static/podcasts/native-podcast.js',
|
||||||
'/static/podcasts/podcast-ui.css',
|
'/static/podcasts/podcast-ui.css',
|
||||||
'/static/podcasts/podcasts-web.css',
|
'/static/podcasts/podcasts-web.css',
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
<button class="tab-btn" onclick="showTab('focus')">Fokus</button>
|
<button class="tab-btn" onclick="showTab('focus')">Fokus</button>
|
||||||
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
|
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
|
||||||
<button class="tab-btn" onclick="showTab('books')">Bücher</button>
|
<button class="tab-btn" onclick="showTab('books')">Bücher</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('feeds')"{% if not user.is_authenticated %} style="display:none;"{% endif %}>Feeds</button>
|
||||||
<button class="tab-btn" onclick="showTab('ambient')">Ambient</button>
|
<button class="tab-btn" onclick="showTab('ambient')">Ambient</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -261,6 +262,10 @@
|
||||||
{% comment %}Filled by static/js/home.js: the book in reading, the next episode, stations, Ambient.{% endcomment %}
|
{% comment %}Filled by static/js/home.js: the book in reading, the next episode, stations, Ambient.{% endcomment %}
|
||||||
<section class="tab-panel" id="tab-home" style="display:none;"></section>
|
<section class="tab-panel" id="tab-home" style="display:none;"></section>
|
||||||
|
|
||||||
|
<!-- ===== FEEDS TAB ===== -->
|
||||||
|
{% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %}
|
||||||
|
<section class="tab-panel" id="tab-feeds" style="display:none;"></section>
|
||||||
|
|
||||||
<!-- ===== AMBIENT TAB ===== -->
|
<!-- ===== AMBIENT TAB ===== -->
|
||||||
{% comment %}"Drift", a generative ambient synthesizer (static/ambient/), in a frame of its own: its
|
{% comment %}"Drift", a generative ambient synthesizer (static/ambient/), in a frame of its own: its
|
||||||
script and styles stay apart from the page's. Loaded the first time the tab opens.{% endcomment %}
|
script and styles stay apart from the page's. Loaded the first time the tab opens.{% endcomment %}
|
||||||
|
|
@ -345,4 +350,5 @@
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
<script src="/static/js/podcasts-web.js"></script>
|
<script src="/static/js/podcasts-web.js"></script>
|
||||||
<script src="/static/js/home.js"></script>
|
<script src="/static/js/home.js"></script>
|
||||||
|
<script src="/static/js/feeds.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue