Abos & Quellen: alle Abos an einem Ort (Navigation Phase 2)
Ein neuer Abschnitt (docs/sources.md, static/js/sources.js) für Podcasts, YouTube-Kanäle, Hörbücher, Nachrichten und Blogs: - ein Feld zum Hinzufügen: Adresse (Feed, Seite, YouTube) oder Name eines Podcasts; die Art ist wählbar (Podcast, Hörbuch, Nachrichten, Blog); /feeds/discover/ erkennt dafür Podcasts (Folgen mit Ton oder Video); - eine Liste aller Abos mit Filtern (auch „Probleme“) und Suche; - je Abo: Inhalt öffnen, Einstellungen (die vorhandenen Editoren), Art ändern, abbestellen; OPML-Import und -Export. In der Seitenleiste unten, auf dem Telefon im App-Menü; ⚙ in den Feeds führt hierher. Abschnitte, die beim Laden schon gezeigt werden, füllen sich jetzt auch (nav.js zeigt sie nach dem Laden aller Skripte noch einmal). Podcast-UI aus diora-android 50c6f9c. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a48dca07ea
commit
143950df94
13 changed files with 672 additions and 17 deletions
47
docs/sources.md
Normal file
47
docs/sources.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Abos & Quellen (navigation phase 2)
|
||||
|
||||
One place to manage every subscription: podcasts, YouTube channels, audiobooks,
|
||||
news sources and blogs. The everyday views (Neu, Warteschlange, Nachrichten,
|
||||
Blogs, Hörbücher) show content; adding, changing and removing sources happens
|
||||
here (their own small entry points lead here).
|
||||
|
||||
## Where
|
||||
|
||||
`DioraNav` section `sources` ("Abos & Quellen", icon ⚙), panel `#tab-sources`,
|
||||
`group: 'more'`, `footer: true`:
|
||||
- not in `groups()` (no tab of its own on a phone);
|
||||
- in `sections()` with `footer: true`: the sidebar puts it at the bottom, above
|
||||
"Menü"; the phone's app menu has an entry; diora Reader's bar leaves it out;
|
||||
- the web page's bar: at the end (wide), as ⚙ (narrow).
|
||||
|
||||
## The page (`static/js/sources.js`, diora-web, every platform)
|
||||
|
||||
- **Hinzufügen**: one field for an address or search words.
|
||||
- a YouTube address → its channel (`/podcasts/search/?q=`), as Podcast or Hörbuch;
|
||||
- another address → `/feeds/discover/`: every feed found, with the kind it
|
||||
looks like (`suggested_kind`: `podcast` when most entries carry sound or
|
||||
video, else `news`/`blog`) and whether it's subscribed already
|
||||
(`subscribed` as a feed, `podcast_subscribed`); the kind can be changed:
|
||||
Podcast · Hörbuch · Nachrichten · Blog;
|
||||
- words → the podcast directory (`/podcasts/search/`).
|
||||
Podcast/Hörbuch subscribe with `POST /podcasts/feeds/add/` (then
|
||||
`settings {kind: 'audiobook'}`), Nachrichten/Blog with `POST /feeds/sources/add/`.
|
||||
- **The list**: all sources, filter chips Alle · Podcasts · YouTube · Hörbücher ·
|
||||
Nachrichten · Blogs · Probleme (fetching fails), and a search over the names.
|
||||
- **A source's actions** (tapping it): open its content, its settings (the
|
||||
existing editors), change its kind (Podcast ↔ Hörbuch, Nachrichten ↔ Blog),
|
||||
unsubscribe.
|
||||
- **Import & Export**: OPML for podcasts and feeds.
|
||||
|
||||
## Hooks it uses (each optional: without it, it goes to the section instead)
|
||||
|
||||
| Hook | From | Does |
|
||||
|------|------|------|
|
||||
| `__dioraPodcastFeedSettings(feedId)` | native-podcast.js | opens the podcast's settings sheet |
|
||||
| `__dioraPodcastOpenFeed(feedId)` | native-podcast.js | shows the podcast's episode list (Podcasts section) |
|
||||
| `__dioraFeedsEdit(sourceId)` | feeds.js | opens the source's editor (Nachrichten/Blogs, manage view) |
|
||||
| `__dioraFeedsStale()` | feeds.js | its state is old: load again when shown |
|
||||
| `__dioraAudiobookOpen(feedId)` | audiobooks.js | opens the book |
|
||||
|
||||
The host calls it uses: `podcastState`, `podcastFeedSettings`, `podcastRefresh`
|
||||
(after subscribing), `podcastFeedRemoved` (after unsubscribing).
|
||||
|
|
@ -14,7 +14,7 @@ test('wide: every section in the bar under its group, each shows its panel and v
|
|||
const wide = page.locator('#tabs .nav-wide');
|
||||
await expect(wide.locator('.nav-group-label')).toHaveText(['Lesen', 'Hören']);
|
||||
await expect(wide.locator('[data-nav]')).toHaveText(
|
||||
['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus']);
|
||||
['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus', 'Abos & Quellen']);
|
||||
|
||||
await wide.locator('[data-nav="blogs"]').click();
|
||||
await expect(page).toHaveURL(/#blogs$/);
|
||||
|
|
@ -33,7 +33,7 @@ test('narrow: three groups, the sections as a row on top, each group remembers i
|
|||
await page.setViewportSize({ width: 390, height: 800 });
|
||||
await page.goto('/#news');
|
||||
const narrow = page.locator('#tabs .nav-narrow');
|
||||
await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören']);
|
||||
await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören', '⚙']);
|
||||
await expect(narrow.locator('[data-nav="lesen"]')).toHaveClass(/active/);
|
||||
const segs = page.locator('#nav-segments');
|
||||
await expect(segs.locator('[data-nav]')).toHaveText(['Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs']);
|
||||
|
|
@ -59,3 +59,16 @@ test('old links and direct panel switches land in their section', async ({ page
|
|||
await page.reload();
|
||||
expect(await visiblePanel(page)).toEqual(['tab-podcasts']); // remembered
|
||||
});
|
||||
|
||||
test('Abos & Quellen: one page for every subscription, reached from the feeds too', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await page.goto('/#news');
|
||||
await page.locator('#tab-feeds [data-act="manage"]').first().click();
|
||||
await expect(page).toHaveURL(/#sources$/);
|
||||
const tab = page.locator('#tab-sources');
|
||||
await expect(tab.locator('.src-h1')).toHaveText('Abos & Quellen');
|
||||
await expect(tab.locator('.src-add-q')).toBeVisible();
|
||||
await expect(tab.locator('.src-chip.on')).toContainText('Nachrichten'); // came from the news
|
||||
await tab.locator('[data-filter="all"]').click();
|
||||
await expect(tab.locator('.src-chip.on')).toContainText('Alle');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -395,3 +395,29 @@ class FolderAndProgressTest(TestCase):
|
|||
self.assertEqual(r, {'progress': 0.4, 'read': False})
|
||||
self.assertEqual(self.client.get('/feeds/blogs/').json()['items'][0]['progress'], 0.4)
|
||||
self.assertTrue(self.post(f'/feeds/items/{self.post_a.id}/progress/', {'progress': 0.95}).json()['read'])
|
||||
|
||||
|
||||
class DiscoverKindTest(TestCase):
|
||||
"""Abos & Quellen: a feed whose entries carry sound is offered as a podcast."""
|
||||
|
||||
def setUp(self):
|
||||
self.u = User.objects.create_user('u', password='pw12345678')
|
||||
self.client.force_login(self.u)
|
||||
|
||||
def discover(self, items):
|
||||
xml = ('<?xml version="1.0"?><rss version="2.0"><channel><title>X</title>' + ''.join(
|
||||
f'<item><title>Folge {i}</title><guid>g{i}</guid>{extra}</item>' for i, extra in enumerate(items)) +
|
||||
'</channel></rss>')
|
||||
with mock.patch('feeds.views._probe', lambda url: (('https://ex.org/feed', 'X', feedparser.parse(xml)), None)):
|
||||
r = self.client.post('/feeds/discover/', json.dumps({'url': 'https://ex.org/feed'}),
|
||||
content_type='application/json')
|
||||
return r.json()['feeds'][0]
|
||||
|
||||
def test_sound_files_make_a_podcast(self):
|
||||
enc = '<enclosure url="https://ex.org/a.mp3" type="audio/mpeg" length="1"/>'
|
||||
f = self.discover([enc] * 4)
|
||||
self.assertEqual(f['suggested_kind'], 'podcast')
|
||||
self.assertFalse(f['podcast_subscribed'])
|
||||
|
||||
def test_text_entries_stay_news_or_a_blog(self):
|
||||
self.assertIn(self.discover([''] * 4)['suggested_kind'], ('news', 'blog'))
|
||||
|
|
|
|||
|
|
@ -241,20 +241,33 @@ def discover(request):
|
|||
found = [feed]
|
||||
except requests.RequestException:
|
||||
return JsonResponse({'error': 'Die Adresse ist nicht erreichbar.'}, status=502)
|
||||
from podcasts.models import PodcastFeed
|
||||
out = []
|
||||
for u, title, parsed in found:
|
||||
entries = parsed.entries[:5]
|
||||
teaser = sum(1 for e in entries if fetch.is_teaser(fetch.parse_entry(e, u)['content']))
|
||||
# Most entries with a sound or video file: a podcast (Abos & Quellen offers it as one).
|
||||
media = sum(1 for e in parsed.entries[:10] if _has_media(e))
|
||||
podcast = media and media * 2 >= min(len(parsed.entries), 10)
|
||||
out.append({
|
||||
'url': u, 'title': title,
|
||||
'subscribed': Source.objects.filter(user=request.user, url=u).exists(),
|
||||
'podcast_subscribed': PodcastFeed.objects.filter(user=request.user, rss_url=u).exists(),
|
||||
'samples': [fetch.plain(e.get('title') or '') for e in entries],
|
||||
# Many entries a day suggests news, few long ones a blog.
|
||||
'suggested_kind': Source.NEWS if len(parsed.entries) >= 25 or teaser >= 3 else Source.BLOG,
|
||||
'suggested_kind': 'podcast' if podcast
|
||||
else Source.NEWS if len(parsed.entries) >= 25 or teaser >= 3 else Source.BLOG,
|
||||
})
|
||||
return JsonResponse({'feeds': out})
|
||||
|
||||
|
||||
def _has_media(entry):
|
||||
for enc in entry.get('enclosures') or []:
|
||||
if (enc.get('type') or '').lower().startswith(('audio/', 'video/')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _place(user, kind, category, folders, **more):
|
||||
"""Where a new source goes: news into a section (category), a blog into
|
||||
folders (the category of an OPML folder or the add dialog is its folder)."""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// Tabs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks'];
|
||||
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks', 'sources'];
|
||||
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -505,6 +505,8 @@
|
|||
|
||||
// Home's "Weiterhören" card (home.js) plays the book from where it was left.
|
||||
window.__dioraAudiobookGo = feedId => goOn(feedId);
|
||||
// Abos & Quellen (sources.js): open a book.
|
||||
window.__dioraAudiobookOpen = feedId => openBook(Number(feedId));
|
||||
|
||||
// ---- following the player ------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -824,6 +824,13 @@
|
|||
return load();
|
||||
}
|
||||
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);
|
||||
|
|
@ -834,6 +841,7 @@
|
|||
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') {
|
||||
|
|
@ -994,6 +1002,24 @@
|
|||
// ---- 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) {
|
||||
|
|
@ -1054,7 +1080,7 @@
|
|||
const dock = document.createElement('div');
|
||||
dock.id = 'nui-dock';
|
||||
dock.innerHTML = '<nav id="nui-tabbar">' + (NAV
|
||||
? NAV.sections().map(s => '<button type="button" data-nav="' + s.id + '"><span class="nui-tab-icon">' +
|
||||
? NAV.sections().filter(s => !s.footer).map(s => '<button type="button" data-nav="' + s.id + '"><span class="nui-tab-icon">' +
|
||||
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>';
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@
|
|||
{id: 'ambient', group: 'hoeren', label: 'Ambient', icon: '∿', panel: 'ambient'},
|
||||
// The focus timer: the web page's own (the apps never had it).
|
||||
{id: 'focus', group: 'hoeren', label: 'Fokus', icon: '◷', panel: 'focus', webOnly: true},
|
||||
// Managing every subscription (docs/sources.md): at the foot of the sidebar, in the app
|
||||
// menu on a phone — not a tab of its own.
|
||||
{id: 'sources', group: 'more', label: 'Abos & Quellen', icon: '⚙', panel: 'sources', account: true, footer: true},
|
||||
];
|
||||
const GROUPS = [
|
||||
{id: 'home', label: 'Home', icon: '⌂'},
|
||||
|
|
@ -46,19 +49,19 @@
|
|||
if (s.account && !authed) return false;
|
||||
if (s.webOnly && nativeUi()) return false;
|
||||
if (s.needsPodcasts && !podcastHost()) return false;
|
||||
if (readerApp() && s.group !== 'lesen') return false; // diora Reader: reading only
|
||||
if (readerApp() && s.group !== 'lesen' && s.id !== 'sources') return false; // diora Reader: reading only
|
||||
if (noRead() && s.group === 'lesen') return false; // TV: no reading
|
||||
return true;
|
||||
}
|
||||
|
||||
function sections() {
|
||||
return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon}));
|
||||
return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon, footer: !!s.footer}));
|
||||
}
|
||||
|
||||
function groups() {
|
||||
const list = sections();
|
||||
return GROUPS.map(g => ({id: g.id, label: g.label, icon: g.icon,
|
||||
sections: list.filter(s => s.group === g.id).map(s => s.id)})).filter(g => g.sections.length);
|
||||
sections: list.filter(s => s.group === g.id && !s.footer).map(s => s.id)})).filter(g => g.sections.length);
|
||||
}
|
||||
|
||||
function load(key) {
|
||||
|
|
@ -74,7 +77,7 @@
|
|||
|
||||
// A section of `group`: the one used last there, else its first.
|
||||
function lastOf(group) {
|
||||
const list = sections().filter(s => s.group === group);
|
||||
const list = sections().filter(s => s.group === group && !s.footer);
|
||||
const last = load('diora_nav_last_' + group);
|
||||
const found = list.filter(s => s.id === last)[0];
|
||||
return (found || list[0] || {}).id || null;
|
||||
|
|
@ -99,7 +102,7 @@
|
|||
current = id;
|
||||
const s = byId[id];
|
||||
save('diora_nav_section', id);
|
||||
save('diora_nav_last_' + s.group, id);
|
||||
if (!s.footer) save('diora_nav_last_' + s.group, id);
|
||||
syncHash();
|
||||
render();
|
||||
listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} });
|
||||
|
|
@ -188,15 +191,19 @@
|
|||
function render() {
|
||||
const cur = current && byId[current];
|
||||
const gs = groups();
|
||||
const foot = sections().filter(s => s.footer);
|
||||
if (tabs && !nativeUi()) {
|
||||
// Wide: every section under its group; narrow: the groups (their sections on top).
|
||||
tabs.innerHTML =
|
||||
'<div class="nav-wide">' + gs.map(g => g.id === 'home'
|
||||
? button('home', 'Home', 'tab-btn', current === 'home')
|
||||
: '<span class="nav-group-label">' + esc(g.label) + '</span>' +
|
||||
g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') + '</div>' +
|
||||
g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') +
|
||||
(foot.length ? '<span class="nav-foot">' + foot.map(s => button(s.id, s.label, 'tab-btn', current === s.id)).join('') + '</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="nav-narrow">' + gs.map(g =>
|
||||
button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') + '</div>';
|
||||
button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') +
|
||||
foot.map(s => button(s.id, s.icon, 'tab-btn nav-icon', current === s.id)).join('') + '</div>';
|
||||
}
|
||||
if (segs) {
|
||||
const g = cur ? gs.filter(x => x.id === cur.group)[0] : null;
|
||||
|
|
@ -224,6 +231,9 @@
|
|||
tabs.addEventListener('click', onClick);
|
||||
}
|
||||
render();
|
||||
// The section was restored while the page was still loading (99-init.js), before the
|
||||
// areas' scripts (feeds.js, audiobooks.js …) were there to fill it: once more, now.
|
||||
if (current) go(current);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
|
|
@ -257,6 +267,8 @@
|
|||
style.textContent = [
|
||||
'#tabs .nav-wide, #tabs .nav-narrow { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }',
|
||||
'#tabs .nav-narrow { display: none; }',
|
||||
'#tabs .nav-foot { margin-left: auto; }',
|
||||
'#tabs .nav-narrow .nav-icon { margin-left: auto; }',
|
||||
'#tabs .nav-group-label { margin: 0 4px 0 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }',
|
||||
'@media (max-width: 720px) { #tabs .nav-wide { display: none; } #tabs .nav-narrow { display: flex; } }',
|
||||
'#nav-segments { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 12px; scrollbar-width: none; }',
|
||||
|
|
|
|||
481
static/js/sources.js
Normal file
481
static/js/sources.js
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
/*
|
||||
* Abos & Quellen (docs/sources.md): every subscription in one place — podcasts, YouTube
|
||||
* channels, audiobooks, news sources, blogs. One field adds any of them (an address or
|
||||
* search words), one list shows them all, a source's actions open its content, its
|
||||
* settings (the existing editors, through their hooks), change its kind or unsubscribe.
|
||||
* Runs in the web page, the desktop app and the Android apps (diora Reader: news and
|
||||
* blogs only, there are no podcasts). Chrome-61-safe: no ?. / ??.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const tab = document.getElementById('tab-sources');
|
||||
if (!tab) return;
|
||||
|
||||
const st = {
|
||||
filter: 'all',
|
||||
q: '',
|
||||
feeds: null, // /feeds/state/ sources
|
||||
adding: null, // {loading, error, found: [candidate…]}
|
||||
open: null, // key of the source whose actions are shown
|
||||
busy: false,
|
||||
};
|
||||
try {
|
||||
const f = localStorage.getItem('diora_sources_filter');
|
||||
if (f) st.filter = f;
|
||||
} catch (e) {}
|
||||
|
||||
const FILTERS = [
|
||||
['all', 'Alle'], ['podcast', 'Podcasts'], ['youtube', 'YouTube'], ['audiobook', 'Hörbücher'],
|
||||
['news', 'Nachrichten'], ['blog', 'Blogs'], ['problem', 'Probleme'],
|
||||
];
|
||||
const KIND_LABEL = {podcast: 'Podcast', youtube: 'YouTube', audiobook: 'Hörbuch', news: 'Nachrichten', blog: 'Blog'};
|
||||
|
||||
// ---- helpers -------------------------------------------------------------------------
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
||||
({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
||||
}
|
||||
|
||||
function host() {
|
||||
const n = window.DioraNative;
|
||||
if (n && typeof n.podcastState === 'function') return n;
|
||||
return window.DioraPodcastHost || null;
|
||||
}
|
||||
|
||||
function podcastFeeds() {
|
||||
const h = host();
|
||||
if (!h) return [];
|
||||
try { return (JSON.parse(h.podcastState()) || {}).feeds || []; } catch (e) { return []; }
|
||||
}
|
||||
|
||||
async function api(path, body) {
|
||||
const opts = {credentials: 'same-origin', cache: 'no-store'};
|
||||
if (body !== undefined) {
|
||||
opts.method = 'POST';
|
||||
opts.headers = {'Content-Type': 'application/json'};
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(path, opts);
|
||||
const d = await r.json().catch(() => null);
|
||||
if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status));
|
||||
return d;
|
||||
}
|
||||
|
||||
function toast(text) {
|
||||
let t = document.getElementById('src-toast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.id = 'src-toast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = text;
|
||||
t.classList.add('src-show');
|
||||
clearTimeout(toast.timer);
|
||||
toast.timer = setTimeout(() => t.classList.remove('src-show'), 2800);
|
||||
}
|
||||
|
||||
const isYouTubeFeed = url => /^https?:\/\/(www\.)?youtube\.com\/feeds\/videos\.xml/.test(url || '');
|
||||
const isYouTubeAddress = s => /^(https?:\/\/)?((www|m|music)\.)?(youtube\.com|youtu\.be)\//i.test(s.trim());
|
||||
const looksLikeAddress = s => !/\s/.test(s.trim()) && /\.[a-z]{2,}/i.test(s);
|
||||
|
||||
function go(id) {
|
||||
if (window.DioraNav) window.DioraNav.go(id);
|
||||
else if (typeof showTab === 'function') showTab(id);
|
||||
}
|
||||
|
||||
// ---- all sources as one list -----------------------------------------------------------
|
||||
|
||||
function rows() {
|
||||
const out = [];
|
||||
podcastFeeds().forEach(f => {
|
||||
const type = f.kind === 'audiobook' ? 'audiobook' : isYouTubeFeed(f.rss_url) ? 'youtube' : 'podcast';
|
||||
out.push({
|
||||
key: 'p' + f.id, type, id: f.id, title: f.title || f.rss_url, sub: f.author || '',
|
||||
art: f.artwork_url || '', url: f.rss_url,
|
||||
meta: (f.episode_count ? f.episode_count + ' Folgen' : '') + (f.new_count ? ' · ' + f.new_count + ' neu' : ''),
|
||||
problem: (f.error_count || 0) >= 3 ? (f.last_error || 'Abruf schlägt fehl') : '',
|
||||
});
|
||||
});
|
||||
(st.feeds || []).forEach(s => {
|
||||
out.push({
|
||||
key: 'f' + s.id, type: s.kind === 'news' ? 'news' : 'blog', id: s.id, title: s.title || s.url,
|
||||
sub: s.kind === 'news' ? (s.category || 'Allgemein') : (s.folders || []).join(', '),
|
||||
art: s.icon_url || '', url: s.url,
|
||||
meta: s.unread ? s.unread + ' ungelesen' : '',
|
||||
problem: (s.error_count || 0) >= 3 ? (s.last_error || 'Abruf schlägt fehl') : '',
|
||||
});
|
||||
});
|
||||
return out.sort((a, b) => a.title.localeCompare(b.title, 'de', {sensitivity: 'base'}));
|
||||
}
|
||||
|
||||
function shown(list) {
|
||||
const q = st.q.trim().toLowerCase();
|
||||
return list.filter(r =>
|
||||
(st.filter === 'all' || (st.filter === 'problem' ? !!r.problem : r.type === st.filter)) &&
|
||||
(!q || (r.title + ' ' + r.sub + ' ' + r.url).toLowerCase().indexOf(q) >= 0));
|
||||
}
|
||||
|
||||
// ---- drawing ---------------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
const all = rows();
|
||||
const list = shown(all);
|
||||
const count = id => id === 'all' ? all.length
|
||||
: id === 'problem' ? all.filter(r => r.problem).length : all.filter(r => r.type === id).length;
|
||||
let h = '<div class="src">';
|
||||
h += '<h2 class="src-h1">Abos & Quellen</h2>';
|
||||
h += addHtml();
|
||||
h += '<div class="src-chips">' + FILTERS.filter(f => f[0] === 'all' || f[0] === st.filter || count(f[0]))
|
||||
.map(f => '<button type="button" class="src-chip' + (st.filter === f[0] ? ' on' : '') + '" data-filter="' + f[0] + '">' +
|
||||
esc(f[1]) + ' <span class="src-count">' + count(f[0]) + '</span></button>').join('') + '</div>';
|
||||
h += '<input type="search" class="src-q" placeholder="In ' + all.length + ' Abos suchen …" value="' + esc(st.q) + '">';
|
||||
if (st.feeds === null && !all.length) h += '<p class="src-note">Wird geladen …</p>';
|
||||
else if (!list.length) h += '<p class="src-note">' + (all.length ? 'Nichts gefunden.' : 'Noch keine Abos. Oben eine Adresse oder einen Namen eingeben.') + '</p>';
|
||||
h += '<div class="src-list">' + list.map(rowHtml).join('') + '</div>';
|
||||
h += ioHtml();
|
||||
h += '</div>';
|
||||
const q = tab.querySelector('.src-q');
|
||||
const focused = q && document.activeElement === q;
|
||||
tab.innerHTML = h;
|
||||
if (focused) {
|
||||
const n = tab.querySelector('.src-q');
|
||||
n.focus();
|
||||
n.setSelectionRange(n.value.length, n.value.length);
|
||||
}
|
||||
}
|
||||
|
||||
function addHtml() {
|
||||
const a = st.adding;
|
||||
let h = '<form class="src-add" data-form="add"><input class="src-add-q" name="q" autocomplete="off" ' +
|
||||
'placeholder="Adresse (Seite, Feed, YouTube) oder Name eines Podcasts" value="' + esc(a && a.query || '') + '">' +
|
||||
'<button class="src-btn src-primary" type="submit"' + (a && a.loading ? ' disabled' : '') + '>' +
|
||||
(a && a.loading ? '…' : 'Suchen') + '</button></form>';
|
||||
if (!a) return h;
|
||||
if (a.error) h += '<p class="src-warn">' + esc(a.error) + '</p>';
|
||||
if (a.found && a.found.length) {
|
||||
h += '<div class="src-found">' + a.found.map((c, n) =>
|
||||
'<div class="src-cand">' + art(c.art, c.title, 'src-art') +
|
||||
'<div class="src-text"><div class="src-title">' + esc(c.title) + '</div>' +
|
||||
'<div class="src-meta">' + esc(c.sub || c.url) + '</div>' +
|
||||
(c.samples && c.samples.length ? '<div class="src-meta">' + c.samples.slice(0, 2).map(esc).join(' · ') + '</div>' : '') +
|
||||
(c.subscribed ? '<div class="src-meta">✓ schon abonniert</div>' :
|
||||
'<div class="src-kinds">' + c.kinds.map(k =>
|
||||
'<button type="button" class="src-btn' + (k === c.kind ? ' src-primary' : '') + '" data-sub="' + n + '" data-kind="' + k + '">' +
|
||||
(k === c.kind ? '+ ' : '') + esc(KIND_LABEL[k]) + '</button>').join('') + '</div>') +
|
||||
'</div></div>').join('') + '</div>';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function art(url, title, cls) {
|
||||
const u = String(url || '').replace(/^http:/, 'https:');
|
||||
return u ? '<img class="' + cls + '" src="' + esc(u) + '" alt="" loading="lazy">'
|
||||
: '<span class="' + cls + ' src-noart">' + esc((title || '?').trim().charAt(0).toUpperCase()) + '</span>';
|
||||
}
|
||||
|
||||
function rowHtml(r) {
|
||||
const open = st.open === r.key;
|
||||
let h = '<div class="src-row' + (open ? ' src-open' : '') + '"><button type="button" class="src-main" data-row="' + r.key + '">' +
|
||||
art(r.art, r.title, 'src-art') + '<span class="src-text"><span class="src-title">' + esc(r.title) + '</span>' +
|
||||
'<span class="src-meta"><span class="src-kind src-kind-' + r.type + '">' + esc(KIND_LABEL[r.type]) + '</span>' +
|
||||
(r.sub ? ' ' + esc(r.sub) : '') + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' +
|
||||
(r.problem ? '<span class="src-warn">⚠ ' + esc(r.problem) + '</span>' : '') + '</span></button>';
|
||||
if (open) h += actionsHtml(r);
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
function actionsHtml(r) {
|
||||
const a = (act, label, cls) => '<button type="button" class="src-btn' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" data-key="' + r.key + '">' + label + '</button>';
|
||||
const list = [];
|
||||
if (r.type === 'audiobook') list.push(a('open', '▶ Hörbuch öffnen'));
|
||||
else if (r.type === 'news' || r.type === 'blog') list.push(a('open', r.type === 'news' ? 'Nachrichten lesen' : 'Blog lesen'));
|
||||
else list.push(a('open', 'Folgen ansehen'));
|
||||
list.push(a('settings', 'Einstellungen …'));
|
||||
if (r.type === 'podcast' || r.type === 'youtube') list.push(a('to-audiobook', 'Als Hörbuch führen'));
|
||||
if (r.type === 'audiobook') list.push(a('to-podcast', 'Als Podcast führen'));
|
||||
if (r.type === 'news') list.push(a('to-blog', 'Als Blog führen'));
|
||||
if (r.type === 'blog') list.push(a('to-news', 'Als Nachrichten führen'));
|
||||
list.push(a('remove', 'Abbestellen', 'src-danger'));
|
||||
return '<div class="src-actions"><div class="src-meta src-url">' + esc(r.url) + '</div>' + list.join('') + '</div>';
|
||||
}
|
||||
|
||||
function ioHtml() {
|
||||
const h = host();
|
||||
const web = !window.DioraNative;
|
||||
return '<details class="src-io"><summary>Import & Export</summary>' +
|
||||
(h ? '<label class="src-btn src-file">Podcasts aus OPML …<input type="file" accept=".opml,.xml,text/xml,text/x-opml" data-io="podcasts" hidden></label>' : '') +
|
||||
(h && web ? '<a class="src-btn" href="/podcasts/feeds/export.opml">Podcasts als OPML</a>' : '') +
|
||||
'<button type="button" class="src-btn" data-io="feeds-opml">Feeds aus OPML …</button>' +
|
||||
(web ? '<a class="src-btn" href="/feeds/export.opml">Feeds als OPML</a>' : '') +
|
||||
'<p class="src-note">Feeds aus einer OPML-Datei werden in der Feed-Verwaltung übernommen (mit Vorschau).</p></details>';
|
||||
}
|
||||
|
||||
// ---- adding --------------------------------------------------------------------------------
|
||||
|
||||
async function find(query) {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
st.adding = {query: q, loading: true};
|
||||
render();
|
||||
try {
|
||||
let found = [];
|
||||
const podcasts = !!host();
|
||||
if (isYouTubeAddress(q) && podcasts) {
|
||||
const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
|
||||
found = (d.results || []).map(r => ({title: r.title, sub: 'YouTube', url: r.rss_url, art: r.artwork_url,
|
||||
kind: 'podcast', kinds: ['podcast', 'audiobook'], subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
|
||||
if (!found.length) throw new Error('Dort ist kein YouTube-Kanal zu finden.');
|
||||
} else if (looksLikeAddress(q)) {
|
||||
const d = await api('/feeds/discover/', {url: q});
|
||||
found = (d.feeds || []).map(f => {
|
||||
const kind = f.suggested_kind === 'podcast' && !podcasts ? 'blog' : f.suggested_kind;
|
||||
return {title: f.title, url: f.url, samples: f.samples, kind,
|
||||
kinds: podcasts ? ['podcast', 'audiobook', 'news', 'blog'] : ['news', 'blog'],
|
||||
subscribed: kind === 'podcast' || kind === 'audiobook' ? f.podcast_subscribed : f.subscribed};
|
||||
});
|
||||
} else if (podcasts) {
|
||||
const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
|
||||
found = (d.results || []).filter(r => r.rss_url).slice(0, 12).map(r => ({title: r.title, sub: r.author,
|
||||
url: r.rss_url, art: r.artwork_url, kind: 'podcast', kinds: ['podcast', 'audiobook'],
|
||||
subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
|
||||
if (!found.length) throw new Error('Kein Podcast dieses Namens gefunden.');
|
||||
} else {
|
||||
throw new Error('Bitte die Adresse einer Seite oder eines Feeds eingeben.');
|
||||
}
|
||||
st.adding = {query: q, found};
|
||||
} catch (e) {
|
||||
st.adding = {query: q, error: navigator.onLine ? (e.message || 'Hat nicht geklappt.') : 'Das braucht eine Verbindung.'};
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
async function subscribe(c, kind) {
|
||||
if (st.busy) return;
|
||||
st.busy = true;
|
||||
toast('Wird abonniert …');
|
||||
try {
|
||||
if (kind === 'podcast' || kind === 'audiobook') {
|
||||
const d = await api('/podcasts/feeds/add/', {rss_url: c.url, title: c.title});
|
||||
if (kind === 'audiobook') await api('/podcasts/feeds/' + d.feed_id + '/settings/', {kind: 'audiobook'});
|
||||
refreshPodcasts();
|
||||
toast('„' + (d.title || c.title) + '“ abonniert' + (kind === 'audiobook' ? ' (Hörbuch)' : ''));
|
||||
} else {
|
||||
await api('/feeds/sources/add/', {url: c.url, title: c.title, kind});
|
||||
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
|
||||
toast('„' + c.title + '“ abonniert');
|
||||
await loadFeeds();
|
||||
}
|
||||
c.subscribed = true;
|
||||
} catch (e) {
|
||||
toast(navigator.onLine ? 'Das ließ sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.');
|
||||
}
|
||||
st.busy = false;
|
||||
render();
|
||||
}
|
||||
|
||||
function refreshPodcasts() {
|
||||
const h = host();
|
||||
// The server read the new feed already: the host only needs its state again.
|
||||
if (h && typeof h.podcastRefresh === 'function') h.podcastRefresh(false);
|
||||
// The host's state comes a moment later.
|
||||
setTimeout(render, 1500);
|
||||
setTimeout(render, 4000);
|
||||
}
|
||||
|
||||
// ---- a source's actions -----------------------------------------------------------------------
|
||||
|
||||
async function act(name, r) {
|
||||
const h = host();
|
||||
if (name === 'open') {
|
||||
if (r.type === 'audiobook') {
|
||||
go('audiobooks');
|
||||
if (window.__dioraAudiobookOpen) window.__dioraAudiobookOpen(r.id);
|
||||
} else if (r.type === 'news' || r.type === 'blog') {
|
||||
go(r.type === 'news' ? 'news' : 'blogs');
|
||||
} else if (window.__dioraPodcastOpenFeed) {
|
||||
window.__dioraPodcastOpenFeed(r.id);
|
||||
} else {
|
||||
go('podcasts');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (name === 'settings') {
|
||||
if (r.type === 'news' || r.type === 'blog') {
|
||||
if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(r.id);
|
||||
else go(r.type === 'news' ? 'news' : 'blogs');
|
||||
} else if (window.__dioraPodcastFeedSettings) {
|
||||
window.__dioraPodcastFeedSettings(r.id);
|
||||
} else if (window.__dioraPodcastOpenFeed) {
|
||||
window.__dioraPodcastOpenFeed(r.id);
|
||||
} else {
|
||||
go('podcasts');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (name === 'to-audiobook' || name === 'to-podcast') {
|
||||
if (!h) return;
|
||||
h.podcastFeedSettings(r.id, JSON.stringify({kind: name === 'to-audiobook' ? 'audiobook' : 'podcast'}));
|
||||
toast(name === 'to-audiobook' ? '„' + r.title + '“ ist jetzt ein Hörbuch' : '„' + r.title + '“ ist wieder ein Podcast');
|
||||
st.open = null;
|
||||
render();
|
||||
setTimeout(render, 600);
|
||||
return;
|
||||
}
|
||||
if (name === 'to-blog' || name === 'to-news') {
|
||||
try {
|
||||
await api('/feeds/sources/' + r.id + '/', {kind: name === 'to-blog' ? 'blog' : 'news'});
|
||||
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
|
||||
await loadFeeds();
|
||||
} catch (e) { toast('Das braucht eine Verbindung.'); }
|
||||
return;
|
||||
}
|
||||
if (name === 'remove') {
|
||||
const ok = typeof customConfirm === 'function'
|
||||
? await customConfirm('„' + r.title + '“ abbestellen?', {danger: true})
|
||||
: window.confirm('„' + r.title + '“ abbestellen?');
|
||||
if (!ok) return;
|
||||
try {
|
||||
if (r.type === 'news' || r.type === 'blog') {
|
||||
await api('/feeds/sources/' + r.id + '/delete/', {});
|
||||
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
|
||||
await loadFeeds();
|
||||
} else {
|
||||
await api('/podcasts/feeds/' + r.id + '/remove/', {});
|
||||
if (h && typeof h.podcastFeedRemoved === 'function') h.podcastFeedRemoved();
|
||||
setTimeout(render, 1500);
|
||||
}
|
||||
st.open = null;
|
||||
toast('„' + r.title + '“ abbestellt');
|
||||
} catch (e) { toast('Das braucht eine Verbindung.'); }
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function importPodcasts(file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
toast('Wird übernommen …');
|
||||
try {
|
||||
const r = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form, credentials: 'same-origin'});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || r.status);
|
||||
toast((d.added || 0) + ' Podcasts übernommen');
|
||||
refreshPodcasts();
|
||||
} catch (e) {
|
||||
toast('Die Datei ließ sich nicht übernehmen.');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- events ---------------------------------------------------------------------------------
|
||||
|
||||
tab.addEventListener('submit', e => {
|
||||
const form = e.target.closest('[data-form="add"]');
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
find(form.q.value);
|
||||
});
|
||||
tab.addEventListener('input', e => {
|
||||
if (!e.target.classList.contains('src-q')) return;
|
||||
st.q = e.target.value;
|
||||
render();
|
||||
});
|
||||
tab.addEventListener('change', e => {
|
||||
const io = e.target.dataset && e.target.dataset.io;
|
||||
if (io === 'podcasts' && e.target.files && e.target.files[0]) importPodcasts(e.target.files[0]);
|
||||
});
|
||||
tab.addEventListener('click', e => {
|
||||
const t = e.target.closest('button, label');
|
||||
if (!t) return;
|
||||
if (t.dataset.filter) {
|
||||
st.filter = t.dataset.filter;
|
||||
try { localStorage.setItem('diora_sources_filter', st.filter); } catch (err) {}
|
||||
render();
|
||||
} else if (t.dataset.row) {
|
||||
st.open = st.open === t.dataset.row ? null : t.dataset.row;
|
||||
render();
|
||||
} else if (t.dataset.act) {
|
||||
const r = rows().filter(x => x.key === t.dataset.key)[0];
|
||||
if (r) act(t.dataset.act, r);
|
||||
} else if (t.dataset.sub != null) {
|
||||
const c = st.adding && st.adding.found && st.adding.found[Number(t.dataset.sub)];
|
||||
if (c) subscribe(c, t.dataset.kind);
|
||||
} else if (t.dataset.io === 'feeds-opml') {
|
||||
if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(null);
|
||||
else go('news');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- loading, showing --------------------------------------------------------------------------
|
||||
|
||||
async function loadFeeds() {
|
||||
try {
|
||||
const d = await api('/feeds/state/');
|
||||
st.feeds = d.sources || [];
|
||||
} catch (e) {
|
||||
if (st.feeds === null) st.feeds = [];
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function show() {
|
||||
// Another view may have chosen what to show (feeds.js: its own kind).
|
||||
try {
|
||||
const f = localStorage.getItem('diora_sources_filter');
|
||||
if (f && FILTERS.some(x => x[0] === f)) st.filter = f;
|
||||
} catch (e) {}
|
||||
render();
|
||||
loadFeeds();
|
||||
}
|
||||
|
||||
const showTab = window.showTab;
|
||||
if (typeof showTab === 'function') {
|
||||
window.showTab = function (name) {
|
||||
const r = showTab.apply(this, arguments);
|
||||
if (name === 'sources') show();
|
||||
return r;
|
||||
};
|
||||
}
|
||||
if (tab.style.display !== 'none') show();
|
||||
|
||||
// ---- styles --------------------------------------------------------------------------------------
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = [
|
||||
'#tab-sources .src { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
|
||||
'#tab-sources .src-h1 { font-size: 18px; margin: 4px 0 12px; }',
|
||||
'#tab-sources .src-add { display: flex; gap: 8px; }',
|
||||
'#tab-sources .src-add-q, #tab-sources .src-q { flex: 1; min-width: 0; box-sizing: border-box; padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }',
|
||||
'#tab-sources .src-q { width: 100%; margin: 8px 0 4px; }',
|
||||
'#tab-sources .src-btn { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; text-decoration: none; margin: 4px 6px 0 0; }',
|
||||
'#tab-sources .src-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
|
||||
'#tab-sources .src-danger { color: var(--accent, #e63946); }',
|
||||
'#tab-sources .src-btn[disabled] { opacity: .5; }',
|
||||
'#tab-sources .src-found { margin: 10px 0 4px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); }',
|
||||
'#tab-sources .src-cand { display: flex; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border, #2a2a2a); }',
|
||||
'#tab-sources .src-cand:last-child { border-bottom: none; }',
|
||||
'#tab-sources .src-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0 4px; }',
|
||||
'#tab-sources .src-chip { min-height: 34px; padding: 0 12px; border-radius: 17px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
|
||||
'#tab-sources .src-chip.on { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
|
||||
'#tab-sources .src-count { opacity: .7; font-size: 12px; }',
|
||||
'#tab-sources .src-row { border-bottom: 1px solid var(--border, #2a2a2a); }',
|
||||
'#tab-sources .src-main { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; background: none; border: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
|
||||
'#tab-sources .src-main:hover { background: var(--hover, rgba(255,255,255,.04)); }',
|
||||
'#tab-sources .src-art { width: 40px; height: 40px; flex: none; border-radius: 6px; object-fit: cover; background: var(--panel, #161616); }',
|
||||
'#tab-sources .src-noart { display: flex; align-items: center; justify-content: center; color: var(--muted, #9a9a9a); }',
|
||||
'#tab-sources .src-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
|
||||
'#tab-sources .src-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
|
||||
'#tab-sources .src-meta, #tab-sources .src-note { color: var(--muted, #9a9a9a); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }',
|
||||
'#tab-sources .src-url { white-space: nowrap; margin-bottom: 4px; }',
|
||||
'#tab-sources .src-warn { color: var(--accent, #e63946); font-size: 12px; }',
|
||||
'#tab-sources .src-kind { display: inline-block; padding: 0 6px; border-radius: 8px; background: var(--raised, rgba(255,255,255,.12)); color: var(--fg, #eee); font-size: 11px; }',
|
||||
'#tab-sources .src-kind-audiobook, #tab-sources .src-kind-youtube { background: var(--accent, #e63946); color: var(--on-accent, #fff); }',
|
||||
'#tab-sources .src-actions { padding: 0 0 12px 50px; }',
|
||||
'#tab-sources .src-io { margin-top: 22px; }',
|
||||
'#tab-sources .src-io summary { cursor: pointer; color: var(--muted, #9a9a9a); font-size: 13px; padding: 6px 0; }',
|
||||
'#src-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
|
||||
'#src-toast.src-show { opacity: 1; }',
|
||||
'body.eink #tab-sources .src-primary, body.eink #tab-sources .src-chip.on { background: #000; color: #fff; }',
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v116';
|
||||
const CACHE = 'diora-v117';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app/01-core.js',
|
||||
|
|
@ -42,6 +42,7 @@ const SHELL = [
|
|||
'/static/js/feeds.js',
|
||||
'/static/js/audiobooks.js',
|
||||
'/static/js/nav.js',
|
||||
'/static/js/sources.js',
|
||||
'/static/podcasts/native-podcast.js',
|
||||
'/static/podcasts/podcast-ui.css',
|
||||
'/static/podcasts/podcasts-web.css',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copied from diora-android 26f5019 2026-10-16: web-native/static/native-podcast.js
|
||||
// Copied from diora-android 50c6f9c 2026-10-16: web-native/static/native-podcast.js
|
||||
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
||||
// and copy again. The browser host is static/js/podcasts-web.js.
|
||||
|
||||
|
|
@ -105,6 +105,9 @@
|
|||
return Math.max(n, total - Math.max(0, sent - n));
|
||||
}
|
||||
const feeds = () => state.feeds || [];
|
||||
// "Abos & Quellen" (diora-web docs/sources.md, sources.js): where subscriptions are managed, if the page has it.
|
||||
const hasSources = () => !!window.DioraNav && DioraNav.sections().some(s => s.id === 'sources');
|
||||
const toSources = () => DioraNav.go('sources');
|
||||
// Audiobooks (diora-web docs/audiobooks.md) are listened to in the library ("Bücher"), not here.
|
||||
const isBook = f => !!f && f.kind === 'audiobook';
|
||||
const podcastFeeds = () => feeds().filter(f => !isBook(f));
|
||||
|
|
@ -494,7 +497,8 @@
|
|||
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
|
||||
{icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml},
|
||||
{icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml},
|
||||
]),
|
||||
hasSources() && {icon: '⚙', label: 'Abos & Quellen', hint: 'alle Abos an einem Ort: Podcasts, YouTube, Nachrichten, Blogs', action: toSources},
|
||||
].filter(Boolean)),
|
||||
{icon: '▤', label: 'Hörstatistik', action: openStats},
|
||||
]);
|
||||
}
|
||||
|
|
@ -1805,7 +1809,10 @@
|
|||
}
|
||||
const head = el('div', 'nui-pod-head');
|
||||
head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`),
|
||||
listTools([button('⇅', 'Sortieren und Zahl wählen', feedSortMenu)]));
|
||||
listTools([
|
||||
hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources),
|
||||
button('⇅', 'Sortieren und Zahl wählen', feedSortMenu),
|
||||
].filter(Boolean)));
|
||||
root.append(head);
|
||||
const list = el('div', 'nui-pod-list');
|
||||
for (const f of items) list.append(feedRow(f));
|
||||
|
|
@ -1813,6 +1820,22 @@
|
|||
if (books) root.append(booksHint());
|
||||
}
|
||||
|
||||
// For Abos & Quellen (sources.js): a podcast's settings sheet, its episode list.
|
||||
window.__dioraPodcastFeedSettings = function (feedId) {
|
||||
const f = feeds().find(x => x.id === Number(feedId));
|
||||
if (!f) return false;
|
||||
feedSettingsSheet(f);
|
||||
return true;
|
||||
};
|
||||
window.__dioraPodcastOpenFeed = function (feedId) {
|
||||
const f = feeds().find(x => x.id === Number(feedId));
|
||||
if (!f) return false;
|
||||
if (window.DioraNav) DioraNav.go('podcasts');
|
||||
else if (typeof showTab === 'function') showTab('podcasts');
|
||||
openFeed(f);
|
||||
return true;
|
||||
};
|
||||
|
||||
async function openFeed(f) {
|
||||
feedView = {feed: f, episodes: null};
|
||||
selected = null;
|
||||
|
|
@ -2542,6 +2565,12 @@
|
|||
search(input.value.trim());
|
||||
});
|
||||
root.append(form);
|
||||
if (hasSources()) {
|
||||
const more = el('button', 'nui-pod-hint nui-pod-link', 'Alles abonnieren und verwalten – auch YouTube, Nachrichten, Blogs: Abos & Quellen ›');
|
||||
more.type = 'button';
|
||||
more.addEventListener('click', toSources);
|
||||
root.append(more);
|
||||
}
|
||||
const s = searchResults;
|
||||
if (!s) {
|
||||
empty('Sucht in deinen Abos und ihren Folgen (auch in den Shownotes) und im Podcast-Verzeichnis von Apple. ' +
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copied from diora-android 26f5019 2026-10-16: the podcast parts of web-native/static/native.css
|
||||
/* Copied from diora-android 50c6f9c 2026-10-16: the podcast parts of web-native/static/native.css
|
||||
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
||||
The web's own additions are in podcasts-web.css. */
|
||||
|
||||
|
|
@ -196,6 +196,8 @@ html.nui-one-player #nui-mini { display: none !important; }
|
|||
.nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); }
|
||||
/* Where audiobook subscriptions went (they are in the library, see audiobooks.js). */
|
||||
.nui-pod-hint { padding: 14px 2px; font-size: 13px; color: var(--nui-muted); font-family: var(--nui-sans); letter-spacing: normal; }
|
||||
/* A hint that leads somewhere (Abos & Quellen). */
|
||||
button.nui-pod-link { display: block; width: 100%; border: none; background: none; text-align: left; cursor: pointer; padding: 4px 2px 12px; }
|
||||
.nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; font-size: 14px; }
|
||||
|
||||
.nui-pod-list { display: flex; flex-direction: column; }
|
||||
|
|
|
|||
|
|
@ -265,6 +265,8 @@
|
|||
<!-- ===== FEEDS TAB ===== -->
|
||||
{% comment %}Audiobooks (podcasts marked as one, docs/audiobooks.md): filled by static/js/audiobooks.js.{% endcomment %}
|
||||
{% if user.is_authenticated %}<section class="tab-panel" id="tab-audiobooks" style="display:none;"></section>{% endif %}
|
||||
{% comment %}Abos & Quellen, every subscription in one place (docs/sources.md): filled by static/js/sources.js.{% endcomment %}
|
||||
{% if user.is_authenticated %}<section class="tab-panel" id="tab-sources" style="display:none;"></section>{% endif %}
|
||||
{% 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>
|
||||
|
||||
|
|
@ -387,4 +389,5 @@
|
|||
<script src="/static/js/home.js"></script>
|
||||
<script src="/static/js/feeds.js"></script>
|
||||
<script src="/static/js/audiobooks.js"></script>
|
||||
<script src="/static/js/sources.js"></script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue